From adbbf4775a35a0cf39582b085f764114a479a1af Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:51:02 +0100 Subject: [PATCH] feat(integrations): add integration framework with link unfurling --- .env.example | 11 + .../public/locales/en-US/translation.json | 20 + apps/client/src/App.tsx | 6 + .../src/components/icons/github-icon.tsx | 22 + .../src/components/icons/gitlab-icon.tsx | 32 ++ .../src/components/icons/google-docs-icon.tsx | 28 + apps/client/src/components/icons/index.ts | 10 +- .../client/src/components/icons/jira-icon.tsx | 38 ++ .../src/components/icons/linear-icon.tsx | 21 + .../client/src/components/icons/miro-icon.tsx | 13 +- .../src/components/icons/slack-icon.tsx | 32 ++ .../components/settings/settings-sidebar.tsx | 12 + apps/client/src/ee/features.ts | 1 + .../common/editor-paste-handler.tsx | 67 +++ .../integration-link/badge-color.ts | 49 ++ .../integration-link-view.module.css | 65 +++ .../integration-link-view.tsx | 525 ++++++++++++++++++ .../integration-mention-view.tsx | 253 +++++++++ .../integration-paste-menu.tsx | 205 +++++++ .../integration-link/use-unfurl.test.tsx | 119 ++++ .../components/integration-link/use-unfurl.ts | 47 ++ .../features/editor/extensions/extensions.ts | 12 + .../extensions/integration-paste-menu.ts | 44 ++ .../src/features/editor/page-editor.tsx | 15 + .../integration/components/connection-row.tsx | 93 ++++ .../components/integration-icons.tsx | 30 + .../components/integration-list-skeleton.tsx | 66 +++ .../components/integration-row.tsx | 93 ++++ .../integration/pages/connections.tsx | 134 +++++ .../integration/pages/integrations.tsx | 126 +++++ .../features/integration/pages/slack-link.tsx | 112 ++++ .../integration/queries/integration-query.ts | 100 ++++ .../services/integration-service.ts | 92 +++ .../services/slack-link-service.ts | 23 + .../integration/types/integration.types.ts | 69 +++ apps/client/src/lib/app-route.ts | 1 + apps/server/src/app.module.ts | 2 + .../src/collaboration/collaboration.util.ts | 4 + apps/server/src/common/events/audit-events.ts | 1 - .../src/common/events/event.contants.ts | 1 + apps/server/src/common/features.ts | 1 + apps/server/src/common/proxy-fetch.spec.ts | 64 +++ apps/server/src/common/proxy-fetch.ts | 38 ++ apps/server/src/core/core.module.ts | 7 + apps/server/src/core/integration/constants.ts | 9 + .../core/integration/dto/integration.dto.ts | 57 ++ .../integration-connection.service.ts | 89 +++ .../integration/integration.controller.ts | 138 +++++ .../core/integration/integration.listener.ts | 55 ++ .../core/integration/integration.module.ts | 37 ++ .../core/integration/integration.processor.ts | 133 +++++ .../core/integration/integration.service.ts | 80 +++ .../integration/oauth/oauth.controller.ts | 159 ++++++ .../core/integration/oauth/oauth.service.ts | 473 ++++++++++++++++ .../providers/github/github-patterns.ts | 72 +++ .../providers/github/github.module.ts | 21 + .../providers/github/github.provider.ts | 154 +++++ .../providers/github/github.service.ts | 267 +++++++++ .../providers/gitlab/gitlab-patterns.ts | 68 +++ .../providers/gitlab/gitlab.module.ts | 21 + .../providers/gitlab/gitlab.provider.ts | 188 +++++++ .../providers/gitlab/gitlab.service.ts | 253 +++++++++ .../integration-provider.interface.ts | 169 ++++++ .../registry/integration-registry.ts | 47 ++ .../repos/integration-connection.repo.ts | 361 ++++++++++++ .../integration/repos/integration.repo.ts | 127 +++++ .../integration/unfurl/unfurl.controller.ts | 35 ++ .../core/integration/unfurl/unfurl.service.ts | 268 +++++++++ .../core/integration/utils/provider-fetch.ts | 67 +++ .../core/integration/utils/relative-time.ts | 5 + .../core/notification/notification.service.ts | 5 + .../page/page-access/page-access.service.ts | 13 + apps/server/src/core/search/search.service.ts | 72 +++ .../20260807T1264122-integrations.ts | 92 +++ apps/server/src/database/types/db.d.ts | 30 + .../server/src/database/types/db.interface.ts | 3 + .../server/src/database/types/entity.types.ts | 15 + apps/server/src/ee | 2 +- .../encryption/encryption.errors.ts | 13 + .../encryption/encryption.module.ts | 9 + .../encryption/encryption.service.spec.ts | 184 ++++++ .../encryption/encryption.service.ts | 108 ++++ .../environment/domain.service.ts | 16 + .../environment/environment.service.ts | 4 + .../queue/constants/queue.constants.ts | 14 + .../src/integrations/queue/queue.module.ts | 8 + apps/server/src/main.ts | 4 + packages/editor-ext/src/index.ts | 1 + .../src/lib/integration-link/index.ts | 15 + .../integration-link-patterns.ts | 383 +++++++++++++ .../lib/integration-link/integration-link.ts | 107 ++++ .../integration-link/integration-mention.ts | 99 ++++ 92 files changed, 7248 insertions(+), 6 deletions(-) create mode 100644 apps/client/src/components/icons/github-icon.tsx create mode 100644 apps/client/src/components/icons/gitlab-icon.tsx create mode 100644 apps/client/src/components/icons/google-docs-icon.tsx create mode 100644 apps/client/src/components/icons/jira-icon.tsx create mode 100644 apps/client/src/components/icons/linear-icon.tsx create mode 100644 apps/client/src/components/icons/slack-icon.tsx create mode 100644 apps/client/src/features/editor/components/integration-link/badge-color.ts create mode 100644 apps/client/src/features/editor/components/integration-link/integration-link-view.module.css create mode 100644 apps/client/src/features/editor/components/integration-link/integration-link-view.tsx create mode 100644 apps/client/src/features/editor/components/integration-link/integration-mention-view.tsx create mode 100644 apps/client/src/features/editor/components/integration-link/integration-paste-menu.tsx create mode 100644 apps/client/src/features/editor/components/integration-link/use-unfurl.test.tsx create mode 100644 apps/client/src/features/editor/components/integration-link/use-unfurl.ts create mode 100644 apps/client/src/features/editor/extensions/integration-paste-menu.ts create mode 100644 apps/client/src/features/integration/components/connection-row.tsx create mode 100644 apps/client/src/features/integration/components/integration-icons.tsx create mode 100644 apps/client/src/features/integration/components/integration-list-skeleton.tsx create mode 100644 apps/client/src/features/integration/components/integration-row.tsx create mode 100644 apps/client/src/features/integration/pages/connections.tsx create mode 100644 apps/client/src/features/integration/pages/integrations.tsx create mode 100644 apps/client/src/features/integration/pages/slack-link.tsx create mode 100644 apps/client/src/features/integration/queries/integration-query.ts create mode 100644 apps/client/src/features/integration/services/integration-service.ts create mode 100644 apps/client/src/features/integration/services/slack-link-service.ts create mode 100644 apps/client/src/features/integration/types/integration.types.ts create mode 100644 apps/server/src/common/proxy-fetch.spec.ts create mode 100644 apps/server/src/common/proxy-fetch.ts create mode 100644 apps/server/src/core/integration/constants.ts create mode 100644 apps/server/src/core/integration/dto/integration.dto.ts create mode 100644 apps/server/src/core/integration/integration-connection.service.ts create mode 100644 apps/server/src/core/integration/integration.controller.ts create mode 100644 apps/server/src/core/integration/integration.listener.ts create mode 100644 apps/server/src/core/integration/integration.module.ts create mode 100644 apps/server/src/core/integration/integration.processor.ts create mode 100644 apps/server/src/core/integration/integration.service.ts create mode 100644 apps/server/src/core/integration/oauth/oauth.controller.ts create mode 100644 apps/server/src/core/integration/oauth/oauth.service.ts create mode 100644 apps/server/src/core/integration/providers/github/github-patterns.ts create mode 100644 apps/server/src/core/integration/providers/github/github.module.ts create mode 100644 apps/server/src/core/integration/providers/github/github.provider.ts create mode 100644 apps/server/src/core/integration/providers/github/github.service.ts create mode 100644 apps/server/src/core/integration/providers/gitlab/gitlab-patterns.ts create mode 100644 apps/server/src/core/integration/providers/gitlab/gitlab.module.ts create mode 100644 apps/server/src/core/integration/providers/gitlab/gitlab.provider.ts create mode 100644 apps/server/src/core/integration/providers/gitlab/gitlab.service.ts create mode 100644 apps/server/src/core/integration/registry/integration-provider.interface.ts create mode 100644 apps/server/src/core/integration/registry/integration-registry.ts create mode 100644 apps/server/src/core/integration/repos/integration-connection.repo.ts create mode 100644 apps/server/src/core/integration/repos/integration.repo.ts create mode 100644 apps/server/src/core/integration/unfurl/unfurl.controller.ts create mode 100644 apps/server/src/core/integration/unfurl/unfurl.service.ts create mode 100644 apps/server/src/core/integration/utils/provider-fetch.ts create mode 100644 apps/server/src/core/integration/utils/relative-time.ts create mode 100644 apps/server/src/database/migrations/20260807T1264122-integrations.ts create mode 100644 apps/server/src/integrations/encryption/encryption.errors.ts create mode 100644 apps/server/src/integrations/encryption/encryption.module.ts create mode 100644 apps/server/src/integrations/encryption/encryption.service.spec.ts create mode 100644 apps/server/src/integrations/encryption/encryption.service.ts create mode 100644 packages/editor-ext/src/lib/integration-link/index.ts create mode 100644 packages/editor-ext/src/lib/integration-link/integration-link-patterns.ts create mode 100644 packages/editor-ext/src/lib/integration-link/integration-link.ts create mode 100644 packages/editor-ext/src/lib/integration-link/integration-mention.ts diff --git a/.env.example b/.env.example index 6c5756fad..77ccb39ec 100644 --- a/.env.example +++ b/.env.example @@ -51,6 +51,17 @@ DRAWIO_URL= # Gotenberg URL for server-side PDF export GOTENBERG_URL= +# Integration OAuth apps (create one per provider you enable) +INTEGRATION_GITHUB_CLIENT_ID= +INTEGRATION_GITHUB_CLIENT_SECRET= +# Set only for GitHub Enterprise Server, e.g https://github.example.com +INTEGRATION_GITHUB_BASE_URL= + +INTEGRATION_GITLAB_CLIENT_ID= +INTEGRATION_GITLAB_CLIENT_SECRET= +# Set only for self-hosted GitLab, e.g https://gitlab.example.com +INTEGRATION_GITLAB_BASE_URL= + DISABLE_TELEMETRY=false # Allow other sites to embed Docmost in an iframe. diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 03e10c53e..e8110cf6d 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -14,6 +14,7 @@ "Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.", "Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Are you sure you want to remove this user from the space? The user will lose all access to this space.", "Are you sure you want to restore this version? Any changes not versioned will be lost.": "Are you sure you want to restore this version? Any changes not versioned will be lost.", + "Assigned to {{name}}": "Assigned to {{name}}", "Can become members of groups and spaces in workspace": "Can become members of groups and spaces in workspace", "Can create and edit pages in space.": "Can create and edit pages in space.", "Can edit": "Can edit", @@ -22,6 +23,7 @@ "Can view": "Can view", "Can view pages in space but not edit.": "Can view pages in space but not edit.", "Cancel": "Cancel", + "Card": "Card", "Change email": "Change email", "Change password": "Change password", "Change photo": "Change photo", @@ -30,6 +32,8 @@ "Choose your preferred interface language.": "Choose your preferred interface language.", "Choose your preferred page width.": "Choose your preferred page width.", "Confirm": "Confirm", + "Connect to {{name}} to preview": "Connect to {{name}} to preview", + "Connected as {{label}}": "Connected as {{label}}", "Copy as Markdown": "Copy as Markdown", "Copy link": "Copy link", "Create": "Create", @@ -41,6 +45,16 @@ "Dark": "Dark", "Date": "Date", "Delete": "Delete", + "Initiative": "Initiative", + "Last modified by {{name}}": "Last modified by {{name}}", + "Open in Slack": "Open in Slack", + "Paid": "Paid", + "Paste as": "Paste as", + "Project": "Project", + "Uninstall {{name}}": "Uninstall {{name}}", + "Disconnect {{name}}": "Disconnect {{name}}", + "This disconnects your {{name}} account. Links are not enriched for you until you reconnect.": "This disconnects your {{name}} account. Links are not enriched for you until you reconnect.", + "This disables the {{name}} integration for the entire workspace. Members' connections are removed and links are not enriched.": "This disables the {{name}} integration for the entire workspace. Members' connections are removed and links are not enriched.", "Remove from page": "Remove from page", "Base options": "Base options", "Delete group": "Delete group", @@ -150,6 +164,8 @@ "page": "page", "Page deleted successfully": "Page deleted successfully", "Page history": "Page history", + "replies": "replies", + "reply": "reply", "Select version": "Select version", "Highlight changes": "Highlight changes", "Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.", @@ -184,6 +200,8 @@ "Invitation sent": "Invitation sent", "Settings": "Settings", "Setup workspace": "Setup workspace", + "show less": "show less", + "show more": "show more", "Sign In": "Sign In", "Sign Up": "Sign Up", "Slug": "Slug", @@ -206,7 +224,9 @@ "Theme": "Theme", "To change your email, you have to enter your password and new email.": "To change your email, you have to enter your password and new email.", "Toggle full page width": "Toggle full page width", + "Toggle {{name}} integration": "Toggle {{name}} integration", "Unable to import pages. Please try again.": "Unable to import pages. Please try again.", + "Unassigned": "Unassigned", "untitled": "untitled", "Untitled": "Untitled", "Updated successfully": "Updated successfully", diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index ab291ffea..8200414bd 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -38,6 +38,9 @@ import SpaceTrash from "@/pages/space/space-trash.tsx"; import UserApiKeys from "@/ee/api-key/pages/user-api-keys"; import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys"; import AiSettings from "@/ee/ai/pages/ai-settings.tsx"; +import Integrations from "@/features/integration/pages/integrations.tsx"; +import Connections from "@/features/integration/pages/connections.tsx"; +import SlackLinkPage from "@/features/integration/pages/slack-link.tsx"; import BasePage from "@/ee/base/pages/base-page.tsx"; import AuditLogs from "@/ee/audit/pages/audit-logs.tsx"; import VerifiedPages from "@/ee/page-verification/pages/verified-pages.tsx"; @@ -87,6 +90,7 @@ export default function App() { } /> } /> } /> + } /> }> } /> @@ -116,6 +120,7 @@ export default function App() { element={} /> } /> + } /> } /> } /> } /> @@ -128,6 +133,7 @@ export default function App() { } /> } /> } /> + } /> {!isCloud() && } />} {isCloud() && } />} diff --git a/apps/client/src/components/icons/github-icon.tsx b/apps/client/src/components/icons/github-icon.tsx new file mode 100644 index 000000000..fcac089bc --- /dev/null +++ b/apps/client/src/components/icons/github-icon.tsx @@ -0,0 +1,22 @@ +import { rem } from '@mantine/core'; + +interface Props { + size?: number | string; +} + +export function GithubIcon({ size }: Props) { + return ( + + + + ); +} diff --git a/apps/client/src/components/icons/gitlab-icon.tsx b/apps/client/src/components/icons/gitlab-icon.tsx new file mode 100644 index 000000000..db155579c --- /dev/null +++ b/apps/client/src/components/icons/gitlab-icon.tsx @@ -0,0 +1,32 @@ +import { rem } from '@mantine/core'; + +interface Props { + size?: number | string; +} + +export function GitlabIcon({ size }: Props) { + return ( + + + + + + + ); +} diff --git a/apps/client/src/components/icons/google-docs-icon.tsx b/apps/client/src/components/icons/google-docs-icon.tsx new file mode 100644 index 000000000..e7e32c0c0 --- /dev/null +++ b/apps/client/src/components/icons/google-docs-icon.tsx @@ -0,0 +1,28 @@ +import { rem } from '@mantine/core'; + +interface Props { + size?: number | string; +} + +export function GoogleDocsIcon({ size }: Props) { + return ( + + + + + + ); +} diff --git a/apps/client/src/components/icons/index.ts b/apps/client/src/components/icons/index.ts index 0f5fd1ad7..29e20de46 100644 --- a/apps/client/src/components/icons/index.ts +++ b/apps/client/src/components/icons/index.ts @@ -1,10 +1,16 @@ export { AirtableIcon } from "./airtable-icon.tsx"; export { FigmaIcon } from "./figma-icon.tsx"; +export { GithubIcon } from "./github-icon.tsx"; +export { GitlabIcon } from "./gitlab-icon.tsx"; +export { GoogleDocsIcon } from "./google-docs-icon.tsx"; +export { GoogleDriveIcon } from "./google-drive-icon.tsx"; +export { GoogleSheetsIcon } from "./google-sheets-icon.tsx"; +export { JiraIcon } from "./jira-icon.tsx"; +export { LinearIcon } from "./linear-icon.tsx"; export { TypeformIcon } from "./typeform-icon.tsx"; export { VimeoIcon } from "./vimeo-icon.tsx"; export { MiroIcon } from "./miro-icon.tsx"; -export { GoogleDriveIcon } from "./google-drive-icon.tsx"; -export { GoogleSheetsIcon } from "./google-sheets-icon.tsx"; +export { SlackIcon } from "./slack-icon.tsx"; export { FramerIcon } from "./framer-icon.tsx"; export { LoomIcon } from "./loom-icon.tsx"; export { YoutubeIcon } from "./youtube-icon.tsx"; diff --git a/apps/client/src/components/icons/jira-icon.tsx b/apps/client/src/components/icons/jira-icon.tsx new file mode 100644 index 000000000..c7164c14e --- /dev/null +++ b/apps/client/src/components/icons/jira-icon.tsx @@ -0,0 +1,38 @@ +import { rem } from '@mantine/core'; + +interface Props { + size?: number | string; +} + +export function JiraIcon({ size }: Props) { + return ( + + + + + + + + + + + + + + + + ); +} diff --git a/apps/client/src/components/icons/linear-icon.tsx b/apps/client/src/components/icons/linear-icon.tsx new file mode 100644 index 000000000..b44ecce7b --- /dev/null +++ b/apps/client/src/components/icons/linear-icon.tsx @@ -0,0 +1,21 @@ +import { rem } from '@mantine/core'; + +interface Props { + size?: number | string; +} + +export function LinearIcon({ size }: Props) { + return ( + + + + ); +} diff --git a/apps/client/src/components/icons/miro-icon.tsx b/apps/client/src/components/icons/miro-icon.tsx index 9d07898af..d62b4ef36 100644 --- a/apps/client/src/components/icons/miro-icon.tsx +++ b/apps/client/src/components/icons/miro-icon.tsx @@ -8,11 +8,20 @@ export function MiroIcon({ size }: Props) { return ( + fill="#FFDD33" + d="M3 100.754C3 46.2604 47.2435 2 101.754 2H299.246C353.756 2 398 46.2435 398 100.754V298.246C398 352.756 353.756 397 299.246 397H101.754C47.2435 397 3 352.756 3 298.246V100.754Z" + /> + ); } diff --git a/apps/client/src/components/icons/slack-icon.tsx b/apps/client/src/components/icons/slack-icon.tsx new file mode 100644 index 000000000..d26e9f2c5 --- /dev/null +++ b/apps/client/src/components/icons/slack-icon.tsx @@ -0,0 +1,32 @@ +import { rem } from '@mantine/core'; + +interface Props { + size?: number | string; +} + +export function SlackIcon({ size }: Props) { + return ( + + + + + + + ); +} diff --git a/apps/client/src/components/settings/settings-sidebar.tsx b/apps/client/src/components/settings/settings-sidebar.tsx index 542cad910..ee9e50b22 100644 --- a/apps/client/src/components/settings/settings-sidebar.tsx +++ b/apps/client/src/components/settings/settings-sidebar.tsx @@ -13,6 +13,7 @@ import { IconKey, IconWorld, IconSparkles, + IconPlug, IconHistory, IconShieldCheck, } from "@tabler/icons-react"; @@ -74,6 +75,11 @@ const groupedData: DataGroup[] = [ path: "/settings/account/api-keys", feature: Feature.API_KEYS, }, + { + label: "Connections", + icon: IconPlug, + path: "/settings/account/connections", + }, ], }, { @@ -125,6 +131,12 @@ const groupedData: DataGroup[] = [ role: "owner", env: "selfhosted", }, + { + label: "Integrations", + icon: IconPlug, + path: "/settings/integrations", + role: "admin", + }, ], }, { diff --git a/apps/client/src/ee/features.ts b/apps/client/src/ee/features.ts index e50e8d70b..77bd1db73 100644 --- a/apps/client/src/ee/features.ts +++ b/apps/client/src/ee/features.ts @@ -22,4 +22,5 @@ export const Feature = { PERSONAL_SPACES: 'spaces:personal', DOCX_EXPORT: 'export:docx', BASES: 'bases', + INTEGRATIONS: 'integrations', } as const; diff --git a/apps/client/src/features/editor/components/common/editor-paste-handler.tsx b/apps/client/src/features/editor/components/common/editor-paste-handler.tsx index b01e2b810..67ff5767e 100644 --- a/apps/client/src/features/editor/components/common/editor-paste-handler.tsx +++ b/apps/client/src/features/editor/components/common/editor-paste-handler.tsx @@ -5,6 +5,10 @@ import { uploadPdfAction } from "../pdf/upload-pdf-action"; import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts"; import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts"; import { Editor } from "@tiptap/core"; +import { matchIntegrationLink } from "@docmost/editor-ext"; +import { integrationPasteMenuKey } from "@/features/editor/extensions/integration-paste-menu"; +import { queryClient } from "@/main.tsx"; +import { Integration } from "@/features/integration/types/integration.types"; import { getAttachmentInfo, uploadFile, @@ -22,6 +26,16 @@ const ATTACHMENT_NODE_TYPES = [ const ATTACHMENT_URL_RE = /\/api\/files\/([0-9a-f-]+)\//; +// Only installed providers get card treatment; anything else pastes as an +// ordinary link. The cache is prefetched when the page editor mounts; +// a cold cache also means ordinary link. +function isIntegrationInstalled(provider: string): boolean { + const installed = queryClient.getQueryData([ + "installed-integrations", + ]); + return Boolean(installed?.some((i) => i.type === provider)); +} + export const handlePaste = ( editor: Editor, event: ClipboardEvent, @@ -30,6 +44,59 @@ export const handlePaste = ( ) => { const clipboardData = event.clipboardData.getData("text/plain"); + const integrationMatch = matchIntegrationLink(clipboardData.trim()); + if ( + integrationMatch && + editor.state.selection.empty && + isIntegrationInstalled(integrationMatch.provider) + ) { + event.preventDefault(); + const pastedUrl = clipboardData.trim(); + editor + .chain() + .focus() + .setIntegrationLink({ + url: pastedUrl, + provider: integrationMatch.provider, + }) + // Anchor the "Paste as" menu to the inserted node, in the SAME + // transaction: BubbleMenu ignores meta-only transactions (it only + // re-evaluates when the doc or selection changed). Locate the node via + // the range this transaction's own steps touched, never by url, so a + // duplicate of the same link elsewhere in the doc can't steal the menu. + .command(({ tr }) => { + let start: number | null = null; + let end: number | null = null; + tr.mapping.maps.forEach((map, index) => { + const rest = tr.mapping.slice(index + 1); + map.forEach((_oldStart, _oldEnd, newStart, newEnd) => { + const mappedStart = rest.map(newStart, -1); + const mappedEnd = rest.map(newEnd, 1); + start = start === null ? mappedStart : Math.min(start, mappedStart); + end = end === null ? mappedEnd : Math.max(end, mappedEnd); + }); + }); + if (start === null || end === null) return true; + + let pastedPos: number | null = null; + tr.doc.nodesBetween( + start, + Math.min(end, tr.doc.content.size), + (node, pos) => { + if (node.type.name === "integrationLink") { + pastedPos = pos; + } + }, + ); + if (pastedPos !== null) { + tr.setMeta(integrationPasteMenuKey, { pos: pastedPos }); + } + return true; + }) + .run(); + return true; + } + if (INTERNAL_LINK_REGEX.test(clipboardData)) { // we have to do this validation here to allow the default link extension to takeover if needs be event.preventDefault(); diff --git a/apps/client/src/features/editor/components/integration-link/badge-color.ts b/apps/client/src/features/editor/components/integration-link/badge-color.ts new file mode 100644 index 000000000..da8fe34fc --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/badge-color.ts @@ -0,0 +1,49 @@ +// Light-scheme text per hue, measured to pass 4.5:1 on the light-variant +// badge background; hexes are darkened .9 shades for hues whose scale +// never gets dark enough. +const BADGE_TEXT_LIGHT: Record = { + dark: "var(--mantine-color-dark-9)", + gray: "var(--mantine-color-gray-9)", + red: "var(--mantine-color-red-9)", + pink: "var(--mantine-color-pink-9)", + grape: "var(--mantine-color-grape-9)", + violet: "var(--mantine-color-violet-9)", + indigo: "var(--mantine-color-indigo-9)", + blue: "var(--mantine-color-blue-9)", + cyan: "var(--mantine-color-cyan-9)", + teal: "var(--mantine-color-teal-9)", + green: "#277c38", + lime: "#4e7e0b", + yellow: "#ad5900", + orange: "#c3410e", +}; + +export function badgeTextColor(color?: string): string | undefined { + if (!color) return undefined; + const light = BADGE_TEXT_LIGHT[color]; + if (!light) return undefined; + // Dark scheme keeps Mantine's own light-variant text. + return `light-dark(${light}, var(--mantine-color-${color}-light-color))`; +} + +export function toBadgeColor(raw?: string): string { + if (!raw) return "gray"; + const hex = raw.toLowerCase().replace("#", ""); + if (/^[0-9a-f]{6}$/.test(hex)) { + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const l = (max + min) / 2 / 255; + if (max - min < 30) return l > 0.6 ? "gray" : "dark"; + if (r > g && r > b) return g > 160 ? "orange" : "red"; + if (g > r && g > b) return r > 160 ? "lime" : "green"; + if (b > r && b > g) return r > 100 ? "violet" : "blue"; + if (r > 200 && g > 200) return "yellow"; + if (r > 200 && b > 200) return "pink"; + if (g > 200 && b > 200) return "cyan"; + return "gray"; + } + return raw; +} diff --git a/apps/client/src/features/editor/components/integration-link/integration-link-view.module.css b/apps/client/src/features/editor/components/integration-link/integration-link-view.module.css new file mode 100644 index 000000000..abb2e880b --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/integration-link-view.module.css @@ -0,0 +1,65 @@ +.card { + max-width: 100%; + cursor: pointer; + transition: background-color 150ms ease; + margin: 4px 0; +} + +.card:hover { + background-color: var(--mantine-color-gray-0); +} + +:global([data-mantine-color-scheme="dark"]) .card:hover { + background-color: var(--mantine-color-dark-5); +} + +.thumbnail { + display: block; + width: 100%; + max-height: 320px; + object-fit: cover; + background-color: var(--mantine-color-gray-0); +} + +:global([data-mantine-color-scheme="dark"]) .thumbnail { + background-color: var(--mantine-color-dark-6); +} + +.mention { + display: inline-flex; + align-items: center; + gap: 4px; + max-width: 100%; + vertical-align: text-bottom; + text-decoration: none; + color: inherit; + border-radius: var(--mantine-radius-sm); +} + +:global(.node-integrationMention) .mention { + border-bottom: none !important; + font-weight: 400; +} + +.mention:hover { + background-color: var(--mantine-color-gray-0); +} + +:global([data-mantine-color-scheme="dark"]) .mention:hover { + background-color: var(--mantine-color-dark-5); +} + +.mentionText { + text-decoration: underline; + text-decoration-color: var(--mantine-color-gray-4); + text-underline-offset: 3px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 340px; + font-weight: 500; +} + +.mentionIcon { + flex-shrink: 0; +} diff --git a/apps/client/src/features/editor/components/integration-link/integration-link-view.tsx b/apps/client/src/features/editor/components/integration-link/integration-link-view.tsx new file mode 100644 index 000000000..055a221dc --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/integration-link-view.tsx @@ -0,0 +1,525 @@ +import { NodeViewWrapper } from "@tiptap/react"; +import { + Card, + Group, + Text, + Badge, + Avatar, + Skeleton, + Anchor, + Stack, + Button, +} from "@mantine/core"; +import { useCallback, useState, memo } from "react"; +import { useTranslation } from "react-i18next"; +import { notifications } from "@mantine/notifications"; +import { describeIntegrationLink } from "@docmost/editor-ext"; +import { getIntegrationIcon } from "@/features/integration/components/integration-icons"; +import { getOAuthAuthorizeUrl } from "@/features/integration/services/integration-service"; +import { timeAgo } from "@/lib/time"; +import { useUnfurl } from "./use-unfurl"; +import { badgeTextColor, toBadgeColor } from "./badge-color"; +import classes from "./integration-link-view.module.css"; + +const SLACK_TEXT_CLAMP_LINES = 4; + +function SlackMessageCard({ + url, + unfurlData, +}: { + url: string; + unfurlData: Record; +}) { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(false); + + const meta = unfurlData.metadata ?? {}; + const postedAt = meta.ts ? new Date(parseFloat(meta.ts) * 1000) : null; + const text: string = unfurlData.description ?? ""; + const isLong = + text.length > 280 || text.split("\n").length > SLACK_TEXT_CLAMP_LINES; + + const footer = [ + meta.replyCount + ? `${meta.replyCount} ${meta.replyCount === 1 ? t("reply") : t("replies")}` + : null, + unfurlData.status, + meta.teamName, + ] + .filter(Boolean) + .join(" • "); + + return ( + + + + + {(unfurlData.author ?? "?").charAt(0)} + + + + + + {unfurlData.author} + + {postedAt && ( + + {timeAgo(postedAt)} + + )} + + + {text && ( + + {text} + + )} + + {isLong && ( + setExpanded((v) => !v)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setExpanded((v) => !v); + } + }} + > + {expanded ? t("show less") : t("show more")} + + )} + + {footer && ( + + {footer} + + )} + + + + {getIntegrationIcon("slack", 18)} + + + + + ); +} + +function JiraIssueCard({ + url, + unfurlData, +}: { + url: string; + unfurlData: Record; +}) { + const { t } = useTranslation(); + const meta = unfurlData.metadata ?? {}; + + const infoLine = [ + meta.issueKey, + unfurlData.author + ? t("Assigned to {{name}}", { name: unfurlData.author }) + : t("Unassigned"), + meta.updatedAt + ? t("Updated {{time}}", { time: timeAgo(new Date(meta.updatedAt)) }) + : null, + ] + .filter(Boolean) + .join(" • "); + + return ( + + + + {unfurlData.authorAvatarUrl ? ( + + ) : ( +
{getIntegrationIcon("jira", 28)}
+ )} + + + + + {unfurlData.title} + + {unfurlData.status && ( + + {unfurlData.status} + + )} + + + + {meta.issueTypeIconUrl && ( + + )} + + {infoLine} + + + + +
+ {getIntegrationIcon("jira", 18)} +
+
+
+
+ ); +} + +function FigmaFileCard({ + url, + unfurlData, +}: { + url: string; + unfurlData: Record; +}) { + const { t } = useTranslation(); + // Figma thumbnail links are pre-signed and expire; drop the preview rather + // than render a broken image. + const [thumbnailFailed, setThumbnailFailed] = useState(false); + const meta = unfurlData.metadata ?? {}; + const thumbnailUrl: string | undefined = meta.thumbnailUrl; + const showThumbnail = Boolean(thumbnailUrl) && !thumbnailFailed; + + const subtitle = [ + unfurlData.author + ? t("Last modified by {{name}}", { name: unfurlData.author }) + : unfurlData.description, + meta.lastModified ? timeAgo(new Date(meta.lastModified)) : null, + ] + .filter(Boolean) + .join(" • "); + + return ( + + + {showThumbnail && ( + + setThumbnailFailed(true)} + /> + + )} + + + + {(unfurlData.author ?? unfurlData.title ?? "F").charAt(0)} + + + + + {unfurlData.title} + + {subtitle && ( + + {subtitle} + + )} + + +
+ {getIntegrationIcon("figma", 18)} +
+
+
+
+ ); +} + +function IntegrationLinkView(props: any) { + const { node } = props; + const { url, provider } = node.attrs; + const { t } = useTranslation(); + + const unfurl = useUnfurl(url); + const [connecting, setConnecting] = useState(false); + + const needsConnection = + unfurl.state === "needsConnection" ? unfurl.needsConnection : null; + + const handleConnect = useCallback( + async (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + if (!needsConnection) return; + + setConnecting(true); + try { + const result = await getOAuthAuthorizeUrl({ + integrationId: needsConnection.integrationId, + returnPath: window.location.pathname, + }); + window.location.href = result.authorizationUrl; + } catch (error) { + setConnecting(false); + notifications.show({ + message: + error?.["response"]?.data?.message || + t("Failed to start OAuth connection"), + color: "red", + }); + } + }, + [needsConnection, t], + ); + + if (needsConnection) { + return ( + + + +
+ {getIntegrationIcon(provider, 28)} +
+ + + + {needsConnection.title} + + {needsConnection.description && ( + + {needsConnection.description} + + )} + + + {needsConnection.oauthConnect ? ( + + ) : ( + + {t("Use")} /docmost help {t("in")}{" "} + {needsConnection.integrationName} {t("to link your account.")} + + )} +
+
+
+ ); + } + + if (unfurl.state === "loading") { + return ( + + + + + + + + + + + + ); + } + + if (unfurl.state !== "loaded") { + // anonymous or error: no third-party data, only what the url itself says + const described = describeIntegrationLink(url); + if (described) { + return ( + + + +
+ {getIntegrationIcon(provider, 28)} +
+ + + {described.title} + + {described.description && ( + + {described.description} + + )} + +
+
+
+ ); + } + return ( + + + + {url} + + + + ); + } + + const unfurlData = unfurl.data; + + // metadata.ts marks legacy message unfurls stored before metadata.type existed. + const slackMeta = provider === "slack" ? unfurlData.metadata : null; + if (slackMeta?.type === "message" || (slackMeta && !slackMeta.type && slackMeta.ts)) { + return ; + } + + if (provider === "jira" && unfurlData.metadata?.issueKey) { + return ; + } + + if (provider === "figma") { + return ; + } + + return ( + + + + {unfurlData.authorAvatarUrl ? ( + + ) : ( +
{getIntegrationIcon(provider, 28)}
+ )} + + + + + {unfurlData.title} + + {unfurlData.status && ( + + {unfurlData.status} + + )} + + + {unfurlData.description && ( + + {unfurlData.description} + + )} + + + {provider && ( +
+ {getIntegrationIcon(provider, 18)} +
+ )} +
+
+
+ ); +} + +export default memo(IntegrationLinkView); diff --git a/apps/client/src/features/editor/components/integration-link/integration-mention-view.tsx b/apps/client/src/features/editor/components/integration-link/integration-mention-view.tsx new file mode 100644 index 000000000..f2519af47 --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/integration-mention-view.tsx @@ -0,0 +1,253 @@ +import { NodeViewWrapper } from "@tiptap/react"; +import { + Avatar, + Badge, + Button, + Group, + HoverCard, + Stack, + Text, +} from "@mantine/core"; +import { memo, useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { notifications } from "@mantine/notifications"; +import { describeIntegrationLink } from "@docmost/editor-ext"; +import { getIntegrationIcon } from "@/features/integration/components/integration-icons"; +import { getOAuthAuthorizeUrl } from "@/features/integration/services/integration-service"; +import { useUnfurl } from "./use-unfurl"; +import { badgeTextColor, toBadgeColor } from "./badge-color"; +import classes from "./integration-link-view.module.css"; + +function shortUrl(url: string): string { + try { + const parsed = new URL(url); + return `${parsed.host}${parsed.pathname}`; + } catch { + return url; + } +} + +function IntegrationMentionView(props: any) { + const { node } = props; + const { url, provider } = node.attrs; + const { t } = useTranslation(); + + const unfurl = useUnfurl(url); + const data = unfurl.state === "loaded" ? unfurl.data : null; + const needsConnection = + unfurl.state === "needsConnection" ? unfurl.needsConnection : null; + const [connecting, setConnecting] = useState(false); + + const handleConnect = useCallback( + async (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + if (!needsConnection) return; + + setConnecting(true); + try { + const result = await getOAuthAuthorizeUrl({ + integrationId: needsConnection.integrationId, + returnPath: window.location.pathname, + }); + window.location.href = result.authorizationUrl; + } catch (error) { + setConnecting(false); + notifications.show({ + message: + error?.["response"]?.data?.message || + t("Failed to start OAuth connection"), + color: "red", + }); + } + }, + [needsConnection, t], + ); + const meta = data?.metadata ?? {}; + const isSlackMessage = + provider === "slack" && (meta.type === "message" || (!meta.type && meta.ts)); + const issueNumber = meta.iid ?? meta.number; + const typeLabel = + meta.type === "project" + ? t("Project") + : meta.type === "initiative" + ? t("Initiative") + : null; + + const statusBadge = data?.status ? ( + + {data.status} + + ) : null; + + let content; + if (!data) { + // anonymous / loading / error / needs-connection: a compact link chip. + // With no unfurl response, fall back to what the url text itself says. + content = ( + <> + {getIntegrationIcon(provider, 14)} + + {needsConnection?.title ?? + describeIntegrationLink(url)?.title ?? + shortUrl(url)} + + + ); + } else if (isSlackMessage) { + content = ( + <> + + {(data.author ?? "?").charAt(0)} + + {data.author && ( + + {data.author} + + )} + + {(data.description ?? "").split("\n")[0] || shortUrl(url)} + + {getIntegrationIcon("slack", 14)} + {data.status && ( + + {data.status} + + )} + + ); + } else if (meta.issueKey) { + // Jira: type icon leads, provider icon trails. + content = ( + <> + {meta.issueTypeIconUrl ? ( + + ) : ( + getIntegrationIcon(provider, 14) + )} + + {meta.issueKey} + + {data.title} + {statusBadge} + {meta.issueTypeIconUrl && getIntegrationIcon(provider, 14)} + + ); + } else if (issueNumber) { + content = ( + <> + {getIntegrationIcon(provider, 14)} + + #{issueNumber} + + {data.title} + {statusBadge} + + ); + } else if (typeLabel) { + content = ( + <> + {getIntegrationIcon(provider, 14)} + + {typeLabel} + + {data.title} + {statusBadge} + + ); + } else { + content = ( + <> + {getIntegrationIcon(provider, 14)} + {data.title || shortUrl(url)} + {statusBadge} + + ); + } + + const anchor = ( + + {content} + + ); + + return ( + + {needsConnection ? ( + + {anchor} + + + + {getIntegrationIcon(provider, 16)} + + {needsConnection.integrationName} + + + {needsConnection.oauthConnect ? ( + + ) : ( + + {t("Use")} /docmost help {t("in")}{" "} + {needsConnection.integrationName}{" "} + {t("to link your account.")} + + )} + + + + ) : ( + anchor + )} + + ); +} + +export default memo(IntegrationMentionView); diff --git a/apps/client/src/features/editor/components/integration-link/integration-paste-menu.tsx b/apps/client/src/features/editor/components/integration-link/integration-paste-menu.tsx new file mode 100644 index 000000000..d0d46605f --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/integration-paste-menu.tsx @@ -0,0 +1,205 @@ +import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; +import { posToDOMRect, useEditorState } from "@tiptap/react"; +import { useCallback, useEffect, useState } from "react"; +import { + Button, + Paper, + Stack, + Text, + VisuallyHidden, +} from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts"; +import { integrationPasteMenuKey } from "@/features/editor/extensions/integration-paste-menu"; + +const INTEGRATION_NODE_TYPES = ["integrationLink", "integrationMention"]; + +type PasteTarget = "card" | "mention" | "url"; + +const PASTE_OPTIONS: { target: PasteTarget; label: string }[] = [ + { target: "card", label: "Card" }, + { target: "mention", label: "Mention" }, + { target: "url", label: "URL" }, +]; + +export function IntegrationPasteMenu({ editor }: EditorMenuProps) { + const { t } = useTranslation(); + const [selectedIndex, setSelectedIndex] = useState(0); + + const menuState = useEditorState({ + editor, + selector: (ctx) => { + if (!ctx.editor) return null; + return integrationPasteMenuKey.getState(ctx.editor.state) ?? null; + }, + }); + + const findTarget = useCallback(() => { + const state = integrationPasteMenuKey.getState(editor.state); + if (!state) return null; + const node = editor.state.doc.nodeAt(state.pos); + if (!node || !INTEGRATION_NODE_TYPES.includes(node.type.name)) return null; + return { node, pos: state.pos }; + }, [editor]); + + const shouldShow = useCallback(() => Boolean(findTarget()), [findTarget]); + + const getReferencedVirtualElement = useCallback(() => { + const target = findTarget(); + if (!target) return undefined; + const dom = editor.view.nodeDOM(target.pos) as HTMLElement | null; + const domRect = + dom?.getBoundingClientRect?.() ?? + posToDOMRect( + editor.view, + target.pos, + target.pos + target.node.nodeSize, + ); + return { + getBoundingClientRect: () => domRect, + getClientRects: () => [domRect], + }; + }, [editor, findTarget]); + + const dismiss = useCallback(() => { + editor.view.dispatch( + editor.state.tr.setMeta(integrationPasteMenuKey, null), + ); + }, [editor]); + + const convert = useCallback( + (target: PasteTarget) => { + const found = findTarget(); + if (!found) { + dismiss(); + return; + } + const { node, pos } = found; + const attrs = { ...node.attrs }; + const from = pos; + const to = pos + node.nodeSize; + const isBlock = node.type.name === "integrationLink"; + + // Always replace, even when the node is already in the requested form: + // the menu closes via the doc change, and BubbleMenu never re-evaluates + // on meta-only transactions, so a bare dismiss would leave it stuck. + let content: Record; + if (target === "card") { + content = { type: "integrationLink", attrs }; + } else if (target === "mention") { + const mention = { type: "integrationMention", attrs }; + content = isBlock + ? { + type: "paragraph", + content: [mention, { type: "text", text: " " }], + } + : mention; + } else { + const linkText = { + type: "text", + text: attrs.url, + marks: [{ type: "link", attrs: { href: attrs.url } }], + }; + content = isBlock + ? { type: "paragraph", content: [linkText] } + : linkText; + } + + editor + .chain() + .focus(undefined, { scrollIntoView: false }) + .deleteRange({ from, to }) + .insertContentAt(from, content) + .run(); + }, + [editor, findTarget, dismiss], + ); + + useEffect(() => { + if (menuState) setSelectedIndex(0); + }, [menuState]); + + // DOM focus stays in the editor (as with the slash menu); keys are handled + // here. Capture phase, because ProseMirror itself consumes arrow keys next + // to atom nodes (gap cursor / node selection) before they would bubble. + useEffect(() => { + if (!menuState) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + dismiss(); + return; + } + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + event.stopPropagation(); + const delta = event.key === "ArrowDown" ? 1 : -1; + setSelectedIndex( + (prev) => + (prev + delta + PASTE_OPTIONS.length) % PASTE_OPTIONS.length, + ); + return; + } + if (event.key === "Enter") { + event.preventDefault(); + event.stopPropagation(); + convert(PASTE_OPTIONS[selectedIndex].target); + } + }; + window.addEventListener("keydown", onKeyDown, true); + return () => window.removeEventListener("keydown", onKeyDown, true); + }, [menuState, dismiss, convert, selectedIndex]); + + return ( + + {/* Content is gated on the plugin state too: meta-only dismissals + (Escape) are invisible to BubbleMenu's update cycle. */} + {menuState ? ( + + + {t(PASTE_OPTIONS[selectedIndex].label)} + + + {t("Paste as")} + + + {PASTE_OPTIONS.map((option, index) => ( + + ))} + + + ) : null} + + ); +} diff --git a/apps/client/src/features/editor/components/integration-link/use-unfurl.test.tsx b/apps/client/src/features/editor/components/integration-link/use-unfurl.test.tsx new file mode 100644 index 000000000..f9ffb8ccc --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/use-unfurl.test.tsx @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createStore, Provider } from "jotai"; +import { ReactNode } from "react"; +import { useUnfurl } from "./use-unfurl"; +import { currentUserAtom } from "@/features/user/atoms/current-user-atom"; +import { unfurlUrl } from "@/features/integration/services/integration-service"; +import { + UnfurlNeedsConnection, + UnfurlResult, +} from "@/features/integration/types/integration.types"; + +vi.mock("@/features/integration/services/integration-service", () => ({ + unfurlUrl: vi.fn(), +})); + +const mockedUnfurlUrl = vi.mocked(unfurlUrl); + +const ISSUE_URL = "https://github.com/acme/repo/issues/42"; + +const loadedResult: UnfurlResult = { + title: "Fix race condition in file watcher", + url: ISSUE_URL, + provider: "github", + status: "open", +}; + +const needsConnectionResult: UnfurlNeedsConnection = { + needsConnection: true, + integrationId: "int-1", + integrationType: "github", + integrationName: "GitHub", + oauthConnect: true, + title: "GitHub link", + description: "github.com/acme/repo/issues/42", +}; + +function createWrapper(loggedIn: boolean) { + const store = createStore(); + if (loggedIn) { + store.set(currentUserAtom, { user: { id: "user-1" } } as any); + } + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ({ children }: { children: ReactNode }) => ( + + {children} + + ); +} + +describe("useUnfurl", () => { + beforeEach(() => { + localStorage.clear(); + vi.resetAllMocks(); + }); + + it("never fetches for anonymous viewers and reports anonymous", () => { + const { result } = renderHook(() => useUnfurl(ISSUE_URL), { + wrapper: createWrapper(false), + }); + + expect(result.current.state).toBe("anonymous"); + expect(mockedUnfurlUrl).not.toHaveBeenCalled(); + }); + + it("starts loading then exposes the unfurl result", async () => { + mockedUnfurlUrl.mockResolvedValue(loadedResult); + + const { result } = renderHook(() => useUnfurl(ISSUE_URL), { + wrapper: createWrapper(true), + }); + + expect(result.current.state).toBe("loading"); + await waitFor(() => expect(result.current.state).toBe("loaded")); + expect( + result.current.state === "loaded" && result.current.data, + ).toEqual(loadedResult); + expect(mockedUnfurlUrl).toHaveBeenCalledWith({ url: ISSUE_URL }); + }); + + it("maps a needsConnection response without treating it as an error", async () => { + mockedUnfurlUrl.mockResolvedValue(needsConnectionResult); + + const { result } = renderHook(() => useUnfurl(ISSUE_URL), { + wrapper: createWrapper(true), + }); + + await waitFor(() => + expect(result.current.state).toBe("needsConnection"), + ); + expect( + result.current.state === "needsConnection" && + result.current.needsConnection, + ).toEqual(needsConnectionResult); + }); + + it("maps a null result (no matching provider) to error", async () => { + mockedUnfurlUrl.mockResolvedValue(null); + + const { result } = renderHook(() => useUnfurl(ISSUE_URL), { + wrapper: createWrapper(true), + }); + + await waitFor(() => expect(result.current.state).toBe("error")); + }); + + it("maps a rejected request to error", async () => { + mockedUnfurlUrl.mockRejectedValue(new Error("boom")); + + const { result } = renderHook(() => useUnfurl(ISSUE_URL), { + wrapper: createWrapper(true), + }); + + await waitFor(() => expect(result.current.state).toBe("error")); + }); +}); diff --git a/apps/client/src/features/editor/components/integration-link/use-unfurl.ts b/apps/client/src/features/editor/components/integration-link/use-unfurl.ts new file mode 100644 index 000000000..ee218e4a1 --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/use-unfurl.ts @@ -0,0 +1,47 @@ +import { useQuery } from "@tanstack/react-query"; +import { useAtomValue } from "jotai"; +import { currentUserAtom } from "@/features/user/atoms/current-user-atom"; +import { unfurlUrl } from "@/features/integration/services/integration-service"; +import { + UnfurlNeedsConnection, + UnfurlResult, +} from "@/features/integration/types/integration.types"; + +const UNFURL_STALE_TIME = 5 * 60 * 1000; // mirrors the server-side Redis TTL + +export type UnfurlState = + | { state: "anonymous" } + | { state: "loading" } + | { state: "error" } + | { state: "needsConnection"; needsConnection: UnfurlNeedsConnection } + | { state: "loaded"; data: UnfurlResult }; + +// Resolves the unfurl per viewer at render time. Nothing is written back into +// the document, so third-party permissions are enforced on every view: +// unconnected viewers get needsConnection and anonymous viewers never fetch. +export function useUnfurl(url: string): UnfurlState { + const currentUser = useAtomValue(currentUserAtom); + const isAuthenticated = Boolean(currentUser?.user); + + const query = useQuery({ + queryKey: ["unfurl", url], + queryFn: () => unfurlUrl({ url }), + enabled: isAuthenticated && Boolean(url), + staleTime: UNFURL_STALE_TIME, + retry: false, + }); + + if (!isAuthenticated || !url) { + return { state: "anonymous" }; + } + if (query.isPending) { + return { state: "loading" }; + } + if (query.isError || !query.data) { + return { state: "error" }; + } + if ("needsConnection" in query.data) { + return { state: "needsConnection", needsConnection: query.data }; + } + return { state: "loaded", data: query.data }; +} diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index c72456e6e..8ad0681ad 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -57,6 +57,8 @@ import { Indent, UniqueID, SharedStorage, + IntegrationLink, + IntegrationMention, Columns, Column, Status, @@ -95,6 +97,9 @@ import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-v import EmbedView from "@/features/editor/components/embed/embed-view.tsx"; import PdfView from "@/features/editor/components/pdf/pdf-view.tsx"; import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx"; +import IntegrationLinkView from "@/features/editor/components/integration-link/integration-link-view.tsx"; +import IntegrationMentionView from "@/features/editor/components/integration-link/integration-mention-view.tsx"; +import { IntegrationPasteMenuExtension } from "@/features/editor/extensions/integration-paste-menu"; import TransclusionView from "@/features/editor/components/transclusion/transclusion-view.tsx"; import TransclusionReferenceView from "@/features/editor/components/transclusion/transclusion-reference-view.tsx"; import { BaseEmbedView } from "@/features/editor/components/base-embed/base-embed-view.tsx"; @@ -387,6 +392,13 @@ export const mainExtensions = [ Subpages.configure({ view: SubpagesView, }), + IntegrationLink.configure({ + view: IntegrationLinkView, + }), + IntegrationMention.configure({ + view: IntegrationMentionView, + }), + IntegrationPasteMenuExtension, Status.configure({ view: StatusView, }), diff --git a/apps/client/src/features/editor/extensions/integration-paste-menu.ts b/apps/client/src/features/editor/extensions/integration-paste-menu.ts new file mode 100644 index 000000000..66936c2e9 --- /dev/null +++ b/apps/client/src/features/editor/extensions/integration-paste-menu.ts @@ -0,0 +1,44 @@ +import { Extension } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; + +export type IntegrationPasteMenuState = { pos: number } | null; + +export const integrationPasteMenuKey = new PluginKey( + "integrationPasteMenu", +); + +// Holds the position of a just-pasted integration node so the "Paste as" +// menu can anchor to it. Any other edit or selection change dismisses it. +export const IntegrationPasteMenuExtension = Extension.create({ + name: "integrationPasteMenu", + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: integrationPasteMenuKey, + state: { + init: (): IntegrationPasteMenuState => null, + apply(tr, prev): IntegrationPasteMenuState { + const meta = tr.getMeta(integrationPasteMenuKey); + if (meta !== undefined) return meta; + if (!prev) return null; + // Clicking or typing elsewhere dismisses. + if (tr.selectionSet) return null; + // Structural follow-ups (unique-id assignment, trailing node) + // keep the menu anchored: remap and re-validate the position. + if (tr.docChanged) { + const pos = tr.mapping.map(prev.pos); + const node = tr.doc.nodeAt(pos); + const isIntegrationNode = + node && + (node.type.name === "integrationLink" || + node.type.name === "integrationMention"); + return isIntegrationNode ? { pos } : null; + } + return prev; + }, + }, + }), + ]; + }, +}); diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index 77233f34f..5a5654f17 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -75,6 +75,8 @@ import { useEditorScroll } from "./hooks/use-editor-scroll"; import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu"; import { EditorLinkMenu } from "@/features/editor/components/link/link-menu"; import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx"; +import { IntegrationPasteMenu } from "@/features/editor/components/integration-link/integration-paste-menu.tsx"; +import { getInstalledIntegrations } from "@/features/integration/services/integration-service"; import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context"; import { useTranslation } from "react-i18next"; import { @@ -326,6 +328,18 @@ function CollabPageEditor({ [pageId, editable, extensions], ); + useEffect(() => { + // Warm the cache the paste handler reads to decide whether a pasted + // integration url becomes a card or stays an ordinary link. gcTime + // Infinity: no observer holds this query, and the default 5-minute gc + // would evict it mid-session, silently downgrading pastes to plain links. + queryClient.prefetchQuery({ + queryKey: ["installed-integrations"], + queryFn: getInstalledIntegrations, + gcTime: Infinity, + }); + }, []); + useLayoutEffect(() => { if (editor && !editor.isDestroyed) { // @ts-ignore @@ -454,6 +468,7 @@ function CollabPageEditor({ + )} {editor && !editorIsEditable && (editable || canComment) && ( diff --git a/apps/client/src/features/integration/components/connection-row.tsx b/apps/client/src/features/integration/components/connection-row.tsx new file mode 100644 index 000000000..16b5a3c3e --- /dev/null +++ b/apps/client/src/features/integration/components/connection-row.tsx @@ -0,0 +1,93 @@ +import { Group, Text, Button, Box } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { IntegrationDefinition, UserConnection } from "../types/integration.types"; +import { getIntegrationIcon } from "./integration-icons"; + +type ConnectionRowProps = { + definition: IntegrationDefinition; + connection?: UserConnection; + onConnect: (type: string) => void; + onDisconnect: (integrationId: string) => void; + disconnectingId?: string; +}; + +export default function ConnectionRow({ + definition, + connection, + onConnect, + onDisconnect, + disconnectingId, +}: ConnectionRowProps) { + const { t } = useTranslation(); + const connectedLabel = + connection?.providerDisplayName || connection?.providerUserId; + + return ( + + + + {getIntegrationIcon(definition.type, 28)} +
+ + {definition.name} + + + {definition.description} + +
+
+ + + {connection ? ( + <> + {connection.invalidatedAt ? ( + <> + + {t("Connection expired")} + + + + ) : ( + + {connectedLabel + ? t("Connected as {{label}}", { label: connectedLabel }) + : t("Connected")} + + )} + + + ) : ( + + )} + +
+
+ ); +} diff --git a/apps/client/src/features/integration/components/integration-icons.tsx b/apps/client/src/features/integration/components/integration-icons.tsx new file mode 100644 index 000000000..da18e45c7 --- /dev/null +++ b/apps/client/src/features/integration/components/integration-icons.tsx @@ -0,0 +1,30 @@ +import { ReactNode } from "react"; +import { + FigmaIcon, + GithubIcon, + GitlabIcon, + GoogleDocsIcon, + JiraIcon, + LinearIcon, + SlackIcon, +} from "@/components/icons"; +import { IconPuzzle } from "@tabler/icons-react"; + +const integrationIconMap: Record ReactNode> = { + github: (size) => , + gitlab: (size) => , + slack: (size) => , + linear: (size) => , + jira: (size) => , + figma: (size) => , + google_docs: (size) => , +}; + +export function getIntegrationIcon( + type: string, + size: number, +): ReactNode { + const renderIcon = integrationIconMap[type]; + if (renderIcon) return renderIcon(size); + return ; +} diff --git a/apps/client/src/features/integration/components/integration-list-skeleton.tsx b/apps/client/src/features/integration/components/integration-list-skeleton.tsx new file mode 100644 index 000000000..798def1a3 --- /dev/null +++ b/apps/client/src/features/integration/components/integration-list-skeleton.tsx @@ -0,0 +1,66 @@ +import { Box, Group, Skeleton, Stack } from "@mantine/core"; + +const TITLE_WIDTHS = [64, 52, 60, 44, 58, 96, 56]; +const DESCRIPTION_WIDTHS = [300, 250, 320, 180, 290, 270, 260]; + +type IntegrationListSkeletonProps = { + rows?: number; + withBadges?: boolean; +}; + +export default function IntegrationListSkeleton({ + rows = 7, + withBadges = true, +}: IntegrationListSkeletonProps) { + return ( + + ); +} diff --git a/apps/client/src/features/integration/components/integration-row.tsx b/apps/client/src/features/integration/components/integration-row.tsx new file mode 100644 index 000000000..2f8fb68c4 --- /dev/null +++ b/apps/client/src/features/integration/components/integration-row.tsx @@ -0,0 +1,93 @@ +import { + Group, + Text, + Badge, + Button, + Box, + Stack, + Tooltip, +} from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { + IntegrationDefinition, + Integration, +} from "../types/integration.types"; +import { getIntegrationIcon } from "./integration-icons"; +import { useHasFeature } from "@/ee/hooks/use-feature"; +import { Feature } from "@/ee/features"; +import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label"; + +type IntegrationRowProps = { + definition: IntegrationDefinition; + installation?: Integration; + onInstall: (type: string) => void; + onUninstall: (integrationId: string) => void; +}; + +export default function IntegrationRow({ + definition, + installation, + onInstall, + onUninstall, +}: IntegrationRowProps) { + const { t } = useTranslation(); + const isInstalled = !!installation; + const hasAccess = useHasFeature(Feature.INTEGRATIONS); + const locked = !!definition.requiresLicense && !hasAccess; + const upgradeLabel = useUpgradeLabel(); + + return ( + + + + {getIntegrationIcon(definition.type, 28)} + + + + {definition.name} + + {locked && ( + + {t("Paid")} + + )} + + + {definition.description} + + + + + + {isInstalled ? ( + + ) : ( + + + + )} + + + + ); +} diff --git a/apps/client/src/features/integration/pages/connections.tsx b/apps/client/src/features/integration/pages/connections.tsx new file mode 100644 index 000000000..cc13aab3f --- /dev/null +++ b/apps/client/src/features/integration/pages/connections.tsx @@ -0,0 +1,134 @@ +import { Text, Alert, Stack } from "@mantine/core"; +import { modals } from "@mantine/modals"; +import { Helmet } from "react-helmet-async"; +import { useTranslation } from "react-i18next"; +import { notifications } from "@mantine/notifications"; +import { getAppName } from "@/lib/config"; +import SettingsTitle from "@/components/settings/settings-title"; +import ConnectionRow from "../components/connection-row"; +import IntegrationListSkeleton from "../components/integration-list-skeleton"; +import { + useAvailableIntegrations, + useInstalledIntegrations, + useMyConnections, + useDisconnectIntegration, +} from "../queries/integration-query"; +import * as integrationService from "../services/integration-service"; + +export default function Connections() { + const { t } = useTranslation(); + const { data: available, isLoading: loadingAvailable } = + useAvailableIntegrations(); + const { data: installed, isLoading: loadingInstalled } = + useInstalledIntegrations(); + const { data: myConnections, isLoading: loadingConnections } = + useMyConnections(); + const disconnectMutation = useDisconnectIntegration(); + + const isLoading = loadingAvailable || loadingInstalled || loadingConnections; + + const handleConnect = async (type: string) => { + const integration = installed?.find((i) => i.type === type); + if (!integration) return; + + try { + // Workspace-scoped providers default the OAuth return to the admin + // integrations page; members connecting here must come back here. + const result = await integrationService.getOAuthAuthorizeUrl({ + integrationId: integration.id, + returnPath: "/settings/account/connections", + }); + window.location.href = result.authorizationUrl; + } catch (error) { + const errorMessage = error["response"]?.data?.message; + notifications.show({ + message: errorMessage || t("Failed to start OAuth connection"), + color: "red", + }); + } + }; + + const handleDisconnect = (integrationId: string) => { + const installation = installed?.find((i) => i.id === integrationId); + const name = + available?.find((d) => d.type === installation?.type)?.name ?? + installation?.type ?? + ""; + modals.openConfirmModal({ + title: t("Disconnect {{name}}", { name }), + centered: true, + children: ( + + {t( + "This disconnects your {{name}} account. Links are not enriched for you until you reconnect.", + { name }, + )} + + ), + labels: { confirm: t("Disconnect"), cancel: t("Cancel") }, + confirmProps: { color: "red" }, + onConfirm: () => disconnectMutation.mutate({ integrationId }), + }); + }; + + // Only the row being disconnected shows a loader; isPending alone is shared by every row. + const disconnectingId = disconnectMutation.isPending + ? disconnectMutation.variables?.integrationId + : undefined; + + const error = new URLSearchParams(window.location.search).get("error"); + + return ( + <> + + + {t("Connections")} - {getAppName()} + + + + + + + {t("Manage the apps you have connected to your account.")} + + + {error === "oauth_failed" && ( + + {t("OAuth connection failed. Please try again.")} + + )} + + {isLoading ? ( + + ) : !available?.length ? ( + + {t("No integrations available.")} + + ) : ( + + {available + .filter((def) => { + if (!def.capabilities.includes("oauth")) return false; + return installed?.some((i) => i.type === def.type); + }) + .map((def) => { + const connection = myConnections?.find( + (c) => c.type === def.type, + ); + + return ( + + ); + })} + + )} + + ); +} diff --git a/apps/client/src/features/integration/pages/integrations.tsx b/apps/client/src/features/integration/pages/integrations.tsx new file mode 100644 index 000000000..1d9fd8346 --- /dev/null +++ b/apps/client/src/features/integration/pages/integrations.tsx @@ -0,0 +1,126 @@ +import { Text, Alert, Stack } from "@mantine/core"; +import { modals } from "@mantine/modals"; +import { Helmet } from "react-helmet-async"; +import { useTranslation } from "react-i18next"; +import { useCallback } from "react"; +import { getAppName } from "@/lib/config"; +import SettingsTitle from "@/components/settings/settings-title"; +import IntegrationRow from "../components/integration-row"; +import IntegrationListSkeleton from "../components/integration-list-skeleton"; +import { + useAvailableIntegrations, + useInstalledIntegrations, + useInstallIntegration, + useUninstallIntegration, +} from "../queries/integration-query"; +import { getOAuthInstallUrl } from "../services/integration-service"; +import { notifications } from "@mantine/notifications"; + +export default function Integrations() { + const { t } = useTranslation(); + const { data: available, isLoading: loadingAvailable } = + useAvailableIntegrations(); + const { data: installed, isLoading: loadingInstalled } = + useInstalledIntegrations(); + const installMutation = useInstallIntegration(); + const uninstallMutation = useUninstallIntegration(); + + const handleInstall = useCallback( + async (type: string) => { + const definition = available?.find((d) => d.type === type); + + // OAuth providers only become installed once the admin's OAuth + // callback succeeds; a cancelled or failed flow persists nothing. + if (definition?.capabilities?.includes("oauth")) { + try { + const { authorizationUrl } = await getOAuthInstallUrl({ type }); + window.location.href = authorizationUrl; + } catch (err: any) { + notifications.show({ + message: + err?.response?.data?.message ?? t("Failed to start installation"), + color: "red", + }); + } + return; + } + + installMutation.mutate({ type }); + }, + [installMutation, available, t], + ); + + const handleUninstall = useCallback( + (integrationId: string) => { + const installation = installed?.find((i) => i.id === integrationId); + const name = + available?.find((d) => d.type === installation?.type)?.name ?? + installation?.type ?? + ""; + modals.openConfirmModal({ + title: t("Uninstall {{name}}", { name }), + centered: true, + children: ( + + {t( + "This disables the {{name}} integration for the entire workspace. Members' connections are removed and links are not enriched.", + { name }, + )} + + ), + labels: { confirm: t("Uninstall"), cancel: t("Cancel") }, + confirmProps: { color: "red" }, + onConfirm: () => uninstallMutation.mutate({ integrationId }), + }); + }, + [uninstallMutation, installed, available, t], + ); + + const isLoading = loadingAvailable || loadingInstalled; + const error = new URLSearchParams(window.location.search).get("error"); + + return ( + <> + + + {t("Integrations")} - {getAppName()} + + + + + + + {t("Manage workspace integrations.")} + + + {error === "oauth_failed" && ( + + {t("OAuth connection failed. Please try again.")} + + )} + + {isLoading ? ( + + ) : !available?.length ? ( + + {t("No integrations available.")} + + ) : ( + + {available.map((def) => { + const installation = installed?.find((i) => i.type === def.type); + return ( + + ); + })} + + )} + + ); +} diff --git a/apps/client/src/features/integration/pages/slack-link.tsx b/apps/client/src/features/integration/pages/slack-link.tsx new file mode 100644 index 000000000..f9cec389b --- /dev/null +++ b/apps/client/src/features/integration/pages/slack-link.tsx @@ -0,0 +1,112 @@ +import { useEffect, useState } from "react"; +import { useSearchParams, useNavigate } from "react-router-dom"; +import { Alert, Button, Card, Group, Loader, Stack, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { useAtomValue } from "jotai"; +import { + decodeSlackLinkState, + confirmSlackLink, + SlackLinkStateInfo, +} from "../services/slack-link-service"; +import { currentUserAtom } from "@/features/user/atoms/current-user-atom"; +import APP_ROUTE from "@/lib/app-route"; + +export default function SlackLinkPage() { + const { t } = useTranslation(); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const state = searchParams.get("state"); + const currentUser = useAtomValue(currentUserAtom); + + const [info, setInfo] = useState(null); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [done, setDone] = useState(false); + + useEffect(() => { + if (!currentUser) { + const redirectPath = window.location.pathname + window.location.search; + navigate(`${APP_ROUTE.AUTH.LOGIN}?redirect=${encodeURIComponent(redirectPath)}`); + return; + } + + if (!state) { + setError(t("Missing state parameter")); + return; + } + + decodeSlackLinkState(state) + .then(setInfo) + .catch((e) => setError(e?.response?.data?.message ?? e.message)); + }, [state, t, currentUser, navigate]); + + async function onConfirm() { + if (!state) return; + setSubmitting(true); + try { + await confirmSlackLink(state); + setDone(true); + } catch (e: any) { + setError(e?.response?.data?.message ?? e.message); + } finally { + setSubmitting(false); + } + } + + if (done) { + return ( + + + {t("Connected")} + {t("You can close this tab and return to Slack.")} + + + ); + } + + if (error) { + return ( + + + {error} + + + ); + } + + if (!info || !currentUser) { + return ( +
+ +
+ ); + } + + return ( + + + {t("Link your Docmost account to Slack")} + + {t("Connect Docmost account")}{" "} + {currentUser.user.email}{" "} + {t("to Slack user")} @{info.slackUserName} + {info.slackTeamName && ( + <> + {" "} + {t("in")} {info.slackTeamName} + + )} + ? + + + + + + + + ); +} diff --git a/apps/client/src/features/integration/queries/integration-query.ts b/apps/client/src/features/integration/queries/integration-query.ts new file mode 100644 index 000000000..1c177181c --- /dev/null +++ b/apps/client/src/features/integration/queries/integration-query.ts @@ -0,0 +1,100 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { notifications } from "@mantine/notifications"; +import * as integrationService from "../services/integration-service"; + +export function useAvailableIntegrations() { + return useQuery({ + queryKey: ["available-integrations"], + queryFn: integrationService.getAvailableIntegrations, + }); +} + +export function useInstalledIntegrations() { + return useQuery({ + queryKey: ["installed-integrations"], + queryFn: integrationService.getInstalledIntegrations, + }); +} + +export function useInstallIntegration() { + const qc = useQueryClient(); + const { t } = useTranslation(); + return useMutation({ + mutationFn: integrationService.installIntegration, + onSuccess: () => { + notifications.show({ message: t("Integration installed successfully") }); + qc.invalidateQueries({ queryKey: ["installed-integrations"] }); + }, + onError: (error) => { + const errorMessage = error["response"]?.data?.message; + notifications.show({ + message: errorMessage || t("Failed to install integration"), + color: "red", + }); + }, + }); +} + +export function useUninstallIntegration() { + const qc = useQueryClient(); + const { t } = useTranslation(); + return useMutation({ + mutationFn: integrationService.uninstallIntegration, + onSuccess: () => { + notifications.show({ + message: t("Integration uninstalled successfully"), + }); + qc.invalidateQueries({ queryKey: ["installed-integrations"] }); + }, + onError: (error) => { + const errorMessage = error["response"]?.data?.message; + notifications.show({ + message: errorMessage || t("Failed to uninstall integration"), + color: "red", + }); + }, + }); +} + +export function useMyConnections() { + return useQuery({ + queryKey: ["my-connections"], + queryFn: integrationService.getMyConnections, + }); +} + +export function useConnectionStatus(integrationId: string | undefined) { + return useQuery({ + queryKey: ["integration-connection", integrationId], + queryFn: () => + integrationService.getConnectionStatus({ + integrationId: integrationId!, + }), + enabled: !!integrationId, + }); +} + +export function useDisconnectIntegration() { + const qc = useQueryClient(); + const { t } = useTranslation(); + return useMutation({ + mutationFn: integrationService.disconnectIntegration, + onSuccess: (_data, variables) => { + notifications.show({ message: t("Integration disconnected") }); + qc.invalidateQueries({ + queryKey: ["integration-connection", variables.integrationId], + }); + qc.invalidateQueries({ queryKey: ["my-connections"] }); + // removeQueries, not invalidate: refetchOnMount false leaves invalidated inactive queries unrefreshed + qc.removeQueries({ queryKey: ["unfurl"] }); + }, + onError: (error) => { + const errorMessage = error["response"]?.data?.message; + notifications.show({ + message: errorMessage || t("Failed to disconnect integration"), + color: "red", + }); + }, + }); +} diff --git a/apps/client/src/features/integration/services/integration-service.ts b/apps/client/src/features/integration/services/integration-service.ts new file mode 100644 index 000000000..3073b7ad4 --- /dev/null +++ b/apps/client/src/features/integration/services/integration-service.ts @@ -0,0 +1,92 @@ +import api from "@/lib/api-client"; +import { + IntegrationDefinition, + Integration, + ConnectionStatus, + UserConnection, + UnfurlResult, + UnfurlNeedsConnection, +} from "../types/integration.types"; + +export async function getAvailableIntegrations(): Promise< + IntegrationDefinition[] +> { + const req = await api.post( + "/integrations/available", + ); + return req.data; +} + +export async function getInstalledIntegrations(): Promise { + const req = await api.post("/integrations/list"); + return req.data; +} + +export async function installIntegration(data: { + type: string; +}): Promise { + const req = await api.post("/integrations/install", data); + return req.data; +} + +export async function uninstallIntegration(data: { + integrationId: string; +}): Promise { + await api.post("/integrations/uninstall", data); +} + +export async function getMyConnections(): Promise { + const req = await api.post("/integrations/connections/mine"); + return req.data; +} + +export async function getConnectionStatus(data: { + integrationId: string; +}): Promise { + const req = await api.post( + "/integrations/connection/status", + data, + ); + return req.data; +} + +export async function getOAuthAuthorizeUrl(data: { + integrationId: string; + returnPath?: string; +}): Promise<{ authorizationUrl: string }> { + const req = await api.post<{ authorizationUrl: string }>( + "/integrations/oauth/authorize", + data, + ); + return req.data; +} + +/** + * For workspace-scoped providers: returns the authorize URL WITHOUT creating + * the integration row. The row is created atomically when the OAuth callback + * succeeds; a cancelled OAuth leaves no half-installed state. + */ +export async function getOAuthInstallUrl(data: { + type: string; +}): Promise<{ authorizationUrl: string }> { + const req = await api.post<{ authorizationUrl: string }>( + "/integrations/oauth/install", + data, + ); + return req.data; +} + +export async function disconnectIntegration(data: { + integrationId: string; +}): Promise { + await api.post("/integrations/oauth/disconnect", data); +} + +export async function unfurlUrl(data: { + url: string; +}): Promise { + const req = await api.post<{ + data: UnfurlResult | UnfurlNeedsConnection | null; + }>("/integrations/unfurl", data); + return req.data.data; +} diff --git a/apps/client/src/features/integration/services/slack-link-service.ts b/apps/client/src/features/integration/services/slack-link-service.ts new file mode 100644 index 000000000..ea761dba0 --- /dev/null +++ b/apps/client/src/features/integration/services/slack-link-service.ts @@ -0,0 +1,23 @@ +import api from "@/lib/api-client"; + +export type SlackLinkStateInfo = { + slackUserName: string; + slackUserId: string; + slackTeamId: string; + slackTeamName: string | null; + integrationWorkspaceId: string | undefined; +}; + +export async function decodeSlackLinkState( + state: string, +): Promise { + const req = await api.post( + "/integrations/slack/link/state", + { state }, + ); + return req.data; +} + +export async function confirmSlackLink(state: string): Promise { + await api.post("/integrations/slack/link", { state }); +} diff --git a/apps/client/src/features/integration/types/integration.types.ts b/apps/client/src/features/integration/types/integration.types.ts new file mode 100644 index 000000000..9450f4421 --- /dev/null +++ b/apps/client/src/features/integration/types/integration.types.ts @@ -0,0 +1,69 @@ +export type IntegrationCapability = "oauth" | "unfurl" | "actions"; + +export type OAuthConfig = { + authUrl: string; + tokenUrl: string; + scopes: string[]; + connectionScope?: 'workspace' | 'user'; +}; + +export type IntegrationDefinition = { + type: string; + name: string; + description: string; + icon: string; + capabilities: IntegrationCapability[]; + oauth?: OAuthConfig; + requiresLicense?: boolean; +}; + +export type Integration = { + id: string; + workspaceId: string; + type: string; + settings: Record | null; + installedById: string | null; + createdAt: string; + updatedAt: string; +}; + +export type ConnectionStatus = { + connected: boolean; + providerUserId?: string; +}; + +export type UserConnection = { + integrationId: string; + type: string; + providerUserId: string | null; + providerDisplayName: string | null; + connectedAt: string; + invalidatedAt: string | null; +}; + +export type UnfurlResult = { + title: string; + description?: string; + url: string; + provider: string; + providerIcon?: string; + status?: string; + statusColor?: string; + author?: string; + authorAvatarUrl?: string; + metadata?: Record; +}; + +// Returned when the link's provider needs a per-user connection the +// requesting user has not authorized yet. +export type UnfurlNeedsConnection = { + needsConnection: true; + integrationId: string; + integrationType: string; + integrationName: string; + // false for workspace-scoped providers (Slack): linking happens on the + // provider's side, so no Docmost-initiated OAuth button. + oauthConnect: boolean; + title: string; + description?: string; +}; diff --git a/apps/client/src/lib/app-route.ts b/apps/client/src/lib/app-route.ts index 48c1b87d1..6a907c098 100644 --- a/apps/client/src/lib/app-route.ts +++ b/apps/client/src/lib/app-route.ts @@ -27,6 +27,7 @@ const APP_ROUTE = { SPACES: "/settings/spaces", BILLING: "/settings/billing", SECURITY: "/settings/security", + INTEGRATIONS: "/settings/integrations", }, }, }; diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index b8cfc5877..4f5411eca 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -27,6 +27,7 @@ import { LoggerModule } from './common/logger/logger.module'; import { ClsModule } from 'nestjs-cls'; import { NoopAuditModule } from './integrations/audit/audit.module'; import { ThrottleModule } from './integrations/throttle/throttle.module'; +import { EncryptionModule } from './integrations/encryption/encryption.module'; const enterpriseModules = []; try { @@ -53,6 +54,7 @@ try { CoreModule, DatabaseModule, EnvironmentModule, + EncryptionModule, RedisModule.forRootAsync({ useClass: RedisConfigService, }), diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts index ed182cf7f..3d254260e 100644 --- a/apps/server/src/collaboration/collaboration.util.ts +++ b/apps/server/src/collaboration/collaboration.util.ts @@ -49,6 +49,8 @@ import { Footnotes, Footnote, FootnoteReference, + IntegrationLink, + IntegrationMention, } from '@docmost/editor-ext'; import { extensions as coreExtensions, @@ -128,6 +130,8 @@ export const tiptapExtensions = [ Footnotes, Footnote, FootnoteReference, + IntegrationLink, + IntegrationMention ] as any; export function jsonToHtml(tiptapJson: any) { diff --git a/apps/server/src/common/events/audit-events.ts b/apps/server/src/common/events/audit-events.ts index d8be76f83..aa7d536c9 100644 --- a/apps/server/src/common/events/audit-events.ts +++ b/apps/server/src/common/events/audit-events.ts @@ -107,7 +107,6 @@ export const EXCLUDED_AUDIT_EVENTS: Set = new Set([ AuditEvent.PAGE_CREATED, AuditEvent.PAGE_MOVED_TO_SPACE, AuditEvent.PAGE_DUPLICATED, - AuditEvent.COMMENT_CREATED, AuditEvent.COMMENT_UPDATED, AuditEvent.COMMENT_RESOLVED, AuditEvent.COMMENT_REOPENED, diff --git a/apps/server/src/common/events/event.contants.ts b/apps/server/src/common/events/event.contants.ts index 3a0ecba17..6f0404c46 100644 --- a/apps/server/src/common/events/event.contants.ts +++ b/apps/server/src/common/events/event.contants.ts @@ -15,6 +15,7 @@ export enum EventName { WORKSPACE_CREATED = 'workspace.created', WORKSPACE_UPDATED = 'workspace.updated', WORKSPACE_DELETED = 'workspace.deleted', + NOTIFICATION_CREATED = 'notification.created', BASE_CREATED = 'base.created', BASE_UPDATED = 'base.updated', diff --git a/apps/server/src/common/features.ts b/apps/server/src/common/features.ts index 2a889fd08..a3194f0d3 100644 --- a/apps/server/src/common/features.ts +++ b/apps/server/src/common/features.ts @@ -23,6 +23,7 @@ export const Feature = { PERSONAL_SPACES: 'spaces:personal', DOCX_EXPORT: 'export:docx', BASES: 'bases', + INTEGRATIONS: 'integrations', } as const; export type FeatureKey = (typeof Feature)[keyof typeof Feature]; diff --git a/apps/server/src/common/proxy-fetch.spec.ts b/apps/server/src/common/proxy-fetch.spec.ts new file mode 100644 index 000000000..25cbc5f84 --- /dev/null +++ b/apps/server/src/common/proxy-fetch.spec.ts @@ -0,0 +1,64 @@ +import { getProxyAwareFetch, proxyFetch } from './proxy-fetch'; + +describe('getProxyAwareFetch', () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it('returns undefined when no proxy env vars are set', () => { + delete process.env.HTTP_PROXY; + delete process.env.HTTPS_PROXY; + delete process.env.http_proxy; + delete process.env.https_proxy; + + expect(getProxyAwareFetch()).toBeUndefined(); + }); + + it('returns a fetch function when HTTP_PROXY is set', () => { + delete process.env.HTTPS_PROXY; + delete process.env.http_proxy; + delete process.env.https_proxy; + process.env.HTTP_PROXY = 'http://proxy.example.com:8080'; + + expect(typeof getProxyAwareFetch()).toBe('function'); + }); + + it('returns a fetch function when HTTPS_PROXY is set', () => { + delete process.env.HTTP_PROXY; + delete process.env.http_proxy; + delete process.env.https_proxy; + process.env.HTTPS_PROXY = 'http://proxy.example.com:8080'; + + expect(typeof getProxyAwareFetch()).toBe('function'); + }); + + it('returns a fetch function when lowercase http_proxy is set', () => { + delete process.env.HTTP_PROXY; + delete process.env.HTTPS_PROXY; + delete process.env.https_proxy; + process.env.http_proxy = 'http://proxy.example.com:8080'; + + expect(typeof getProxyAwareFetch()).toBe('function'); + }); + + it('proxyFetch delegates to the platform fetch when no proxy is configured', async () => { + delete process.env.HTTP_PROXY; + delete process.env.HTTPS_PROXY; + delete process.env.http_proxy; + delete process.env.https_proxy; + + const original = globalThis.fetch; + const response = new Response('ok'); + const spy = jest.fn().mockResolvedValue(response); + globalThis.fetch = spy as unknown as typeof fetch; + + try { + await expect(proxyFetch('https://example.com')).resolves.toBe(response); + expect(spy).toHaveBeenCalledWith('https://example.com', undefined); + } finally { + globalThis.fetch = original; + } + }); +}); diff --git a/apps/server/src/common/proxy-fetch.ts b/apps/server/src/common/proxy-fetch.ts new file mode 100644 index 000000000..ecaa852e9 --- /dev/null +++ b/apps/server/src/common/proxy-fetch.ts @@ -0,0 +1,38 @@ +import { EnvHttpProxyAgent, fetch as undiciFetch } from 'undici'; + +const LOOPBACK_BYPASS = ['localhost', '127.0.0.1', '::1']; + +let cachedAgent: EnvHttpProxyAgent | undefined; + +function hasProxyEnv(): boolean { + return Boolean( + process.env.HTTP_PROXY || + process.env.HTTPS_PROXY || + process.env.http_proxy || + process.env.https_proxy, + ); +} + +function buildAgent(): EnvHttpProxyAgent { + const existing = process.env.NO_PROXY || process.env.no_proxy || ''; + const merged = [existing, ...LOOPBACK_BYPASS] + .map((s) => s.trim()) + .filter(Boolean) + .join(','); + return new EnvHttpProxyAgent({ noProxy: merged }); +} + +export function getProxyAwareFetch(): typeof fetch | undefined { + if (!hasProxyEnv()) return undefined; + cachedAgent ??= buildAgent(); + const agent = cachedAgent; + return ((input, init) => + undiciFetch(input as any, { + ...(init as any), + dispatcher: agent, + }) as unknown as Promise) as typeof fetch; +} + +// Drop-in replacement for direct fetch calls: proxies when configured, platform fetch otherwise. +export const proxyFetch: typeof fetch = (input, init) => + (getProxyAwareFetch() ?? fetch)(input, init); diff --git a/apps/server/src/core/core.module.ts b/apps/server/src/core/core.module.ts index e898a4a1c..a014c4abb 100644 --- a/apps/server/src/core/core.module.ts +++ b/apps/server/src/core/core.module.ts @@ -21,6 +21,9 @@ import { ShareModule } from './share/share.module'; import { LabelModule } from './label/label.module'; import { NotificationModule } from './notification/notification.module'; import { WatcherModule } from './watcher/watcher.module'; +import { IntegrationModule } from './integration/integration.module'; +import { GitHubModule } from './integration/providers/github/github.module'; +import { GitLabModule } from './integration/providers/gitlab/gitlab.module'; import { FavoriteModule } from './favorite/favorite.module'; import { SessionModule } from './session/session.module'; import { ClsMiddleware } from 'nestjs-cls'; @@ -43,6 +46,9 @@ import { ClsMiddleware } from 'nestjs-cls'; LabelModule, NotificationModule, WatcherModule, + IntegrationModule, + GitHubModule, + GitLabModule, SessionModule, ], }) @@ -53,6 +59,7 @@ export class CoreModule implements NestModule { { path: 'health', method: RequestMethod.GET }, { path: 'health/live', method: RequestMethod.GET }, { path: 'billing/stripe/webhook', method: RequestMethod.POST }, + { path: 'integrations/oauth/*/callback', method: RequestMethod.GET }, ]; consumer diff --git a/apps/server/src/core/integration/constants.ts b/apps/server/src/core/integration/constants.ts new file mode 100644 index 000000000..96ffdb8ea --- /dev/null +++ b/apps/server/src/core/integration/constants.ts @@ -0,0 +1,9 @@ +export enum IntegrationType { + SLACK = 'slack', + GITHUB = 'github', + GITLAB = 'gitlab', + JIRA = 'jira', + LINEAR = 'linear', + GOOGLE_DOCS = 'google_docs', + FIGMA = 'figma', +} diff --git a/apps/server/src/core/integration/dto/integration.dto.ts b/apps/server/src/core/integration/dto/integration.dto.ts new file mode 100644 index 000000000..f714aeaf9 --- /dev/null +++ b/apps/server/src/core/integration/dto/integration.dto.ts @@ -0,0 +1,57 @@ +import { + IsNotEmpty, + IsOptional, + IsString, + Matches, + MaxLength, +} from 'class-validator'; + +export class InstallIntegrationDto { + @IsNotEmpty() + @IsString() + type: string; +} + +export class UninstallIntegrationDto { + @IsNotEmpty() + @IsString() + integrationId: string; +} + +export class IntegrationIdDto { + @IsNotEmpty() + @IsString() + integrationId: string; +} + +export class UnfurlDto { + @IsNotEmpty() + @IsString() + url: string; +} + +export class OAuthAuthorizeDto { + @IsNotEmpty() + @IsString() + integrationId: string; + + // In-app path to land on after OAuth; single leading slash keeps the + // redirect on the workspace origin. + @IsOptional() + @IsString() + @MaxLength(512) + @Matches(/^\/(?!\/)[^\s\\]*$/) + returnPath?: string; +} + +export class OAuthDisconnectDto { + @IsNotEmpty() + @IsString() + integrationId: string; +} + +export class OAuthInstallDto { + @IsNotEmpty() + @IsString() + type: string; +} diff --git a/apps/server/src/core/integration/integration-connection.service.ts b/apps/server/src/core/integration/integration-connection.service.ts new file mode 100644 index 000000000..455376eb3 --- /dev/null +++ b/apps/server/src/core/integration/integration-connection.service.ts @@ -0,0 +1,89 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { IntegrationConnectionRepo } from './repos/integration-connection.repo'; +import { IntegrationRepo } from './repos/integration.repo'; +import { IntegrationConnection } from '@docmost/db/types/entity.types'; +import { UnfurlService } from './unfurl/unfurl.service'; + +@Injectable() +export class IntegrationConnectionService { + constructor( + private readonly connectionRepo: IntegrationConnectionRepo, + private readonly integrationRepo: IntegrationRepo, + private readonly unfurlService: UnfurlService, + ) {} + + async getConnectionStatus( + integrationId: string, + userId: string, + workspaceId: string, + ): Promise<{ connected: boolean; providerUserId?: string }> { + const integration = await this.integrationRepo.findById(integrationId); + if (!integration || integration.workspaceId !== workspaceId) { + throw new NotFoundException('Integration not found'); + } + + const connection = await this.connectionRepo.findByIntegrationAndUser( + integrationId, + userId, + ); + + return { + connected: !!connection && !connection.invalidatedAt, + providerUserId: connection?.providerUserId ?? undefined, + }; + } + + async findByIntegrationAndUser( + integrationId: string, + userId: string, + ): Promise { + return this.connectionRepo.findByIntegrationAndUser(integrationId, userId); + } + + async findByWorkspaceTypeAndUser( + workspaceId: string, + integrationType: string, + userId: string, + ): Promise { + return this.connectionRepo.findByWorkspaceTypeAndUser( + workspaceId, + integrationType, + userId, + ); + } + + async getUserConnections(userId: string, workspaceId: string) { + const rows = await this.connectionRepo.findByUserAndWorkspace( + userId, + workspaceId, + ); + + return rows.map((row) => ({ + integrationId: row.integrationId, + type: row.type, + providerUserId: row.providerUserId ?? null, + providerDisplayName: + (row.metadata as { displayName?: string } | null)?.displayName ?? null, + connectedAt: row.createdAt, + invalidatedAt: row.invalidatedAt ?? null, + })); + } + + async disconnect( + integrationId: string, + userId: string, + workspaceId: string, + ): Promise { + const integration = await this.integrationRepo.findById(integrationId); + if (!integration || integration.workspaceId !== workspaceId) { + throw new NotFoundException('Integration not found'); + } + + await this.connectionRepo.deleteByIntegrationAndUser( + integrationId, + userId, + ); + + await this.unfurlService.purgeUserCache(workspaceId, userId); + } +} diff --git a/apps/server/src/core/integration/integration.controller.ts b/apps/server/src/core/integration/integration.controller.ts new file mode 100644 index 000000000..739f16eed --- /dev/null +++ b/apps/server/src/core/integration/integration.controller.ts @@ -0,0 +1,138 @@ +import { + Body, + Controller, + ForbiddenException, + HttpCode, + HttpStatus, + Post, + UseGuards, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { AuthUser } from '../../common/decorators/auth-user.decorator'; +import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; +import { User, Workspace } from '@docmost/db/types/entity.types'; +import { IntegrationService } from './integration.service'; +import { IntegrationConnectionService } from './integration-connection.service'; +import { + InstallIntegrationDto, + UninstallIntegrationDto, + IntegrationIdDto, +} from './dto/integration.dto'; +import { IntegrationRegistry } from './registry/integration-registry'; +import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory'; +import { + WorkspaceCaslAction, + WorkspaceCaslSubject, +} from '../casl/interfaces/workspace-ability.type'; +import { LicenseCheckService } from '../../integrations/environment/license-check.service'; +import { Feature } from '../../common/features'; + +@Controller('integrations') +export class IntegrationController { + constructor( + private readonly integrationService: IntegrationService, + private readonly connectionService: IntegrationConnectionService, + private readonly workspaceAbility: WorkspaceAbilityFactory, + private readonly licenseCheckService: LicenseCheckService, + private readonly registry: IntegrationRegistry, + ) {} + + private assertIntegrationsLicensed(workspace: Workspace) { + if ( + !this.licenseCheckService.hasFeature( + workspace.licenseKey, + Feature.INTEGRATIONS, + workspace.plan, + ) + ) { + throw new ForbiddenException('This feature requires a valid license'); + } + } + + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @Post('available') + async getAvailableIntegrations() { + return this.integrationService.getAvailableIntegrations(); + } + + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @Post('list') + async getInstalledIntegrations( + @AuthWorkspace() workspace: Workspace, + ) { + return this.integrationService.getInstalledIntegrations(workspace.id); + } + + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @Post('install') + async install( + @Body() dto: InstallIntegrationDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + const ability = this.workspaceAbility.createForUser(user, workspace); + if ( + ability.cannot( + WorkspaceCaslAction.Manage, + WorkspaceCaslSubject.Settings, + ) + ) { + throw new ForbiddenException(); + } + + if (this.registry.getProvider(dto.type)?.definition.requiresLicense) { + this.assertIntegrationsLicensed(workspace); + } + return this.integrationService.install(dto.type, workspace.id, user.id); + } + + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @Post('uninstall') + async uninstall( + @Body() dto: UninstallIntegrationDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + const ability = this.workspaceAbility.createForUser(user, workspace); + if ( + ability.cannot( + WorkspaceCaslAction.Manage, + WorkspaceCaslSubject.Settings, + ) + ) { + throw new ForbiddenException(); + } + + await this.integrationService.uninstall(dto.integrationId, workspace.id); + return { success: true }; + } + + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @Post('connections/mine') + async getMyConnections( + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + return this.connectionService.getUserConnections(user.id, workspace.id); + } + + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @Post('connection/status') + async getConnectionStatus( + @Body() dto: IntegrationIdDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + return this.connectionService.getConnectionStatus( + dto.integrationId, + user.id, + workspace.id, + ); + } +} diff --git a/apps/server/src/core/integration/integration.listener.ts b/apps/server/src/core/integration/integration.listener.ts new file mode 100644 index 000000000..9144fb654 --- /dev/null +++ b/apps/server/src/core/integration/integration.listener.ts @@ -0,0 +1,55 @@ +import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue } from 'bullmq'; +import { QueueJob, QueueName } from '../../integrations/queue/constants'; +import { EventName } from '../../common/events/event.contants'; + +const TOKEN_REFRESH_SCHEDULER_ID = 'integration-token-refresh-scheduler'; +const TOKEN_REFRESH_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes + +@Injectable() +export class IntegrationListener implements OnApplicationBootstrap { + private readonly logger = new Logger(IntegrationListener.name); + + constructor( + @InjectQueue(QueueName.INTEGRATION_QUEUE) + private readonly integrationQueue: Queue, + ) {} + + async onApplicationBootstrap() { + await this.integrationQueue.upsertJobScheduler( + TOKEN_REFRESH_SCHEDULER_ID, + { every: TOKEN_REFRESH_INTERVAL_MS }, + { + name: QueueJob.INTEGRATION_TOKEN_REFRESH, + data: {}, + }, + ); + this.logger.debug('Integration token refresh scheduler created'); + } + + @OnEvent(EventName.PAGE_CREATED) + async onPageCreated(payload: any) { + await this.integrationQueue.add(QueueJob.INTEGRATION_EVENT, { + eventName: EventName.PAGE_CREATED, + ...payload, + }); + } + + @OnEvent(EventName.PAGE_UPDATED) + async onPageUpdated(payload: any) { + await this.integrationQueue.add(QueueJob.INTEGRATION_EVENT, { + eventName: EventName.PAGE_UPDATED, + ...payload, + }); + } + + @OnEvent(EventName.PAGE_DELETED) + async onPageDeleted(payload: any) { + await this.integrationQueue.add(QueueJob.INTEGRATION_EVENT, { + eventName: EventName.PAGE_DELETED, + ...payload, + }); + } +} diff --git a/apps/server/src/core/integration/integration.module.ts b/apps/server/src/core/integration/integration.module.ts new file mode 100644 index 000000000..3b1521457 --- /dev/null +++ b/apps/server/src/core/integration/integration.module.ts @@ -0,0 +1,37 @@ +import { Module } from '@nestjs/common'; +import { IntegrationRegistry } from './registry/integration-registry'; +import { IntegrationService } from './integration.service'; +import { IntegrationConnectionService } from './integration-connection.service'; +import { IntegrationController } from './integration.controller'; +import { OAuthController } from './oauth/oauth.controller'; +import { OAuthService } from './oauth/oauth.service'; +import { UnfurlController } from './unfurl/unfurl.controller'; +import { UnfurlService } from './unfurl/unfurl.service'; +import { IntegrationRepo } from './repos/integration.repo'; +import { IntegrationConnectionRepo } from './repos/integration-connection.repo'; +import { IntegrationListener } from './integration.listener'; +import { IntegrationProcessor } from './integration.processor'; + +@Module({ + controllers: [IntegrationController, OAuthController, UnfurlController], + providers: [ + IntegrationRegistry, + IntegrationService, + IntegrationConnectionService, + OAuthService, + UnfurlService, + IntegrationRepo, + IntegrationConnectionRepo, + IntegrationListener, + IntegrationProcessor, + ], + exports: [ + IntegrationRegistry, + IntegrationService, + IntegrationConnectionService, + OAuthService, + IntegrationRepo, + IntegrationConnectionRepo, + ], +}) +export class IntegrationModule {} diff --git a/apps/server/src/core/integration/integration.processor.ts b/apps/server/src/core/integration/integration.processor.ts new file mode 100644 index 000000000..d8de890f9 --- /dev/null +++ b/apps/server/src/core/integration/integration.processor.ts @@ -0,0 +1,133 @@ +import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq'; +import { Logger, NotFoundException } from '@nestjs/common'; +import { IntegrationConnection } from '@docmost/db/types/entity.types'; +import { TokenInvalidError } from './registry/integration-provider.interface'; +import { Job } from 'bullmq'; +import { QueueJob, QueueName } from '../../integrations/queue/constants/queue.constants'; +import { IntegrationRegistry } from './registry/integration-registry'; +import { IntegrationRepo } from './repos/integration.repo'; +import { IntegrationConnectionRepo } from './repos/integration-connection.repo'; +import { OAuthService } from './oauth/oauth.service'; + +const TOKEN_REFRESH_WINDOW_MS = 15 * 60 * 1000; // 15 minutes + +@Processor(QueueName.INTEGRATION_QUEUE) +export class IntegrationProcessor extends WorkerHost { + private readonly logger = new Logger(IntegrationProcessor.name); + + constructor( + private readonly registry: IntegrationRegistry, + private readonly integrationRepo: IntegrationRepo, + private readonly connectionRepo: IntegrationConnectionRepo, + private readonly oauthService: OAuthService, + ) { + super(); + } + + async process(job: Job): Promise { + switch (job.name) { + case QueueJob.INTEGRATION_EVENT: + await this.handleIntegrationEvent(job); + break; + case QueueJob.INTEGRATION_TOKEN_REFRESH: + await this.handleTokenRefresh(); + break; + default: + this.logger.warn(`Unknown job: ${job.name}`); + } + } + + // Route worker-level errors (e.g. lock renewal after laptop sleep) through + // the logger instead of bullmq's raw console.error fallback. + @OnWorkerEvent('error') + onError(err: Error): void { + this.logger.error(`Worker error: ${err.message}`); + } + + private async handleTokenRefresh(): Promise { + const connections = await this.connectionRepo.findExpiringTokens( + TOKEN_REFRESH_WINDOW_MS, + ); + + if (connections.length === 0) { + return; + } + + this.logger.log( + `Refreshing tokens for ${connections.length} connection(s)`, + ); + + for (const connection of connections) { + try { + await this.oauthService.getValidAccessToken(connection); + } catch (err) { + this.logger.error( + `Token refresh failed for connection ${connection.id}: ${(err as Error).message}`, + ); + // Dead credential or orphaned row: retire it so findExpiringTokens stops selecting it. + if ( + err instanceof NotFoundException || + err instanceof TokenInvalidError + ) { + await this.connectionRepo + .invalidate(connection.id) + .catch(() => undefined); + } + } + } + } + + private async handleIntegrationEvent(job: Job): Promise { + const { eventName, workspaceId, ...payload } = job.data; + + if (!workspaceId) { + return; + } + + const integrations = + await this.integrationRepo.findAllByWorkspace(workspaceId); + + for (const integration of integrations) { + const provider = this.registry.getProvider(integration.type); + if (!provider?.handleEvent) { + continue; + } + + let connection: IntegrationConnection | undefined; + try { + const connections = await this.connectionRepo.findByIntegration( + integration.id, + ); + + connection = connections[0]; + let accessToken: string | undefined; + + if (connection) { + accessToken = await this.oauthService.getValidAccessToken(connection); + } + + await provider.handleEvent({ + eventName, + payload, + integration: { + id: integration.id, + type: integration.type, + settings: integration.settings as Record | null, + }, + connection: connection + ? { accessToken, userId: connection.userId } + : undefined, + }); + } catch (err) { + this.logger.error( + `Integration event handler failed for ${integration.type}: ${(err as Error).message}`, + ); + if (err instanceof TokenInvalidError && connection) { + await this.connectionRepo + .invalidate(connection.id) + .catch(() => undefined); + } + } + } + } +} diff --git a/apps/server/src/core/integration/integration.service.ts b/apps/server/src/core/integration/integration.service.ts new file mode 100644 index 000000000..f362fe5d7 --- /dev/null +++ b/apps/server/src/core/integration/integration.service.ts @@ -0,0 +1,80 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB } from '@docmost/db/types/kysely.types'; +import { executeTx } from '@docmost/db/utils'; +import { IntegrationRepo } from './repos/integration.repo'; +import { IntegrationConnectionRepo } from './repos/integration-connection.repo'; +import { IntegrationRegistry } from './registry/integration-registry'; +import { Integration } from '@docmost/db/types/entity.types'; + +@Injectable() +export class IntegrationService { + constructor( + @InjectKysely() private readonly db: KyselyDB, + private readonly integrationRepo: IntegrationRepo, + private readonly connectionRepo: IntegrationConnectionRepo, + private readonly registry: IntegrationRegistry, + ) {} + + async getAvailableIntegrations() { + return this.registry.getAvailableIntegrations(); + } + + async getInstalledIntegrations(workspaceId: string): Promise { + return this.integrationRepo.findAllByWorkspace(workspaceId); + } + + async findById(integrationId: string): Promise { + return this.integrationRepo.findById(integrationId); + } + + async install( + type: string, + workspaceId: string, + userId: string, + ): Promise { + const provider = this.registry.getProvider(type); + if (!provider || provider.definition.hidden) { + throw new BadRequestException(`Unknown integration type: ${type}`); + } + + // OAuth providers install via install-and-authorize (see OAuthService). + if (provider.definition.oauth) { + throw new BadRequestException( + 'This integration is installed by completing its OAuth flow', + ); + } + + const existing = await this.integrationRepo.findByWorkspaceAndType( + workspaceId, + type, + ); + if (existing) { + throw new BadRequestException( + `Integration "${type}" is already installed`, + ); + } + + return this.integrationRepo.insertOrRestore({ + type, + workspaceId, + installedById: userId, + }); + } + + async uninstall(integrationId: string, workspaceId: string): Promise { + const integration = await this.integrationRepo.findById(integrationId); + if (!integration || integration.workspaceId !== workspaceId) { + throw new NotFoundException('Integration not found'); + } + // Delete child rows first so no orphan connections keep feeding the token refresh scheduler. + await executeTx(this.db, async (trx) => { + await this.connectionRepo.deleteByIntegration(integrationId, trx); + await this.integrationRepo.softDelete(integrationId, trx); + }); + } +} diff --git a/apps/server/src/core/integration/oauth/oauth.controller.ts b/apps/server/src/core/integration/oauth/oauth.controller.ts new file mode 100644 index 000000000..1540b694b --- /dev/null +++ b/apps/server/src/core/integration/oauth/oauth.controller.ts @@ -0,0 +1,159 @@ +import { + BadRequestException, + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Logger, + Param, + Post, + Query, + Res, + UseGuards, +} from '@nestjs/common'; +import { FastifyReply } from 'fastify'; +import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard'; +import { AuthUser } from '../../../common/decorators/auth-user.decorator'; +import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator'; +import { User, Workspace } from '@docmost/db/types/entity.types'; +import { OAuthService } from './oauth.service'; +import { + OAuthAuthorizeDto, + OAuthDisconnectDto, + OAuthInstallDto, +} from '../dto/integration.dto'; +import { IntegrationConnectionService } from '../integration-connection.service'; +import { IntegrationRegistry } from '../registry/integration-registry'; +import { LicenseCheckService } from '../../../integrations/environment/license-check.service'; +import { Feature } from '../../../common/features'; +import { ForbiddenException } from '@nestjs/common'; + +@Controller('integrations/oauth') +export class OAuthController { + private readonly logger = new Logger(OAuthController.name); + + constructor( + private readonly oauthService: OAuthService, + private readonly connectionService: IntegrationConnectionService, + private readonly licenseCheckService: LicenseCheckService, + private readonly registry: IntegrationRegistry, + ) {} + + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @Post('authorize') + async authorize( + @Body() dto: OAuthAuthorizeDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + const { authorizationUrl } = await this.oauthService.getAuthorizationUrl( + dto.integrationId, + workspace.id, + user.id, + dto.returnPath, + ); + + return { authorizationUrl }; + } + + /** + * Install-and-authorize: the install flow for every OAuth provider. The + * integration row is only created on callback after a successful token + * exchange; a cancelled or failed flow persists nothing. + */ + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @Post('install') + async installAndAuthorize( + @Body() dto: OAuthInstallDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + // This flow creates the integration row on callback success; gate it + // like a plain install. + if ( + this.registry.getProvider(dto.type)?.definition.requiresLicense && + !this.licenseCheckService.hasFeature( + workspace.licenseKey, + Feature.INTEGRATIONS, + workspace.plan, + ) + ) { + throw new ForbiddenException('This feature requires a valid license'); + } + + const { authorizationUrl } = await this.oauthService.getInstallAuthorizationUrl( + dto.type, + workspace.id, + user.id, + ); + + return { authorizationUrl }; + } + + @Get(':type/callback') + async callback( + @Param('type') type: string, + @Query('code') code: string, + @Query('state') state: string, + @Res() res: FastifyReply, + ) { + if (!state) { + throw new BadRequestException('Missing state parameter'); + } + + const statePayload = this.oauthService.verifySignedState(state); + if (!statePayload) { + throw new BadRequestException('Invalid or expired OAuth state'); + } + + // returnUrl is derived server-side at authorize time from the workspace's + // own hostname/customDomain (canonical DB truth, not user input), then + // signed into the state JWT. Tampering would invalidate the signature. + const returnUrl = statePayload.returnUrl; + // States signed before returnPath existed fall back to the admin page. + const returnPath = statePayload.returnPath ?? '/settings/integrations'; + + // Consent denied or cancelled at the provider: no code comes back. + if (!code) { + return res + .redirect(`${returnUrl}${returnPath}?error=oauth_failed`, 302) + .send(); + } + + try { + await this.oauthService.exchangeCodeForTokens( + type, + code, + statePayload.integrationId, + statePayload.userId, + statePayload.workspaceId, + ); + + return res.redirect(`${returnUrl}${returnPath}`, 302).send(); + } catch (err) { + this.logger.error(`OAuth callback error for ${type}: ${(err as Error).message}`); + return res + .redirect(`${returnUrl}${returnPath}?error=oauth_failed`, 302) + .send(); + } + } + + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @Post('disconnect') + async disconnect( + @Body() dto: OAuthDisconnectDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + await this.connectionService.disconnect( + dto.integrationId, + user.id, + workspace.id, + ); + return { success: true }; + } +} diff --git a/apps/server/src/core/integration/oauth/oauth.service.ts b/apps/server/src/core/integration/oauth/oauth.service.ts new file mode 100644 index 000000000..6ffb20da9 --- /dev/null +++ b/apps/server/src/core/integration/oauth/oauth.service.ts @@ -0,0 +1,473 @@ +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { EnvironmentService } from '../../../integrations/environment/environment.service'; +import { DomainService } from '../../../integrations/environment/domain.service'; +import { IntegrationRegistry } from '../registry/integration-registry'; +import { IntegrationRepo } from '../repos/integration.repo'; +import { IntegrationConnectionRepo } from '../repos/integration-connection.repo'; +import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; +import { EncryptionService } from '../../../integrations/encryption/encryption.service'; +import { IntegrationConnection } from '@docmost/db/types/entity.types'; +import { + OAuthConfig, + TokenInvalidError, +} from '../registry/integration-provider.interface'; +import { proxyFetch } from '../../../common/proxy-fetch'; +import * as crypto from 'crypto'; + +const OAUTH_HTTP_TIMEOUT_MS = 10_000; + +type OAuthTokenResponse = { + access_token: string; + refresh_token?: string; + expires_in?: number; + token_type?: string; + scope?: string; +}; + +export type OAuthStatePayload = { + // For "authorize-only" flows (per-user OAuth on an already-installed + // integration) integrationId is set; for "install-and-authorize" flows + // (workspace-scoped providers like Slack) it's null until the callback + // resolves-or-creates the row atomically with token exchange success. + integrationId: string | null; + type: string; + userId: string; + workspaceId: string; + // Workspace's canonical URL at authorize time. Cloud workspaces are routed + // through a single central OAuth callback (the only redirect_uri Slack/etc. + // accept), and this lets the callback redirect the user back to their own + // workspace host (subdomain or custom domain) after token exchange. + returnUrl: string; + // Settings page (relative to returnUrl) to land on after the callback. + // Derived server-side from the flow that started it, never from user input. + returnPath?: string; + exp: number; +}; + +@Injectable() +export class OAuthService { + private readonly logger = new Logger(OAuthService.name); + + constructor( + private readonly environmentService: EnvironmentService, + private readonly domainService: DomainService, + private readonly registry: IntegrationRegistry, + private readonly integrationRepo: IntegrationRepo, + private readonly connectionRepo: IntegrationConnectionRepo, + private readonly workspaceRepo: WorkspaceRepo, + private readonly encryptionService: EncryptionService, + ) {} + + async getAuthorizationUrl( + integrationId: string, + workspaceId: string, + userId: string, + returnPathOverride?: string, + ): Promise<{ authorizationUrl: string }> { + const integration = await this.integrationRepo.findById(integrationId); + if (!integration || integration.workspaceId !== workspaceId) { + throw new NotFoundException('Integration not found'); + } + + const provider = this.registry.getProvider(integration.type); + if (!provider || !provider.definition.oauth) { + throw new BadRequestException('Integration does not support OAuth'); + } + + const oauthConfig = provider.getOAuthConfig + ? provider.getOAuthConfig((integration.settings as Record) ?? {}) + : provider.definition.oauth; + + const callbackUrl = this.buildCallbackUrl(integration.type); + + const workspace = await this.workspaceRepo.findById(workspaceId); + const returnUrl = this.domainService.getWorkspaceUrl( + workspace ?? { hostname: null, customDomain: null }, + ); + + // Per-user connects are initiated from the account connections page; + // workspace-scoped authorizes from the admin integrations page. A connect + // started elsewhere (e.g. an editor connect card) passes its own path. + const returnPath = + returnPathOverride ?? + ((provider.definition.oauth.connectionScope ?? 'user') === 'workspace' + ? '/settings/integrations' + : '/settings/account/connections'); + + const state = this.createSignedState({ + integrationId, + type: integration.type, + userId, + workspaceId, + returnUrl, + returnPath, + exp: Date.now() + 10 * 60 * 1000, + }); + + const params = new URLSearchParams({ + client_id: this.getClientId(integration.type), + redirect_uri: callbackUrl, + response_type: 'code', + state, + }); + + const scope = oauthConfig.scopes + .map((s) => encodeURIComponent(s)) + .join('%20'); + + return { + authorizationUrl: `${oauthConfig.authUrl}?${params.toString()}&scope=${scope}`, + }; + } + + /** + * Install-and-authorize: the install flow for every OAuth provider. + * + * Skips creating the integration row up front. The callback persists it + * only after a successful token exchange, so a cancelled consent screen or + * misconfigured client credentials leave nothing half-installed. Refusing + * the already-installed case here keeps the install button idempotent. + */ + async getInstallAuthorizationUrl( + type: string, + workspaceId: string, + userId: string, + ): Promise<{ authorizationUrl: string }> { + const provider = this.registry.getProvider(type); + if (!provider || !provider.definition.oauth) { + throw new BadRequestException('Integration does not support OAuth'); + } + + const existing = await this.integrationRepo.findByWorkspaceAndType( + workspaceId, + type, + ); + if (existing) { + throw new BadRequestException( + `Integration "${type}" is already installed`, + ); + } + + const oauthConfig = provider.getOAuthConfig + ? provider.getOAuthConfig({}) + : provider.definition.oauth; + + const callbackUrl = this.buildCallbackUrl(type); + + const workspace = await this.workspaceRepo.findById(workspaceId); + const returnUrl = this.domainService.getWorkspaceUrl( + workspace ?? { hostname: null, customDomain: null }, + ); + + const state = this.createSignedState({ + integrationId: null, + type, + userId, + workspaceId, + returnUrl, + returnPath: '/settings/integrations', + exp: Date.now() + 10 * 60 * 1000, + }); + + const params = new URLSearchParams({ + client_id: this.getClientId(type), + redirect_uri: callbackUrl, + response_type: 'code', + state, + }); + + const scope = oauthConfig.scopes + .map((s) => encodeURIComponent(s)) + .join('%20'); + + return { + authorizationUrl: `${oauthConfig.authUrl}?${params.toString()}&scope=${scope}`, + }; + } + + verifySignedState(state: string): OAuthStatePayload | null { + const dotIndex = state.lastIndexOf('.'); + if (dotIndex === -1) return null; + + const data = state.substring(0, dotIndex); + const signature = state.substring(dotIndex + 1); + + const secret = this.environmentService.getAppSecret(); + const expected = crypto + .createHmac('sha256', secret) + .update(data) + .digest('base64url'); + + if (signature !== expected) return null; + + try { + const payload: OAuthStatePayload = JSON.parse( + Buffer.from(data, 'base64url').toString(), + ); + + if (payload.exp < Date.now()) return null; + + return payload; + } catch { + return null; + } + } + + async exchangeCodeForTokens( + type: string, + code: string, + integrationId: string | null, + userId: string, + workspaceId: string, + ): Promise { + const provider = this.registry.getProvider(type); + if (!provider || !provider.definition.oauth) { + throw new BadRequestException('Integration does not support OAuth'); + } + + // Install flow: no row yet; persisted only after the token exchange succeeds. + let integration = integrationId + ? await this.integrationRepo.findById(integrationId) + : null; + + const settings = (integration?.settings as Record) ?? {}; + + const oauthConfig = provider.getOAuthConfig + ? provider.getOAuthConfig(settings) + : provider.definition.oauth; + + const tokenResponse = await this.requestTokens( + oauthConfig, + type, + code, + ); + + if (!integration) { + integration = await this.integrationRepo.insertOrRestore({ + type, + workspaceId, + installedById: userId, + }); + integrationId = integration.id; + } + + const encryptedAccessToken = this.encryptionService.encrypt( + tokenResponse.access_token, + ); + const encryptedRefreshToken = tokenResponse.refresh_token + ? this.encryptionService.encrypt(tokenResponse.refresh_token) + : null; + + const tokenExpiresAt = tokenResponse.expires_in + ? new Date(Date.now() + tokenResponse.expires_in * 1000) + : null; + + const connectionScope = + provider.definition.oauth?.connectionScope ?? 'user'; + + const connection = + connectionScope === 'workspace' + ? await this.connectionRepo.upsertWorkspaceConnection({ + integrationId, + userId, + workspaceId, + accessToken: encryptedAccessToken, + refreshToken: encryptedRefreshToken, + tokenExpiresAt, + scopes: tokenResponse.scope ?? null, + }) + : await this.connectionRepo.upsert({ + integrationId, + userId, + workspaceId, + accessToken: encryptedAccessToken, + refreshToken: encryptedRefreshToken, + tokenExpiresAt, + scopes: tokenResponse.scope ?? null, + }); + + if (provider.onConnected) { + await provider.onConnected({ + integrationId, + workspaceId, + accessToken: tokenResponse.access_token, + refreshToken: tokenResponse.refresh_token, + userId, + metadata: tokenResponse, + }); + } + + return connection; + } + + async getValidAccessToken( + connection: IntegrationConnection, + ): Promise { + if (connection.invalidatedAt) { + throw new TokenInvalidError(); + } + const accessToken = this.encryptionService.decrypt(connection.accessToken); + + const needsRefresh = + connection.tokenExpiresAt && + connection.refreshToken && + new Date(connection.tokenExpiresAt).getTime() - Date.now() < 5 * 60 * 1000; + + if (!needsRefresh) { + return accessToken; + } + + return this.refreshAccessToken(connection); + } + + private async refreshAccessToken( + connection: IntegrationConnection, + ): Promise { + const refreshToken = this.encryptionService.decrypt( + connection.refreshToken, + ); + + const integration = await this.integrationRepo.findById( + connection.integrationId, + ); + if (!integration) { + throw new NotFoundException('Integration not found'); + } + + const provider = this.registry.getProvider(integration.type); + if (!provider || !provider.definition.oauth) { + throw new BadRequestException('Integration does not support OAuth'); + } + + const oauthConfig = provider.getOAuthConfig + ? provider.getOAuthConfig((integration.settings as Record) ?? {}) + : provider.definition.oauth; + + const params = new URLSearchParams({ + grant_type: 'refresh_token', + client_id: this.getClientId(integration.type), + client_secret: this.getClientSecret(integration.type), + refresh_token: refreshToken, + }); + + try { + const response = await proxyFetch(oauthConfig.tokenUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, + body: params.toString(), + signal: AbortSignal.timeout(OAUTH_HTTP_TIMEOUT_MS), + }); + + if (!response.ok) { + this.logger.error( + `Token refresh failed for ${integration.type}: ${response.status}`, + ); + // 400/401 from the token endpoint means invalid_grant/invalid_client: + // the refresh token is dead, not a transient failure. + if (response.status === 400 || response.status === 401) { + throw new TokenInvalidError( + `Refresh token rejected for ${integration.type}`, + ); + } + throw new BadRequestException('Token refresh failed'); + } + + const data: OAuthTokenResponse = await response.json(); + const encryptedAccessToken = this.encryptionService.encrypt( + data.access_token, + ); + const encryptedRefreshToken = data.refresh_token + ? this.encryptionService.encrypt(data.refresh_token) + : connection.refreshToken; + const tokenExpiresAt = data.expires_in + ? new Date(Date.now() + data.expires_in * 1000) + : null; + + await this.connectionRepo.update(connection.id, { + accessToken: encryptedAccessToken, + refreshToken: encryptedRefreshToken, + tokenExpiresAt, + invalidatedAt: null, + }); + + return data.access_token; + } catch (err) { + if (err instanceof TokenInvalidError) { + throw err; + } + this.logger.error(`Token refresh error: ${(err as Error).message}`); + throw new BadRequestException('Failed to refresh token'); + } + } + + private async requestTokens( + oauthConfig: OAuthConfig, + type: string, + code: string, + ): Promise { + const params = new URLSearchParams({ + grant_type: 'authorization_code', + client_id: this.getClientId(type), + client_secret: this.getClientSecret(type), + code, + redirect_uri: this.buildCallbackUrl(type), + }); + + const response = await proxyFetch(oauthConfig.tokenUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, + body: params.toString(), + signal: AbortSignal.timeout(OAUTH_HTTP_TIMEOUT_MS), + }); + + if (!response.ok) { + const body = await response.text(); + this.logger.error(`Token exchange failed for ${type}: ${response.status} ${body}`); + throw new BadRequestException('OAuth token exchange failed'); + } + + return response.json(); + } + + buildCallbackUrl(type: string): string { + const appUrl = this.environmentService.getAppUrl(); + return `${appUrl}/api/integrations/oauth/${type}/callback`; + } + + private createSignedState(payload: OAuthStatePayload): string { + const data = Buffer.from(JSON.stringify(payload)).toString('base64url'); + const secret = this.environmentService.getAppSecret(); + const signature = crypto + .createHmac('sha256', secret) + .update(data) + .digest('base64url'); + return `${data}.${signature}`; + } + + private getClientId(type: string): string { + const envKey = `INTEGRATION_${type.toUpperCase()}_CLIENT_ID`; + const value = process.env[envKey]; + if (!value) { + throw new BadRequestException( + `Missing environment variable: ${envKey}`, + ); + } + return value; + } + + private getClientSecret(type: string): string { + const envKey = `INTEGRATION_${type.toUpperCase()}_CLIENT_SECRET`; + const value = process.env[envKey]; + if (!value) { + throw new BadRequestException( + `Missing environment variable: ${envKey}`, + ); + } + return value; + } +} diff --git a/apps/server/src/core/integration/providers/github/github-patterns.ts b/apps/server/src/core/integration/providers/github/github-patterns.ts new file mode 100644 index 000000000..bc29a842f --- /dev/null +++ b/apps/server/src/core/integration/providers/github/github-patterns.ts @@ -0,0 +1,72 @@ +import { UnfurlPattern } from '../../registry/integration-provider.interface'; + +function escapeForRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export function buildGitHubPatterns(baseUrl: string): UnfurlPattern[] { + const escaped = escapeForRegex(baseUrl); + return [ + // Commit within a PR: /:owner/:repo/pull/:num/commits/:sha + { + regex: new RegExp( + `^${escaped}\\/([^\\/]+)\\/([^\\/]+)\\/pull\\/(\\d+)\\/commits\\/([a-f0-9]+)`, + ), + type: 'github-pr-commit', + }, + // PR sub-pages: /:owner/:repo/pull/:num(/checks|/commits|/files)? + { + regex: new RegExp(`^${escaped}\\/([^\\/]+)\\/([^\\/]+)\\/pull\\/(\\d+)`), + type: 'github-pr', + }, + // Single issue: /:owner/:repo/issues/:num + { + regex: new RegExp( + `^${escaped}\\/([^\\/]+)\\/([^\\/]+)\\/issues\\/(\\d+)`, + ), + type: 'github-issue', + }, + // Commit: /:owner/:repo/commit(s)/:sha + { + regex: new RegExp( + `^${escaped}\\/([^\\/]+)\\/([^\\/]+)\\/commits?\\/([a-f0-9]+)`, + ), + type: 'github-commit', + }, + // File/blob: /:owner/:repo/blob/:ref/:path(#L:start(-L:end))? + { + regex: new RegExp( + `^${escaped}\\/([^\\/]+)\\/([^\\/]+)\\/blob\\/([^\\/]+)\\/(.+?)(?:#L(\\d+)(?:-L(\\d+))?)?$`, + ), + type: 'github-file', + }, + // Pulls list: /:owner/:repo/pulls + { + regex: new RegExp( + `^${escaped}\\/([^\\/]+)\\/([^\\/]+)\\/pulls(?:\\/.*)?(?:\\?.*)?$`, + ), + type: 'github-pulls-list', + }, + // Issues list: /:owner/:repo/issues(/created_by/...|/assigned/...)? + { + regex: new RegExp( + `^${escaped}\\/([^\\/]+)\\/([^\\/]+)\\/issues(?:\\/(?:created_by|assigned)\\/[\\w.\\/-]+)?\\/?(?:\\?.*)?$`, + ), + type: 'github-issues-list', + }, + // Releases: /:owner/:repo/releases + { + regex: new RegExp( + `^${escaped}\\/([^\\/]+)\\/([^\\/]+)\\/releases(?:\\/.*)?(?:\\?.*)?$`, + ), + type: 'github-releases-list', + }, + // Repo: /:owner/:repo + { + regex: new RegExp( + `^${escaped}\\/([a-zA-Z0-9\\-_.]+)\\/([a-zA-Z0-9\\-_.]+)\\/?$`, + ), + type: 'github-repo', + }, + ]; +} diff --git a/apps/server/src/core/integration/providers/github/github.module.ts b/apps/server/src/core/integration/providers/github/github.module.ts new file mode 100644 index 000000000..ed4c52be4 --- /dev/null +++ b/apps/server/src/core/integration/providers/github/github.module.ts @@ -0,0 +1,21 @@ +import { Module, OnModuleInit } from '@nestjs/common'; +import { GitHubProvider } from './github.provider'; +import { GitHubService } from './github.service'; +import { IntegrationRegistry } from '../../registry/integration-registry'; +import { IntegrationModule } from '../../integration.module'; + +@Module({ + imports: [IntegrationModule], + providers: [GitHubProvider, GitHubService], + exports: [GitHubProvider], +}) +export class GitHubModule implements OnModuleInit { + constructor( + private readonly registry: IntegrationRegistry, + private readonly githubProvider: GitHubProvider, + ) {} + + onModuleInit() { + this.registry.register(this.githubProvider); + } +} diff --git a/apps/server/src/core/integration/providers/github/github.provider.ts b/apps/server/src/core/integration/providers/github/github.provider.ts new file mode 100644 index 000000000..06dccd20d --- /dev/null +++ b/apps/server/src/core/integration/providers/github/github.provider.ts @@ -0,0 +1,154 @@ +import { Injectable } from '@nestjs/common'; +import { + IntegrationProvider, + IntegrationDefinition, + LinkDescription, + OAuthConfig, + UnfurlPattern, + UnfurlOpts, + UnfurlResult, +} from '../../registry/integration-provider.interface'; +import { GitHubService } from './github.service'; +import { buildGitHubPatterns } from './github-patterns'; + +const DEFAULT_BASE_URL = 'https://github.com'; + +@Injectable() +export class GitHubProvider extends IntegrationProvider { + definition: IntegrationDefinition = { + type: 'github', + name: 'GitHub', + description: 'Link previews for repos, pull requests, issues, commits, and files', + icon: 'github', + capabilities: ['oauth', 'unfurl'], + oauth: { + authUrl: 'https://github.com/login/oauth/authorize', + tokenUrl: 'https://github.com/login/oauth/access_token', + scopes: ['repo', 'read:user'], + }, + unfurlPatterns: buildGitHubPatterns('https://github.com'), + }; + + constructor(private readonly githubService: GitHubService) { + super(); + } + + getOAuthConfig(settings: Record): OAuthConfig { + const baseUrl = this.resolveBaseUrl(); + return { + authUrl: `${baseUrl}/login/oauth/authorize`, + tokenUrl: `${baseUrl}/login/oauth/access_token`, + scopes: ['repo', 'read:user'], + }; + } + + getUnfurlPatterns(settings: Record): UnfurlPattern[] { + const baseUrl = this.resolveBaseUrl(); + if (baseUrl === DEFAULT_BASE_URL) return []; + return buildGitHubPatterns(baseUrl); + } + + async unfurl(opts: UnfurlOpts): Promise { + const { match, patternType, accessToken, url } = opts; + const apiBaseUrl = this.resolveApiBaseUrl(url); + const owner = match[1]; + const repo = match[2]; + + switch (patternType) { + case 'github-pr': { + const number = parseInt(match[3], 10); + return this.githubService.unfurlPullRequest( + accessToken, apiBaseUrl, owner, repo, number, url, + ); + } + + case 'github-issue': { + const number = parseInt(match[3], 10); + return this.githubService.unfurlIssue( + accessToken, apiBaseUrl, owner, repo, number, url, + ); + } + + case 'github-repo': + return this.githubService.unfurlRepo( + accessToken, apiBaseUrl, owner, repo, url, + ); + + case 'github-commit': { + const sha = match[3]; + return this.githubService.unfurlCommit( + accessToken, apiBaseUrl, owner, repo, sha, url, + ); + } + + case 'github-pr-commit': { + const sha = match[4]; + return this.githubService.unfurlCommit( + accessToken, apiBaseUrl, owner, repo, sha, url, + ); + } + + case 'github-file': { + const ref = match[3]; + const path = match[4]; + const startLine = match[5] ? parseInt(match[5], 10) : undefined; + const endLine = match[6] ? parseInt(match[6], 10) : undefined; + return this.githubService.unfurlFile( + owner, repo, ref, path, startLine, endLine, url, + ); + } + + case 'github-pulls-list': + case 'github-issues-list': + case 'github-releases-list': + return this.githubService.unfurlCollectionPage( + accessToken, apiBaseUrl, owner, repo, patternType.replace('github-', ''), url, + ); + + default: + throw new Error(`Unknown GitHub pattern type: ${patternType}`); + } + } + + describeLink( + patternType: string, + match: RegExpMatchArray, + ): LinkDescription | null { + const repo = `${match[1]}/${match[2]}`; + switch (patternType) { + case 'github-pr': + return { title: `Pull Request #${match[3]}`, description: repo }; + case 'github-pr-commit': + return { title: `Commit ${match[4].slice(0, 7)}`, description: repo }; + case 'github-issue': + return { title: `Issue #${match[3]}`, description: repo }; + case 'github-commit': + return { title: `Commit ${match[3].slice(0, 7)}`, description: repo }; + case 'github-file': + return { title: match[4], description: repo }; + case 'github-pulls-list': + return { title: 'Pull Requests', description: repo }; + case 'github-issues-list': + return { title: 'Issues', description: repo }; + case 'github-releases-list': + return { title: 'Releases', description: repo }; + case 'github-repo': + return { title: repo }; + default: + return null; + } + } + + private resolveBaseUrl(): string { + const baseUrl = process.env.INTEGRATION_GITHUB_BASE_URL; + return baseUrl ? baseUrl.replace(/\/+$/, '') : DEFAULT_BASE_URL; + } + + private resolveApiBaseUrl(url: string): string { + const parsed = new URL(url); + if (parsed.hostname === 'github.com') { + return 'https://api.github.com'; + } + return `${parsed.origin}/api/v3`; + } +} diff --git a/apps/server/src/core/integration/providers/github/github.service.ts b/apps/server/src/core/integration/providers/github/github.service.ts new file mode 100644 index 000000000..028db23e9 --- /dev/null +++ b/apps/server/src/core/integration/providers/github/github.service.ts @@ -0,0 +1,267 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { UnfurlResult } from '../../registry/integration-provider.interface'; +import { relativeTime } from '../../utils/relative-time'; +import { providerApiFetch } from '../../utils/provider-fetch'; + +@Injectable() +export class GitHubService { + private readonly logger = new Logger(GitHubService.name); + + async unfurlPullRequest( + accessToken: string, + apiBaseUrl: string, + owner: string, + repo: string, + number: number, + url: string, + ): Promise { + const data = await this.apiGet( + accessToken, + apiBaseUrl, + `/repos/${owner}/${repo}/pulls/${number}`, + ); + + const prAuthor = data.user?.login; + const prDesc = [ + `#${data.number}`, + relativeTime(data.updated_at ?? data.created_at), + prAuthor, + ].filter(Boolean).join(' · '); + + return { + title: data.title, + description: prDesc, + url, + provider: 'github', + providerIcon: 'github', + status: this.formatPrStatus(data), + statusColor: this.getPrStatusColor(data), + author: prAuthor, + authorAvatarUrl: data.user?.avatar_url, + metadata: { + type: 'pr', + number: data.number, + repo: `${owner}/${repo}`, + labels: data.labels?.map((l: any) => l.name) ?? [], + draft: data.draft, + additions: data.additions, + deletions: data.deletions, + }, + }; + } + + async unfurlIssue( + accessToken: string, + apiBaseUrl: string, + owner: string, + repo: string, + number: number, + url: string, + ): Promise { + const data = await this.apiGet( + accessToken, + apiBaseUrl, + `/repos/${owner}/${repo}/issues/${number}`, + ); + + const issueAuthor = data.user?.login; + const issueDesc = [ + `#${data.number}`, + relativeTime(data.updated_at ?? data.created_at), + issueAuthor, + ].filter(Boolean).join(' · '); + + return { + title: data.title, + description: issueDesc, + url, + provider: 'github', + providerIcon: 'github', + status: data.state, + statusColor: data.state === 'open' ? 'green' : 'purple', + author: issueAuthor, + authorAvatarUrl: data.user?.avatar_url, + metadata: { + type: 'issue', + number: data.number, + repo: `${owner}/${repo}`, + labels: data.labels?.map((l: any) => l.name) ?? [], + assignees: data.assignees?.map((a: any) => a.login) ?? [], + }, + }; + } + + async unfurlRepo( + accessToken: string, + apiBaseUrl: string, + owner: string, + repo: string, + url: string, + ): Promise { + const data = await this.apiGet( + accessToken, + apiBaseUrl, + `/repos/${owner}/${repo}`, + ); + + const visibility = data.private ? 'Private' : 'Public'; + + return { + title: data.full_name, + description: data.description?.slice(0, 200) ?? undefined, + url, + provider: 'github', + providerIcon: 'github', + status: visibility, + statusColor: data.private ? 'gray' : 'green', + author: data.owner?.login, + authorAvatarUrl: data.owner?.avatar_url, + metadata: { + type: 'repo', + repo: `${owner}/${repo}`, + stars: data.stargazers_count, + forks: data.forks_count, + language: data.language, + defaultBranch: data.default_branch, + }, + }; + } + + async unfurlCommit( + accessToken: string, + apiBaseUrl: string, + owner: string, + repo: string, + sha: string, + url: string, + ): Promise { + const data = await this.apiGet( + accessToken, + apiBaseUrl, + `/repos/${owner}/${repo}/commits/${sha}`, + ); + + const shortSha = data.sha?.slice(0, 7); + + const commitAuthor = data.author?.login ?? data.commit?.author?.name; + const commitDesc = [ + shortSha, + relativeTime(data.commit?.author?.date ?? data.commit?.committer?.date), + commitAuthor, + ].filter(Boolean).join(' · '); + + return { + title: data.commit?.message?.split('\n')[0] ?? shortSha, + description: commitDesc, + url, + provider: 'github', + providerIcon: 'github', + author: commitAuthor, + authorAvatarUrl: data.author?.avatar_url, + metadata: { + type: 'commit', + sha: data.sha, + shortSha, + repo: `${owner}/${repo}`, + stats: data.stats, + }, + }; + } + + unfurlFile( + owner: string, + repo: string, + ref: string, + path: string, + startLine: number | undefined, + endLine: number | undefined, + url: string, + ): UnfurlResult { + const fileName = path.split('/').pop() ?? path; + const lineRange = startLine + ? endLine + ? `L${startLine}-L${endLine}` + : `L${startLine}` + : undefined; + + return { + title: lineRange ? `${fileName}#${lineRange}` : fileName, + description: `${owner}/${repo} · ${ref.slice(0, 7)}`, + url, + provider: 'github', + providerIcon: 'github', + metadata: { + type: 'file', + repo: `${owner}/${repo}`, + ref, + path, + startLine, + endLine, + }, + }; + } + + async unfurlCollectionPage( + accessToken: string, + apiBaseUrl: string, + owner: string, + repo: string, + collectionType: string, + url: string, + ): Promise { + const data = await this.apiGet( + accessToken, + apiBaseUrl, + `/repos/${owner}/${repo}`, + ); + + const labels: Record = { + 'pulls-list': 'Pull Requests', + 'issues-list': 'Issues', + 'releases-list': 'Releases', + }; + + return { + title: `${labels[collectionType] ?? collectionType} · ${data.full_name}`, + description: `${owner}/${repo}`, + url, + provider: 'github', + providerIcon: 'github', + author: data.owner?.login, + authorAvatarUrl: data.owner?.avatar_url, + metadata: { + type: collectionType, + repo: `${owner}/${repo}`, + }, + }; + } + + private formatPrStatus(pr: any): string { + if (pr.merged) return 'merged'; + if (pr.draft) return 'draft'; + return pr.state; + } + + private getPrStatusColor(pr: any): string { + if (pr.merged) return 'purple'; + if (pr.draft) return 'gray'; + if (pr.state === 'open') return 'green'; + return 'red'; + } + + private async apiGet( + accessToken: string, + apiBaseUrl: string, + path: string, + ): Promise { + const response = await providerApiFetch('GitHub', `${apiBaseUrl}${path}`, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/vnd.github.v3+json', + 'User-Agent': 'Docmost', + }, + }); + + return response.json(); + } +} diff --git a/apps/server/src/core/integration/providers/gitlab/gitlab-patterns.ts b/apps/server/src/core/integration/providers/gitlab/gitlab-patterns.ts new file mode 100644 index 000000000..ce40be4d8 --- /dev/null +++ b/apps/server/src/core/integration/providers/gitlab/gitlab-patterns.ts @@ -0,0 +1,68 @@ +import { UnfurlPattern } from '../../registry/integration-provider.interface'; + +function escapeForRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export function buildGitLabPatterns(baseUrl: string): UnfurlPattern[] { + const escaped = escapeForRegex(baseUrl); + return [ + { + regex: new RegExp( + `^${escaped}\\/(.+)\\/-\\/merge_requests\\/(\\d+)\\/diffs\\?.*commit_id=([a-f0-9]+)`, + ), + type: 'gitlab-commit-in-mr', + }, + { + regex: new RegExp( + `^${escaped}\\/(.+)\\/-\\/merge_requests\\/(\\d+)`, + ), + type: 'gitlab-mr', + }, + { + regex: new RegExp( + `^${escaped}\\/(.+)\\/-\\/issues\\/(\\d+)`, + ), + type: 'gitlab-issue', + }, + // Issues renamed to work items; same iid, resolved via the issues API. + { + regex: new RegExp( + `^${escaped}\\/(.+)\\/-\\/work_items\\/(\\d+)`, + ), + type: 'gitlab-issue', + }, + // Work item opened as a drawer over the list; the target is base64 JSON + // in the show param, decoded by the provider. + { + regex: new RegExp( + `^${escaped}\\/(.+)\\/-\\/work_items\\/?\\?(?:.*&)?show=`, + ), + type: 'gitlab-work-item-drawer', + }, + { + regex: new RegExp( + `^${escaped}\\/(.+)\\/-\\/commits?\\/([a-f0-9]+)`, + ), + type: 'gitlab-commit', + }, + { + regex: new RegExp( + `^${escaped}\\/(.+)\\/-\\/issues(?:\\/)?(?:\\?.*)?$`, + ), + type: 'gitlab-issues-list', + }, + { + regex: new RegExp( + `^${escaped}\\/(.+)\\/-\\/merge_requests(?:\\/)?(?:\\?.*)?$`, + ), + type: 'gitlab-merges-list', + }, + { + regex: new RegExp( + `^${escaped}\\/([a-zA-Z0-9\\-_.]+)\\/([a-zA-Z0-9\\-_]+)\\/?$`, + ), + type: 'gitlab-project', + }, + ]; +} diff --git a/apps/server/src/core/integration/providers/gitlab/gitlab.module.ts b/apps/server/src/core/integration/providers/gitlab/gitlab.module.ts new file mode 100644 index 000000000..261a30310 --- /dev/null +++ b/apps/server/src/core/integration/providers/gitlab/gitlab.module.ts @@ -0,0 +1,21 @@ +import { Module, OnModuleInit } from '@nestjs/common'; +import { GitLabProvider } from './gitlab.provider'; +import { GitLabService } from './gitlab.service'; +import { IntegrationRegistry } from '../../registry/integration-registry'; +import { IntegrationModule } from '../../integration.module'; + +@Module({ + imports: [IntegrationModule], + providers: [GitLabProvider, GitLabService], + exports: [GitLabProvider], +}) +export class GitLabModule implements OnModuleInit { + constructor( + private readonly registry: IntegrationRegistry, + private readonly gitlabProvider: GitLabProvider, + ) {} + + onModuleInit() { + this.registry.register(this.gitlabProvider); + } +} diff --git a/apps/server/src/core/integration/providers/gitlab/gitlab.provider.ts b/apps/server/src/core/integration/providers/gitlab/gitlab.provider.ts new file mode 100644 index 000000000..f7d571d13 --- /dev/null +++ b/apps/server/src/core/integration/providers/gitlab/gitlab.provider.ts @@ -0,0 +1,188 @@ +import { Injectable } from '@nestjs/common'; +import { + IntegrationProvider, + IntegrationDefinition, + LinkDescription, + OAuthConfig, + UnfurlPattern, + UnfurlOpts, + UnfurlResult, +} from '../../registry/integration-provider.interface'; +import { GitLabService } from './gitlab.service'; +import { buildGitLabPatterns } from './gitlab-patterns'; + +const DEFAULT_BASE_URL = 'https://gitlab.com'; + +@Injectable() +export class GitLabProvider extends IntegrationProvider { + definition: IntegrationDefinition = { + type: 'gitlab', + name: 'GitLab', + description: 'Link previews for projects, merge requests, issues, and commits', + icon: 'gitlab', + capabilities: ['oauth', 'unfurl'], + oauth: { + authUrl: 'https://gitlab.com/oauth/authorize', + tokenUrl: 'https://gitlab.com/oauth/token', + scopes: ['read_api', 'read_user'], + }, + unfurlPatterns: buildGitLabPatterns('https://gitlab.com'), + }; + + constructor(private readonly gitlabService: GitLabService) { + super(); + } + + getOAuthConfig(settings: Record): OAuthConfig { + const baseUrl = this.resolveBaseUrl(); + return { + authUrl: `${baseUrl}/oauth/authorize`, + tokenUrl: `${baseUrl}/oauth/token`, + scopes: ['read_api', 'read_user'], + }; + } + + getUnfurlPatterns(settings: Record): UnfurlPattern[] { + const baseUrl = this.resolveBaseUrl(); + if (baseUrl === DEFAULT_BASE_URL) return []; + return buildGitLabPatterns(baseUrl); + } + + async unfurl(opts: UnfurlOpts): Promise { + const { match, patternType, accessToken, url } = opts; + const apiBaseUrl = this.resolveApiBaseUrl(url); + + switch (patternType) { + case 'gitlab-mr': { + const projectPath = match[1]; + const iid = parseInt(match[2], 10); + return this.gitlabService.unfurlMergeRequest( + accessToken, apiBaseUrl, projectPath, iid, url, + ); + } + + case 'gitlab-issue': { + const projectPath = match[1]; + const iid = parseInt(match[2], 10); + return this.gitlabService.unfurlIssue( + accessToken, apiBaseUrl, projectPath, iid, url, + ); + } + + case 'gitlab-project': { + const projectPath = `${match[1]}/${match[2]}`; + return this.gitlabService.unfurlProject( + accessToken, apiBaseUrl, projectPath, url, + ); + } + + case 'gitlab-commit': { + const projectPath = match[1]; + const commitSha = match[2]; + return this.gitlabService.unfurlCommit( + accessToken, apiBaseUrl, projectPath, commitSha, url, + ); + } + + case 'gitlab-commit-in-mr': { + const projectPath = match[1]; + const commitSha = match[3]; + return this.gitlabService.unfurlCommit( + accessToken, apiBaseUrl, projectPath, commitSha, url, + ); + } + + case 'gitlab-work-item-drawer': { + const target = this.decodeWorkItemShowParam(url); + if (!target) { + throw new Error('Could not decode work item show param'); + } + return this.gitlabService.unfurlIssue( + accessToken, apiBaseUrl, target.fullPath, target.iid, url, + ); + } + + case 'gitlab-issues-list': { + const projectPath = match[1]; + return this.gitlabService.unfurlIssuesList( + accessToken, apiBaseUrl, projectPath, url, + ); + } + + case 'gitlab-merges-list': { + const projectPath = match[1]; + return this.gitlabService.unfurlMergesList( + accessToken, apiBaseUrl, projectPath, url, + ); + } + + default: + throw new Error(`Unknown GitLab pattern type: ${patternType}`); + } + } + + describeLink( + patternType: string, + match: RegExpMatchArray, + url: string, + ): LinkDescription | null { + const projectPath = match[1]; + switch (patternType) { + case 'gitlab-mr': + return { title: `Merge Request !${match[2]}`, description: projectPath }; + case 'gitlab-issue': + return { title: `Issue #${match[2]}`, description: projectPath }; + case 'gitlab-work-item-drawer': { + const target = this.decodeWorkItemShowParam(url); + return target + ? { title: `Issue #${target.iid}`, description: target.fullPath } + : { title: 'Work item', description: projectPath }; + } + case 'gitlab-commit': + return { title: `Commit ${match[2].slice(0, 8)}`, description: projectPath }; + case 'gitlab-commit-in-mr': + return { title: `Commit ${match[3].slice(0, 8)}`, description: projectPath }; + case 'gitlab-issues-list': + return { title: 'Issues', description: projectPath }; + case 'gitlab-merges-list': + return { title: 'Merge Requests', description: projectPath }; + case 'gitlab-project': + return { title: `${match[1]}/${match[2]}` }; + default: + return null; + } + } + + // The work items list opens an item as a drawer and encodes it in the URL + // as ?show=base64({ iid, full_path, id }). full_path beats the URL path: + // a drawer opened from a group-level list still names the actual project. + private decodeWorkItemShowParam( + url: string, + ): { fullPath: string; iid: number } | null { + try { + const show = new URL(url).searchParams.get('show'); + if (!show) return null; + const base64 = show.replace(/-/g, '+').replace(/_/g, '/'); + const payload = JSON.parse( + Buffer.from(base64, 'base64').toString('utf8'), + ); + const iid = parseInt(payload.iid, 10); + if (typeof payload.full_path !== 'string' || Number.isNaN(iid)) { + return null; + } + return { fullPath: payload.full_path, iid }; + } catch { + return null; + } + } + + private resolveBaseUrl(): string { + const baseUrl = process.env.INTEGRATION_GITLAB_BASE_URL; + return baseUrl ? baseUrl.replace(/\/+$/, '') : DEFAULT_BASE_URL; + } + + private resolveApiBaseUrl(url: string): string { + const parsed = new URL(url); + return `${parsed.origin}/api/v4`; + } +} diff --git a/apps/server/src/core/integration/providers/gitlab/gitlab.service.ts b/apps/server/src/core/integration/providers/gitlab/gitlab.service.ts new file mode 100644 index 000000000..9a279409c --- /dev/null +++ b/apps/server/src/core/integration/providers/gitlab/gitlab.service.ts @@ -0,0 +1,253 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { UnfurlResult } from '../../registry/integration-provider.interface'; +import { relativeTime } from '../../utils/relative-time'; +import { providerApiFetch } from '../../utils/provider-fetch'; + +@Injectable() +export class GitLabService { + private readonly logger = new Logger(GitLabService.name); + + async unfurlMergeRequest( + accessToken: string, + apiBaseUrl: string, + projectPath: string, + iid: number, + url: string, + ): Promise { + const encodedProject = encodeURIComponent(projectPath); + const data = await this.apiGet( + accessToken, + apiBaseUrl, + `/projects/${encodedProject}/merge_requests/${iid}`, + ); + + const authorName = data.author?.name ?? data.author?.username; + const desc = [ + `!${data.iid}`, + relativeTime(data.updated_at ?? data.created_at), + authorName, + ].filter(Boolean).join(' · '); + + return { + title: data.title, + description: desc, + url, + provider: 'gitlab', + providerIcon: 'gitlab', + status: this.formatMrStatus(data), + statusColor: this.getMrStatusColor(data), + author: authorName, + authorAvatarUrl: data.author?.avatar_url, + metadata: { + type: 'mr', + iid: data.iid, + project: projectPath, + labels: data.labels ?? [], + draft: data.draft ?? data.work_in_progress, + }, + }; + } + + async unfurlIssue( + accessToken: string, + apiBaseUrl: string, + projectPath: string, + iid: number, + url: string, + ): Promise { + const encodedProject = encodeURIComponent(projectPath); + const data = await this.apiGet( + accessToken, + apiBaseUrl, + `/projects/${encodedProject}/issues/${iid}`, + ); + + const issueAuthor = data.author?.name ?? data.author?.username; + const issueDesc = [ + `#${data.iid}`, + relativeTime(data.updated_at ?? data.created_at), + issueAuthor, + ].filter(Boolean).join(' · '); + + return { + title: data.title, + description: issueDesc, + url, + provider: 'gitlab', + providerIcon: 'gitlab', + status: data.state, + statusColor: data.state === 'opened' ? 'green' : 'blue', + author: issueAuthor, + authorAvatarUrl: data.author?.avatar_url, + metadata: { + type: 'issue', + iid: data.iid, + project: projectPath, + labels: data.labels ?? [], + assignees: + data.assignees?.map((a: any) => a.name ?? a.username) ?? [], + }, + }; + } + + async unfurlProject( + accessToken: string, + apiBaseUrl: string, + projectPath: string, + url: string, + ): Promise { + const encodedProject = encodeURIComponent(projectPath); + const data = await this.apiGet( + accessToken, + apiBaseUrl, + `/projects/${encodedProject}`, + ); + + const visibility = data.visibility === 'public' ? 'Public' : data.visibility === 'internal' ? 'Internal' : 'Private'; + + return { + title: data.name, + description: data.description?.slice(0, 200) ?? undefined, + url, + provider: 'gitlab', + providerIcon: 'gitlab', + status: visibility, + statusColor: data.visibility === 'public' ? 'green' : 'gray', + author: data.namespace?.name, + authorAvatarUrl: data.avatar_url ?? data.namespace?.avatar_url, + metadata: { + type: 'project', + project: projectPath, + stars: data.star_count, + forks: data.forks_count, + defaultBranch: data.default_branch, + }, + }; + } + + async unfurlCommit( + accessToken: string, + apiBaseUrl: string, + projectPath: string, + commitSha: string, + url: string, + ): Promise { + const encodedProject = encodeURIComponent(projectPath); + const data = await this.apiGet( + accessToken, + apiBaseUrl, + `/projects/${encodedProject}/repository/commits/${commitSha}`, + ); + + const shortSha = data.short_id ?? data.id?.slice(0, 8); + + const commitDesc = [ + shortSha, + relativeTime(data.committed_date ?? data.created_at), + data.author_name, + ].filter(Boolean).join(' · '); + + return { + title: data.title ?? data.message?.split('\n')[0], + description: commitDesc, + url, + provider: 'gitlab', + providerIcon: 'gitlab', + author: data.author_name, + authorAvatarUrl: undefined, + metadata: { + type: 'commit', + sha: data.id, + shortSha, + project: projectPath, + stats: data.stats, + }, + }; + } + + async unfurlIssuesList( + accessToken: string, + apiBaseUrl: string, + projectPath: string, + url: string, + ): Promise { + const encodedProject = encodeURIComponent(projectPath); + const data = await this.apiGet( + accessToken, + apiBaseUrl, + `/projects/${encodedProject}?statistics=false`, + ); + + return { + title: `Issues · ${data.name}`, + description: projectPath, + url, + provider: 'gitlab', + providerIcon: 'gitlab', + author: data.namespace?.name, + authorAvatarUrl: data.avatar_url ?? data.namespace?.avatar_url, + metadata: { + type: 'issues-list', + project: projectPath, + openIssuesCount: data.open_issues_count, + }, + }; + } + + async unfurlMergesList( + accessToken: string, + apiBaseUrl: string, + projectPath: string, + url: string, + ): Promise { + const encodedProject = encodeURIComponent(projectPath); + const data = await this.apiGet( + accessToken, + apiBaseUrl, + `/projects/${encodedProject}?statistics=false`, + ); + + return { + title: `Merge Requests · ${data.name}`, + description: projectPath, + url, + provider: 'gitlab', + providerIcon: 'gitlab', + author: data.namespace?.name, + authorAvatarUrl: data.avatar_url ?? data.namespace?.avatar_url, + metadata: { + type: 'merges-list', + project: projectPath, + }, + }; + } + + private formatMrStatus(mr: any): string { + if (mr.state === 'merged') return 'merged'; + if (mr.draft || mr.work_in_progress) return 'draft'; + return mr.state; + } + + private getMrStatusColor(mr: any): string { + if (mr.state === 'merged') return 'purple'; + if (mr.draft || mr.work_in_progress) return 'gray'; + if (mr.state === 'opened') return 'green'; + if (mr.state === 'closed') return 'red'; + return 'gray'; + } + + private async apiGet( + accessToken: string, + apiBaseUrl: string, + path: string, + ): Promise { + const response = await providerApiFetch('GitLab', `${apiBaseUrl}${path}`, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }); + + return response.json(); + } +} diff --git a/apps/server/src/core/integration/registry/integration-provider.interface.ts b/apps/server/src/core/integration/registry/integration-provider.interface.ts new file mode 100644 index 000000000..04e005ac9 --- /dev/null +++ b/apps/server/src/core/integration/registry/integration-provider.interface.ts @@ -0,0 +1,169 @@ +export type IntegrationCapability = 'oauth' | 'unfurl' | 'actions'; + +export type OAuthConfig = { + authUrl: string; + tokenUrl: string; + scopes: string[]; + // 'workspace' = one shared bot/app connection per integration (Slack model); + // 'user' (default) = each Docmost user OAuths separately and gets their own token (Linear, Jira, GitHub model) + connectionScope?: 'workspace' | 'user'; +}; + +export type UnfurlPattern = { + regex: RegExp; + type: string; +}; + +export type UnfurlResult = { + title: string; + description?: string; + url: string; + provider: string; + providerIcon?: string; + status?: string; + statusColor?: string; + author?: string; + authorAvatarUrl?: string; + metadata?: Record; +}; + +export type IntegrationDefinition = { + type: string; + name: string; + description: string; + icon: string; + capabilities: IntegrationCapability[]; + oauth?: OAuthConfig; + unfurlPatterns?: UnfurlPattern[]; + // Kept out of the available list and refused for install; existing + // installations keep unfurling. + hidden?: boolean; + // Install requires the INTEGRATIONS license feature; unset = free. + requiresLicense?: boolean; +}; + +export type ConnectedEvent = { + integrationId: string; + workspaceId: string; + accessToken: string; + refreshToken?: string; + // The Docmost user who completed the OAuth flow (installer for + // workspace-scoped providers). + userId: string; + metadata: Record; +}; + +export type HandleEventOpts = { + eventName: string; + payload: Record; + integration: { + id: string; + type: string; + settings: Record | null; + }; + connection?: { + accessToken: string; + userId: string; + }; +}; + +export type UnfurlOpts = { + url: string; + accessToken: string; + match: RegExpMatchArray; + patternType: string; + settings?: Record; + // The requesting Docmost user and integration. Providers backed by a shared + // (workspace) connection MUST authorize the requester against the target + // resource before returning content: the shared bot token is not itself + // proof that the requester may see it. + userId: string; + integrationId: string; +}; + +// Thrown by a provider's unfurl() when the requesting user is not authorized +// to view the linked resource. UnfurlService turns it into a null result +// (no card) rather than logging it as an error. +export class UnfurlForbiddenError extends Error { + constructor(message = 'Not authorized to unfurl this link') { + super(message); + this.name = 'UnfurlForbiddenError'; + } +} + +// Thrown by a provider's unfurl() when the requesting user has no usable +// identity with the provider yet (e.g. no Slack account link). UnfurlService +// turns it into a needs-connection response, unlike UnfurlForbiddenError +// which stays a silent null. +export class UnfurlNeedsConnectionError extends Error { + constructor(message = 'User has not connected this integration') { + super(message); + this.name = 'UnfurlNeedsConnectionError'; + } +} + +// Thrown when the provider definitively rejects the stored credential (API 401, +// or invalid_grant at the token endpoint). Callers retire the connection. +export class TokenInvalidError extends Error { + constructor(message = 'Integration credential is no longer valid') { + super(message); + this.name = 'TokenInvalidError'; + } +} + +export class ProviderApiError extends Error { + constructor( + readonly provider: string, + readonly status: number, + statusText = '', + ) { + super(`${provider} API error: ${status} ${statusText}`.trimEnd()); + this.name = 'ProviderApiError'; + } +} + +export type LinkDescription = { + title: string; + description?: string; +}; + +// Returned instead of an UnfurlResult when the link needs a per-user +// connection the requesting user does not have yet. +export type UnfurlNeedsConnection = { + needsConnection: true; + integrationId: string; + integrationType: string; + integrationName: string; + // true when a Docmost-initiated OAuth flow yields a personal connection + // for the requesting user (all OAuth-capable providers; workspace-scoped + // ones bind the authorizing user's identity in onConnected). + oauthConnect: boolean; + title: string; + description?: string; +}; + +export abstract class IntegrationProvider { + abstract definition: IntegrationDefinition; + + getOAuthConfig?( + workspaceSettings: Record, + ): OAuthConfig; + + getUnfurlPatterns?( + workspaceSettings: Record, + ): UnfurlPattern[]; + + onConnected?(opts: ConnectedEvent): Promise; + + unfurl?(opts: UnfurlOpts): Promise; + + // Tokenless summary of a matched link (e.g. "Pull Request #13337"), + // shown on the connect prompt before the user has authorized. + describeLink?( + patternType: string, + match: RegExpMatchArray, + url: string, + ): LinkDescription | null; + + handleEvent?(opts: HandleEventOpts): Promise; +} diff --git a/apps/server/src/core/integration/registry/integration-registry.ts b/apps/server/src/core/integration/registry/integration-registry.ts new file mode 100644 index 000000000..f7d2eb345 --- /dev/null +++ b/apps/server/src/core/integration/registry/integration-registry.ts @@ -0,0 +1,47 @@ +import { Injectable } from '@nestjs/common'; +import { + IntegrationDefinition, + IntegrationProvider, +} from './integration-provider.interface'; + +@Injectable() +export class IntegrationRegistry { + private providers = new Map(); + + register(provider: IntegrationProvider): void { + this.providers.set(provider.definition.type, provider); + } + + getProvider(type: string): IntegrationProvider | undefined { + return this.providers.get(type); + } + + getAllProviders(): IntegrationProvider[] { + return Array.from(this.providers.values()); + } + + getAvailableIntegrations(): IntegrationDefinition[] { + return this.getAllProviders() + .map((p) => p.definition) + .filter((definition) => !definition.hidden); + } + + findUnfurlProvider( + url: string, + ): { + provider: IntegrationProvider; + match: RegExpMatchArray; + patternType: string; + } | null { + for (const provider of this.providers.values()) { + if (!provider.definition.unfurlPatterns) continue; + for (const pattern of provider.definition.unfurlPatterns) { + const match = url.match(pattern.regex); + if (match) { + return { provider, match, patternType: pattern.type }; + } + } + } + return null; + } +} diff --git a/apps/server/src/core/integration/repos/integration-connection.repo.ts b/apps/server/src/core/integration/repos/integration-connection.repo.ts new file mode 100644 index 000000000..d2957784a --- /dev/null +++ b/apps/server/src/core/integration/repos/integration-connection.repo.ts @@ -0,0 +1,361 @@ +import { Injectable } from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; +import { sql } from 'kysely'; +import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; +import { + IntegrationConnection, + InsertableIntegrationConnection, + UpdatableIntegrationConnection, +} from '@docmost/db/types/entity.types'; +import { dbOrTx } from '@docmost/db/utils'; + +@Injectable() +export class IntegrationConnectionRepo { + constructor(@InjectKysely() private readonly db: KyselyDB) {} + + async findById( + connectionId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + return db + .selectFrom('integrationConnections') + .selectAll() + .where('id', '=', connectionId) + .executeTakeFirst(); + } + + async findByIntegrationAndUser( + integrationId: string, + userId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + return db + .selectFrom('integrationConnections') + .selectAll() + .where('integrationId', '=', integrationId) + .where('userId', '=', userId) + .executeTakeFirst(); + } + + async findByWorkspaceTypeAndUser( + workspaceId: string, + integrationType: string, + userId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + return db + .selectFrom('integrationConnections') + .innerJoin( + 'integrations', + 'integrations.id', + 'integrationConnections.integrationId', + ) + .selectAll('integrationConnections') + .where('integrations.workspaceId', '=', workspaceId) + .where('integrations.type', '=', integrationType) + .where('integrations.deletedAt', 'is', null) + .where('integrationConnections.userId', '=', userId) + .executeTakeFirst(); + } + + async findByIntegration( + integrationId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + return db + .selectFrom('integrationConnections') + .selectAll() + .where('integrationId', '=', integrationId) + .execute(); + } + + async upsert( + connection: InsertableIntegrationConnection, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + // The (integration_id, user_id) unique index is partial on kind='user'; + // ON CONFLICT must repeat that predicate or Postgres cannot infer it. + return db + .insertInto('integrationConnections') + .values(connection) + .onConflict((oc) => + oc + .columns(['integrationId', 'userId']) + .where(sql.ref('kind'), '=', 'user') + .doUpdateSet({ + accessToken: connection.accessToken, + refreshToken: connection.refreshToken, + tokenExpiresAt: connection.tokenExpiresAt, + invalidatedAt: null, + scopes: connection.scopes, + providerUserId: connection.providerUserId, + metadata: connection.metadata, + updatedAt: new Date(), + }), + ) + .returningAll() + .executeTakeFirstOrThrow(); + } + + async upsertWorkspaceConnection( + input: { + integrationId: string; + userId: string; + workspaceId: string; + accessToken: string; + refreshToken?: string | null; + tokenExpiresAt?: Date | null; + scopes?: string | null; + }, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + + const existing = await this.findWorkspaceConnection(input.integrationId, trx); + if (existing) { + return this.update( + existing.id, + { + accessToken: input.accessToken, + refreshToken: input.refreshToken ?? null, + tokenExpiresAt: input.tokenExpiresAt ?? null, + invalidatedAt: null, + scopes: input.scopes ?? null, + userId: input.userId, + }, + trx, + ); + } + + // No need to clear other rows: the (integration_id, user_id) unique index + // is partial on kind='user', so a workspace insert never conflicts with + // the installer's user-link row. + + return db + .insertInto('integrationConnections') + .values({ + integrationId: input.integrationId, + userId: input.userId, + workspaceId: input.workspaceId, + accessToken: input.accessToken, + refreshToken: input.refreshToken ?? null, + tokenExpiresAt: input.tokenExpiresAt ?? null, + scopes: input.scopes ?? null, + kind: 'workspace', + }) + .returningAll() + .executeTakeFirstOrThrow(); + } + + async update( + connectionId: string, + data: UpdatableIntegrationConnection, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + return db + .updateTable('integrationConnections') + .set({ ...data, updatedAt: new Date() }) + .where('id', '=', connectionId) + .returningAll() + .executeTakeFirstOrThrow(); + } + + async deleteByIntegrationAndUser( + integrationId: string, + userId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + // Never delete a kind='workspace' row from a per-user disconnect. + // For Slack (and any future workspace-scoped provider) the installer's + // userId matches the workspace connection's userId; without this filter + // a single user clicking Disconnect would wipe the shared bot token and + // break the integration for the whole workspace. Full uninstall uses + // deleteByIntegration which intentionally has no kind filter. + await db + .deleteFrom('integrationConnections') + .where('integrationId', '=', integrationId) + .where('userId', '=', userId) + .where('kind', '!=', 'workspace') + .execute(); + } + + async findByUserAndWorkspace( + userId: string, + workspaceId: string, + trx?: KyselyTransaction, + ) { + const db = dbOrTx(this.db, trx); + return db + .selectFrom('integrationConnections') + .innerJoin( + 'integrations', + 'integrations.id', + 'integrationConnections.integrationId', + ) + .select([ + 'integrationConnections.integrationId', + 'integrations.type', + 'integrationConnections.providerUserId', + 'integrationConnections.metadata', + 'integrationConnections.createdAt', + 'integrationConnections.invalidatedAt', + ]) + .where('integrationConnections.userId', '=', userId) + // The workspace bot row carries the installer's userId; without this + // filter it renders on the connections page as a personal link. + .where('integrationConnections.kind', '=', 'user') + .where('integrations.workspaceId', '=', workspaceId) + .where('integrations.deletedAt', 'is', null) + .execute(); + } + + async findExpiringTokens( + expiresBeforeMs: number, + ): Promise { + const threshold = new Date(Date.now() + expiresBeforeMs); + return this.db + .selectFrom('integrationConnections') + .innerJoin( + 'integrations', + 'integrations.id', + 'integrationConnections.integrationId', + ) + .selectAll('integrationConnections') + .where('integrations.deletedAt', 'is', null) + .where('integrationConnections.invalidatedAt', 'is', null) + .where('integrationConnections.refreshToken', 'is not', null) + .where('integrationConnections.tokenExpiresAt', 'is not', null) + .where('integrationConnections.tokenExpiresAt', '<', threshold) + .execute(); + } + + // Retire a rejected credential: flag for reconnect UX, drop the dead refresh token; no-op if the row is gone. + async invalidate(connectionId: string): Promise { + await this.db + .updateTable('integrationConnections') + .set({ + invalidatedAt: new Date(), + refreshToken: null, + tokenExpiresAt: null, + updatedAt: new Date(), + }) + .where('id', '=', connectionId) + .execute(); + } + + async deleteByIntegration( + integrationId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + await db + .deleteFrom('integrationConnections') + .where('integrationId', '=', integrationId) + .execute(); + } + + async findWorkspaceConnection( + integrationId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + return db + .selectFrom('integrationConnections') + .selectAll() + .where('integrationId', '=', integrationId) + .where('kind', '=', 'workspace') + .executeTakeFirst(); + } + + async findUserLink( + integrationId: string, + providerUserId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + return db + .selectFrom('integrationConnections') + .selectAll() + .where('integrationId', '=', integrationId) + .where('providerUserId', '=', providerUserId) + .where('kind', '=', 'user') + .executeTakeFirst(); + } + + async findUserLinkByUserId( + integrationId: string, + userId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + return db + .selectFrom('integrationConnections') + .selectAll() + .where('integrationId', '=', integrationId) + .where('userId', '=', userId) + .where('kind', '=', 'user') + .executeTakeFirst(); + } + + async deleteUserLink( + integrationId: string, + userId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + await db + .deleteFrom('integrationConnections') + .where('integrationId', '=', integrationId) + .where('userId', '=', userId) + .where('kind', '=', 'user') + .execute(); + } + + async upsertUserLink( + input: { + integrationId: string; + workspaceId: string; + userId: string; + providerUserId: string; + metadata: Record; + }, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + // Target the partial unique index uq_integration_connections_user_per_integration + // (integration_id, user_id) WHERE kind = 'user'. Without the .where() hint, + // ON CONFLICT can't match a partial index. The kind discriminator means a + // workspace bot row sharing (integration_id, user_id) with this user-link + // is no longer a conflict, so we cannot flip its kind. + return await db + .insertInto('integrationConnections') + .values({ + integrationId: input.integrationId, + workspaceId: input.workspaceId, + userId: input.userId, + providerUserId: input.providerUserId, + kind: 'user', + metadata: input.metadata as any, + accessToken: null, + }) + .onConflict((oc) => + oc + .columns(['integrationId', 'userId']) + .where(sql.ref('kind'), '=', 'user') + .doUpdateSet({ + providerUserId: input.providerUserId, + metadata: input.metadata as any, + updatedAt: new Date(), + }), + ) + .returningAll() + .executeTakeFirstOrThrow(); + } +} diff --git a/apps/server/src/core/integration/repos/integration.repo.ts b/apps/server/src/core/integration/repos/integration.repo.ts new file mode 100644 index 000000000..aa0511f45 --- /dev/null +++ b/apps/server/src/core/integration/repos/integration.repo.ts @@ -0,0 +1,127 @@ +import { Injectable } from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; +import { sql } from 'kysely'; +import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; +import { + Integration, + InsertableIntegration, + UpdatableIntegration, +} from '@docmost/db/types/entity.types'; +import { dbOrTx } from '@docmost/db/utils'; + +@Injectable() +export class IntegrationRepo { + constructor(@InjectKysely() private readonly db: KyselyDB) {} + + async findById( + integrationId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + return db + .selectFrom('integrations') + .selectAll() + .where('id', '=', integrationId) + .where('deletedAt', 'is', null) + .executeTakeFirst(); + } + + async findByWorkspaceAndType( + workspaceId: string, + type: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + return db + .selectFrom('integrations') + .selectAll() + .where('workspaceId', '=', workspaceId) + .where('type', '=', type) + .where('deletedAt', 'is', null) + .executeTakeFirst(); + } + + async findAllByWorkspace( + workspaceId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + return db + .selectFrom('integrations') + .selectAll() + .where('workspaceId', '=', workspaceId) + .where('deletedAt', 'is', null) + .execute(); + } + + async insert( + integration: InsertableIntegration, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + return db + .insertInto('integrations') + .values(integration) + .returningAll() + .executeTakeFirstOrThrow(); + } + + async insertOrRestore( + integration: InsertableIntegration, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + return db + .insertInto('integrations') + .values(integration) + .onConflict((oc) => + oc.columns(['type', 'workspaceId']).doUpdateSet({ + deletedAt: null, + installedById: integration.installedById, + updatedAt: new Date(), + }), + ) + .returningAll() + .executeTakeFirstOrThrow(); + } + + async update( + integrationId: string, + data: UpdatableIntegration, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + return db + .updateTable('integrations') + .set({ ...data, updatedAt: new Date() }) + .where('id', '=', integrationId) + .returningAll() + .executeTakeFirstOrThrow(); + } + + async softDelete( + integrationId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + await db + .updateTable('integrations') + .set({ deletedAt: new Date() }) + .where('id', '=', integrationId) + .execute(); + } + + async findByTypeAndSettingsField( + type: string, + key: string, + value: string, + ): Promise { + return this.db + .selectFrom('integrations') + .selectAll() + .where('type', '=', type) + .where('deletedAt', 'is', null) + .where(sql`settings->>${sql.lit(key)}`, '=', value) + .executeTakeFirst(); + } +} diff --git a/apps/server/src/core/integration/unfurl/unfurl.controller.ts b/apps/server/src/core/integration/unfurl/unfurl.controller.ts new file mode 100644 index 000000000..5e2911220 --- /dev/null +++ b/apps/server/src/core/integration/unfurl/unfurl.controller.ts @@ -0,0 +1,35 @@ +import { + Body, + Controller, + HttpCode, + HttpStatus, + Post, + UseGuards, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard'; +import { AuthUser } from '../../../common/decorators/auth-user.decorator'; +import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator'; +import { User, Workspace } from '@docmost/db/types/entity.types'; +import { UnfurlService } from './unfurl.service'; +import { UnfurlDto } from '../dto/integration.dto'; + +@Controller('integrations') +export class UnfurlController { + constructor(private readonly unfurlService: UnfurlService) {} + + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @Post('unfurl') + async unfurl( + @Body() dto: UnfurlDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + const result = await this.unfurlService.unfurl( + dto.url, + user.id, + workspace.id, + ); + return { data: result }; + } +} diff --git a/apps/server/src/core/integration/unfurl/unfurl.service.ts b/apps/server/src/core/integration/unfurl/unfurl.service.ts new file mode 100644 index 000000000..16d9ed9d5 --- /dev/null +++ b/apps/server/src/core/integration/unfurl/unfurl.service.ts @@ -0,0 +1,268 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { IntegrationRegistry } from '../registry/integration-registry'; +import { IntegrationConnectionRepo } from '../repos/integration-connection.repo'; +import { IntegrationRepo } from '../repos/integration.repo'; +import { OAuthService } from '../oauth/oauth.service'; +import { + UnfurlResult, + UnfurlNeedsConnection, + UnfurlForbiddenError, + UnfurlNeedsConnectionError, + TokenInvalidError, + ProviderApiError, + IntegrationProvider, +} from '../registry/integration-provider.interface'; +import { RedisService } from '@nestjs-labs/nestjs-ioredis'; +import type { Redis } from 'ioredis'; +import * as crypto from 'crypto'; + +const UNFURL_CACHE_TTL = 300; // 5 minutes +// Transient failures get a short negative cache so a broken provider is not +// re-fetched on every view; 404s cache at the normal TTL (the target is gone). +const UNFURL_ERROR_CACHE_TTL = 60; +const UNFURL_CACHE_PREFIX = 'unfurl:'; + +@Injectable() +export class UnfurlService { + private readonly logger = new Logger(UnfurlService.name); + private readonly redis: Redis; + + constructor( + private readonly registry: IntegrationRegistry, + private readonly integrationRepo: IntegrationRepo, + private readonly connectionRepo: IntegrationConnectionRepo, + private readonly oauthService: OAuthService, + private readonly redisService: RedisService, + ) { + this.redis = this.redisService.getOrThrow(); + } + + async unfurl( + url: string, + userId: string, + workspaceId: string, + ): Promise { + const cacheKey = this.buildCacheKey(workspaceId, userId, url); + const cached = await this.redis.get(cacheKey); + if (cached) { + return JSON.parse(cached); + } + + const resolved = await this.resolveProvider(url, workspaceId); + + if (!resolved) { + return null; + } + + const { provider, match, patternType, integration } = resolved; + + if (!provider.unfurl) { + return null; + } + + // Workspace-scoped providers (Slack) share one bot connection that serves + // every member; user-scoped providers need the requester's own token. + const connectionScope = + provider.definition.oauth?.connectionScope ?? 'user'; + const connection = + connectionScope === 'workspace' + ? await this.connectionRepo.findWorkspaceConnection(integration.id) + : await this.connectionRepo.findByIntegrationAndUser( + integration.id, + userId, + ); + + if (!connection || connection.invalidatedAt) { + // Dead workspace connections need an admin re-install; members get no card. + if (connectionScope === 'workspace') { + return null; + } + // Not cached: the card should load as soon as the user (re)connects. + return this.buildNeedsConnection( + provider, + integration.id, + patternType, + match, + url, + ); + } + + try { + const accessToken = + await this.oauthService.getValidAccessToken(connection); + + const unfurlResult = await provider.unfurl({ + url, + accessToken, + match, + patternType, + settings: (integration.settings as Record) ?? {}, + userId, + integrationId: integration.id, + }); + + await this.redis.set( + cacheKey, + JSON.stringify(unfurlResult), + 'EX', + UNFURL_CACHE_TTL, + ); + + return unfurlResult; + } catch (err) { + // The provider needs the requester to link an identity first (Slack's + // workspace bot serves everyone, but only linked members may unfurl). + // Not cached: the card should load as soon as the user links. + if (err instanceof UnfurlNeedsConnectionError) { + return this.buildNeedsConnection( + provider, + integration.id, + patternType, + match, + url, + ); + } + // Not-authorized is an expected outcome (no card), not an error. + if (err instanceof UnfurlForbiddenError) { + this.logger.debug( + `Unfurl not authorized for ${url}: ${(err as Error).message}`, + ); + await this.cacheNull(cacheKey, UNFURL_ERROR_CACHE_TTL); + return null; + } + if (err instanceof TokenInvalidError) { + this.logger.warn( + `Retiring connection ${connection.id}: ${(err as Error).message}`, + ); + await this.connectionRepo + .invalidate(connection.id) + .catch(() => undefined); + if (connectionScope === 'workspace') { + return null; + } + // Not cached so the card heals the moment the user reconnects. + return this.buildNeedsConnection( + provider, + integration.id, + patternType, + match, + url, + ); + } + this.logger.error(`Unfurl failed for ${url}: ${(err as Error).message}`); + const ttl = + err instanceof ProviderApiError && err.status === 404 + ? UNFURL_CACHE_TTL + : UNFURL_ERROR_CACHE_TTL; + await this.cacheNull(cacheKey, ttl); + return null; + } + } + + private async cacheNull(cacheKey: string, ttl: number): Promise { + await this.redis.set(cacheKey, 'null', 'EX', ttl); + } + + async purgeUserCache(workspaceId: string, userId: string): Promise { + const pattern = `${UNFURL_CACHE_PREFIX}${workspaceId}:${userId}:*`; + try { + const stream = this.redis.scanStream({ match: pattern, count: 100 }); + for await (const keys of stream as AsyncIterable) { + if (keys.length) { + await this.redis.unlink(...keys); + } + } + } catch (err) { + // best-effort by design: never fail a disconnect on cache purge; the TTL is the backstop + this.logger.error( + `Failed to purge unfurl cache for user ${userId}: ${(err as Error).message}`, + ); + } + } + + private buildNeedsConnection( + provider: IntegrationProvider, + integrationId: string, + patternType: string, + match: RegExpMatchArray, + url: string, + ): UnfurlNeedsConnection { + const described = + provider.describeLink?.(patternType, match, url) ?? null; + + let fallbackDescription: string | undefined; + try { + const parsed = new URL(url); + fallbackDescription = `${parsed.host}${parsed.pathname}`; + } catch { + fallbackDescription = undefined; + } + + return { + needsConnection: true, + integrationId, + integrationType: provider.definition.type, + integrationName: provider.definition.name, + // Workspace-scoped providers also bind the authorizing user's identity + // on OAuth completion (onConnected upserts their user link), so any + // OAuth-capable provider supports connecting from Docmost. + oauthConnect: !!provider.definition.oauth, + title: described?.title ?? `${provider.definition.name} link`, + description: described?.description ?? fallbackDescription, + }; + } + + private async resolveProvider( + url: string, + workspaceId: string, + ): Promise<{ + provider: IntegrationProvider; + match: RegExpMatchArray; + patternType: string; + integration: { + id: string; + type: string; + settings: unknown; + }; + } | null> { + const staticResult = this.registry.findUnfurlProvider(url); + if (staticResult) { + const integration = await this.integrationRepo.findByWorkspaceAndType( + workspaceId, + staticResult.provider.definition.type, + ); + if (integration) { + return { ...staticResult, integration }; + } + } + + const integrations = + await this.integrationRepo.findAllByWorkspace(workspaceId); + + for (const integration of integrations) { + const provider = this.registry.getProvider(integration.type); + if (!provider?.getUnfurlPatterns || !provider.unfurl) continue; + + const settings = (integration.settings as Record) ?? {}; + const patterns = provider.getUnfurlPatterns(settings); + + for (const pattern of patterns) { + const match = url.match(pattern.regex); + if (match) { + return { provider, match, patternType: pattern.type, integration }; + } + } + } + + return null; + } + + private buildCacheKey(workspaceId: string, userId: string, url: string): string { + const hash = crypto + .createHash('sha256') + .update(url) + .digest('hex') + .slice(0, 16); + return `${UNFURL_CACHE_PREFIX}${workspaceId}:${userId}:${hash}`; + } +} diff --git a/apps/server/src/core/integration/utils/provider-fetch.ts b/apps/server/src/core/integration/utils/provider-fetch.ts new file mode 100644 index 000000000..fec29f916 --- /dev/null +++ b/apps/server/src/core/integration/utils/provider-fetch.ts @@ -0,0 +1,67 @@ +import { + ProviderApiError, + TokenInvalidError, + UnfurlForbiddenError, +} from '../registry/integration-provider.interface'; +import { proxyFetch } from '../../../common/proxy-fetch'; + +export const INTEGRATION_HTTP_TIMEOUT_MS = 10_000; + +// Providers explain refusals in the response body ("insufficient scope", +// "not allowed", ...); without it a 403 is undiagnosable from the logs. +const MAX_ERROR_BODY_CHARS = 300; + +async function readErrorBody(response: Response): Promise { + try { + const text = await response.text(); + return text.replace(/\s+/g, ' ').trim().slice(0, MAX_ERROR_BODY_CHARS); + } catch { + return ''; + } +} + +// Bounds every provider call and maps 401 to TokenInvalidError so callers can retire the connection. +export async function providerApiFetch( + providerName: string, + url: string, + init: RequestInit = {}, +): Promise { + const response = await proxyFetch(url, { + ...init, + redirect: 'manual', + signal: AbortSignal.timeout(INTEGRATION_HTTP_TIMEOUT_MS), + }); + + // Don't follow redirects: a 3xx could hop to an internal address. + if (response.type === 'opaqueredirect' || response.status === 0) { + throw new ProviderApiError(providerName, 502, 'unexpected redirect'); + } + + if (response.status === 401) { + throw new TokenInvalidError( + `${providerName} API error: 401 Unauthorized ${await readErrorBody(response)}`.trimEnd(), + ); + } + if (!response.ok) { + const body = await readErrorBody(response); + + // 403 normally means the viewer simply can't reach that resource, which is + // an expected "no card" outcome. GitHub also spends 403 on secondary rate + // limits, so quota signals stay a real error an operator can see. + const rateLimited = + response.headers.get('retry-after') !== null || + response.headers.get('x-ratelimit-remaining') === '0'; + if (response.status === 403 && !rateLimited) { + throw new UnfurlForbiddenError( + `${providerName} API error: 403 ${body}`.trimEnd(), + ); + } + + throw new ProviderApiError( + providerName, + response.status, + `${response.statusText} ${body}`.trim(), + ); + } + return response; +} diff --git a/apps/server/src/core/integration/utils/relative-time.ts b/apps/server/src/core/integration/utils/relative-time.ts new file mode 100644 index 000000000..250ce1469 --- /dev/null +++ b/apps/server/src/core/integration/utils/relative-time.ts @@ -0,0 +1,5 @@ +import { formatDistanceStrict } from 'date-fns'; + +export function relativeTime(iso: string): string { + return formatDistanceStrict(new Date(iso), new Date(), { addSuffix: true }); +} diff --git a/apps/server/src/core/notification/notification.service.ts b/apps/server/src/core/notification/notification.service.ts index 1f88bf59e..ab1b88e64 100644 --- a/apps/server/src/core/notification/notification.service.ts +++ b/apps/server/src/core/notification/notification.service.ts @@ -1,5 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectKysely } from 'nestjs-kysely'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { KyselyDB } from '@docmost/db/types/kysely.types'; import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo'; import { InsertableNotification } from '@docmost/db/types/entity.types'; @@ -8,6 +9,7 @@ import { WsGateway } from '../../ws/ws.gateway'; import { MailService } from '../../integrations/mail/mail.service'; import { NotificationTab, NotificationType, NotificationTypeToSettingKey } from './notification.constants'; import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo'; +import { EventName } from '../../common/events/event.contants'; @Injectable() export class NotificationService { @@ -18,6 +20,7 @@ export class NotificationService { private readonly pagePermissionRepo: PagePermissionRepo, private readonly wsGateway: WsGateway, private readonly mailService: MailService, + private readonly eventEmitter: EventEmitter2, @InjectKysely() private readonly db: KyselyDB, ) {} @@ -34,6 +37,8 @@ export class NotificationService { const notification = await this.notificationRepo.insert(data); + this.eventEmitter.emit(EventName.NOTIFICATION_CREATED, notification); + this.wsGateway.server .to(`user-${data.userId}`) .emit('notification', { id: notification.id, type: notification.type }); diff --git a/apps/server/src/core/page/page-access/page-access.service.ts b/apps/server/src/core/page/page-access/page-access.service.ts index 6d6db03fa..0dded2f00 100644 --- a/apps/server/src/core/page/page-access/page-access.service.ts +++ b/apps/server/src/core/page/page-access/page-access.service.ts @@ -102,6 +102,19 @@ export class PageAccessService { return { hasRestriction: hasAnyRestriction }; } + /** + * Validate user can create a root page in the space, throws if not. + * Mirrors the space-level check the HTTP create endpoint enforces so + * non-HTTP callers (Slack, integrations) cannot bypass it. A non-member + * (including a space in another workspace) throws from createForUser. + */ + async validateCanCreate(spaceId: string, user: User): Promise { + const ability = await this.spaceAbility.createForUser(user, spaceId); + if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Page)) { + throw new ForbiddenException(); + } + } + async validateCanComment( page: Page, user: User, diff --git a/apps/server/src/core/search/search.service.ts b/apps/server/src/core/search/search.service.ts index 9883b2654..54d748329 100644 --- a/apps/server/src/core/search/search.service.ts +++ b/apps/server/src/core/search/search.service.ts @@ -27,6 +27,7 @@ export class SearchService { opts: { userId?: string; workspaceId: string; + titlesOnly?: boolean; }, ): Promise<{ items: SearchResponseDto[] }> { const { query } = searchParams; @@ -34,6 +35,12 @@ export class SearchService { if (query.length < 1) { return { items: [] }; } + + // Use ILIKE titles-only search if titlesOnly flag is set + if (opts.titlesOnly) { + return this.searchPageTitlesOnly(searchParams, opts); + } + const searchQuery = tsquery(query.trim() + '*'); let queryResults = this.db @@ -151,6 +158,71 @@ export class SearchService { return { items: searchResults }; } + private async searchPageTitlesOnly( + searchParams: SearchDTO, + opts: { + userId?: string; + workspaceId: string; + }, + ): Promise<{ items: SearchResponseDto[] }> { + const { query } = searchParams; + + let queryResults = this.db + .selectFrom('pages') + .select([ + 'id', + 'slugId', + 'title', + 'icon', + 'parentPageId', + 'creatorId', + 'createdAt', + 'updatedAt', + ]) + .where('title', 'ilike', `%${query}%`) + .where('deletedAt', 'is', null) + .orderBy('updatedAt', 'desc') + .limit(searchParams.limit || 10) + .offset(searchParams.offset || 0); + + if (searchParams.spaceId) { + // search by spaceId + queryResults = queryResults.where('spaceId', '=', searchParams.spaceId); + } else if (opts.userId) { + // only search spaces the user is a member of + queryResults = queryResults + .where( + 'spaceId', + 'in', + this.spaceMemberRepo.getUserSpaceIdsQuery(opts.userId), + ) + .where('workspaceId', '=', opts.workspaceId); + } else { + return { items: [] }; + } + + queryResults = queryResults.select((eb) => this.pageRepo.withSpace(eb)); + + //@ts-ignore + let results: any[] = await queryResults.execute(); + + // Filter results by page-level permissions (if user is authenticated) + if (opts.userId && results.length > 0) { + const pageIds = results.map((r: any) => r.id); + const accessibleIds = + await this.pagePermissionRepo.filterAccessiblePageIds({ + pageIds, + userId: opts.userId, + spaceId: searchParams.spaceId, + }); + const accessibleSet = new Set(accessibleIds); + results = results.filter((r: any) => accessibleSet.has(r.id)); + } + + //@ts-ignore + return { items: results }; + } + async searchSuggestions( suggestion: SearchSuggestionDTO, userId: string, diff --git a/apps/server/src/database/migrations/20260807T1264122-integrations.ts b/apps/server/src/database/migrations/20260807T1264122-integrations.ts new file mode 100644 index 000000000..c9e03e9d0 --- /dev/null +++ b/apps/server/src/database/migrations/20260807T1264122-integrations.ts @@ -0,0 +1,92 @@ +import { type Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('integrations') + .ifNotExists() + .addColumn('id', 'uuid', (col) => + col.primaryKey().defaultTo(sql`gen_uuid_v7()`), + ) + .addColumn('workspace_id', 'uuid', (col) => + col.references('workspaces.id').onDelete('cascade').notNull(), + ) + .addColumn('type', 'text', (col) => col.notNull()) + .addColumn('settings', 'jsonb') + .addColumn('installed_by_id', 'uuid', (col) => + col.references('users.id').onDelete('set null'), + ) + .addColumn('created_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addColumn('updated_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addColumn('deleted_at', 'timestamptz') + .addUniqueConstraint('uq_integrations_workspace_type', [ + 'workspace_id', + 'type', + ]) + .execute(); + + await db.schema + .createTable('integration_connections') + .ifNotExists() + .addColumn('id', 'uuid', (col) => + col.primaryKey().defaultTo(sql`gen_uuid_v7()`), + ) + .addColumn('integration_id', 'uuid', (col) => + col.references('integrations.id').onDelete('cascade').notNull(), + ) + .addColumn('user_id', 'uuid', (col) => + col.references('users.id').onDelete('cascade').notNull(), + ) + .addColumn('workspace_id', 'uuid', (col) => + col.references('workspaces.id').onDelete('cascade').notNull(), + ) + .addColumn('provider_user_id', 'text') + .addColumn('access_token', 'text') + .addColumn('refresh_token', 'text') + .addColumn('token_expires_at', 'timestamptz') + .addColumn('invalidated_at', 'timestamptz') + .addColumn('scopes', 'text') + .addColumn('metadata', 'jsonb') + // 'workspace' = one shared bot/app connection per integration (Slack); + // 'user' = a per-user OAuth token or identity link (Linear, GitHub, Slack + // identity binding). Enforced via a check constraint below. + .addColumn('kind', 'text', (col) => col.notNull().defaultTo('user')) + .addColumn('created_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addColumn('updated_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .execute(); + + await sql` + ALTER TABLE integration_connections + ADD CONSTRAINT integration_connections_kind_check + CHECK (kind IN ('workspace', 'user')) + `.execute(db); + + // One workspace-bot connection per integration. + await db.schema + .createIndex('uq_integration_connections_workspace_per_integration') + .on('integration_connections') + .column('integration_id') + .where(sql.ref('kind'), '=', 'workspace') + .unique() + .execute(); + + await db.schema + .createIndex('uq_integration_connections_user_per_integration') + .on('integration_connections') + .columns(['integration_id', 'user_id']) + .where(sql.ref('kind'), '=', 'user') + .unique() + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('integration_connections').execute(); + await db.schema.dropTable('integrations').execute(); +} diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index 4756c2636..34a5f1245 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -506,6 +506,34 @@ export interface Watchers { createdAt: Generated; } +export interface Integrations { + id: Generated; + workspaceId: string; + type: string; + settings: Json | null; + installedById: string | null; + createdAt: Generated; + updatedAt: Generated; + deletedAt: Timestamp | null; +} + +export interface IntegrationConnections { + id: Generated; + integrationId: string; + userId: string; + workspaceId: string; + providerUserId: string | null; + accessToken: string | null; + refreshToken: string | null; + tokenExpiresAt: Timestamp | null; + invalidatedAt: Timestamp | null; + scopes: string | null; + kind: string; + metadata: Json | null; + createdAt: Generated; + updatedAt: Generated; +} + export interface Labels { id: Generated; name: string; @@ -654,6 +682,8 @@ export interface DB { fileTasks: FileTasks; groups: Groups; groupUsers: GroupUsers; + integrationConnections: IntegrationConnections; + integrations: Integrations; labels: Labels; notifications: Notifications; pageAccess: PageAccess; diff --git a/apps/server/src/database/types/db.interface.ts b/apps/server/src/database/types/db.interface.ts index be66fd8c0..418f902bc 100644 --- a/apps/server/src/database/types/db.interface.ts +++ b/apps/server/src/database/types/db.interface.ts @@ -1,6 +1,9 @@ import { DB } from '@docmost/db/types/db'; import { PageEmbeddings } from '@docmost/db/types/embeddings.types'; +import { Integrations, IntegrationConnections } from '@docmost/db/types/db'; export interface DbInterface extends DB { pageEmbeddings: PageEmbeddings; + integrations: Integrations; + integrationConnections: IntegrationConnections; } diff --git a/apps/server/src/database/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts index e8aa0572c..9dc80cafe 100644 --- a/apps/server/src/database/types/entity.types.ts +++ b/apps/server/src/database/types/entity.types.ts @@ -8,6 +8,8 @@ import { BaseViews, Comments, Groups, + Integrations as _Integrations, + IntegrationConnections as _IntegrationConnections, Labels, Notifications, PageLabels, @@ -199,6 +201,19 @@ export type Watcher = Selectable; export type InsertableWatcher = Insertable; export type UpdatableWatcher = Updateable>; +// Integration +export type Integration = Selectable<_Integrations>; +export type InsertableIntegration = Insertable<_Integrations>; +export type UpdatableIntegration = Updateable>; + +// Integration Connection +export type IntegrationConnection = Selectable<_IntegrationConnections>; +export type InsertableIntegrationConnection = + Insertable<_IntegrationConnections>; +export type UpdatableIntegrationConnection = Updateable< + Omit<_IntegrationConnections, 'id'> +>; + // Label export type Label = Selectable; export type InsertableLabel = Insertable; diff --git a/apps/server/src/ee b/apps/server/src/ee index b38dc5ecd..6d2d7a35d 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit b38dc5ecd10118b687e6cb073cf7202e8c2fe914 +Subproject commit 6d2d7a35df6389673a8666034e8d4a8c6945370f diff --git a/apps/server/src/integrations/encryption/encryption.errors.ts b/apps/server/src/integrations/encryption/encryption.errors.ts new file mode 100644 index 000000000..06b5cca81 --- /dev/null +++ b/apps/server/src/integrations/encryption/encryption.errors.ts @@ -0,0 +1,13 @@ +export class UnableToInitialize extends Error { + constructor(message: string) { + super(`Unable to initialize the encryption service: ${message}`); + this.name = 'UnableToInitialize'; + } +} + +export class UnableToDecrypt extends Error { + constructor(reason: string) { + super(`Unable to decrypt the ciphertext: ${reason}`); + this.name = 'UnableToDecrypt'; + } +} diff --git a/apps/server/src/integrations/encryption/encryption.module.ts b/apps/server/src/integrations/encryption/encryption.module.ts new file mode 100644 index 000000000..75022234e --- /dev/null +++ b/apps/server/src/integrations/encryption/encryption.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { EncryptionService } from './encryption.service'; + +@Global() +@Module({ + providers: [EncryptionService], + exports: [EncryptionService], +}) +export class EncryptionModule {} diff --git a/apps/server/src/integrations/encryption/encryption.service.spec.ts b/apps/server/src/integrations/encryption/encryption.service.spec.ts new file mode 100644 index 000000000..64b1a8e00 --- /dev/null +++ b/apps/server/src/integrations/encryption/encryption.service.spec.ts @@ -0,0 +1,184 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { EncryptionService } from './encryption.service'; +import { UnableToDecrypt, UnableToInitialize } from './encryption.errors'; +import { EnvironmentService } from '../environment/environment.service'; + +const APP_SECRET = 'test-app-secret-with-plenty-of-entropy-1234567890'; + +const buildService = (appSecret: string | undefined) => { + const env = { getAppSecret: () => appSecret } as EnvironmentService; + return new EncryptionService(env); +}; + +const decodeEnvelope = (encrypted: string) => + JSON.parse(Buffer.from(encrypted, 'base64').toString()) as { + iv: string; + authTag: string; + cipherText: string; + }; + +const encodeEnvelope = (envelope: { + iv: string; + authTag: string; + cipherText: string; +}) => Buffer.from(JSON.stringify(envelope)).toString('base64'); + +describe('EncryptionService', () => { + let service: EncryptionService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EncryptionService, + { + provide: EnvironmentService, + useValue: { getAppSecret: () => APP_SECRET }, + }, + ], + }).compile(); + + service = module.get(EncryptionService); + }); + + describe('initialization', () => { + it('compiles via Nest DI', () => { + expect(service).toBeDefined(); + }); + + it('throws UnableToInitialize when APP_SECRET is missing', () => { + expect(() => buildService(undefined)).toThrow(UnableToInitialize); + expect(() => buildService('')).toThrow(UnableToInitialize); + }); + }); + + describe('encrypt + decrypt round-trip', () => { + it('decrypts back to the original plaintext', () => { + const plaintext = 'hello world'; + const encrypted = service.encrypt(plaintext); + expect(service.decrypt(encrypted)).toBe(plaintext); + }); + + it('handles empty string', () => { + const encrypted = service.encrypt(''); + expect(service.decrypt(encrypted)).toBe(''); + }); + + it('handles unicode (multi-byte UTF-8)', () => { + const plaintext = 'héllo 🔐 世界'; + const encrypted = service.encrypt(plaintext); + expect(service.decrypt(encrypted)).toBe(plaintext); + }); + + it('handles long plaintext (>1 block)', () => { + const plaintext = 'a'.repeat(10_000); + const encrypted = service.encrypt(plaintext); + expect(service.decrypt(encrypted)).toBe(plaintext); + }); + + it('produces distinct ciphertexts for the same plaintext (random IV)', () => { + const plaintext = 'same input'; + const a = service.encrypt(plaintext); + const b = service.encrypt(plaintext); + expect(a).not.toBe(b); + expect(service.decrypt(a)).toBe(plaintext); + expect(service.decrypt(b)).toBe(plaintext); + }); + }); + + describe('cross-key isolation', () => { + it('cannot decrypt ciphertext produced under a different APP_SECRET', () => { + const other = buildService('totally-different-secret-value-9876543210'); + const encrypted = service.encrypt('secret'); + expect(() => other.decrypt(encrypted)).toThrow(UnableToDecrypt); + }); + }); + + describe('tamper detection', () => { + it('rejects modified ciphertext', () => { + const encrypted = service.encrypt('hello'); + const env = decodeEnvelope(encrypted); + const tamperedCipher = Buffer.from(env.cipherText, 'base64'); + tamperedCipher[0] ^= 0x01; + const tampered = encodeEnvelope({ + ...env, + cipherText: tamperedCipher.toString('base64'), + }); + expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt); + }); + + it('rejects modified auth tag', () => { + const encrypted = service.encrypt('hello'); + const env = decodeEnvelope(encrypted); + const tamperedTag = Buffer.from(env.authTag, 'base64'); + tamperedTag[0] ^= 0x01; + const tampered = encodeEnvelope({ + ...env, + authTag: tamperedTag.toString('base64'), + }); + expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt); + }); + + it('rejects modified IV', () => { + const encrypted = service.encrypt('hello'); + const env = decodeEnvelope(encrypted); + const tamperedIV = Buffer.from(env.iv, 'base64'); + tamperedIV[0] ^= 0x01; + const tampered = encodeEnvelope({ + ...env, + iv: tamperedIV.toString('base64'), + }); + expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt); + }); + }); + + describe('malformed payloads', () => { + it('rejects non-base64 garbage', () => { + expect(() => service.decrypt('!!!not-valid-base64!!!')).toThrow( + UnableToDecrypt, + ); + }); + + it('rejects base64 of non-JSON', () => { + const garbage = Buffer.from('not json at all').toString('base64'); + expect(() => service.decrypt(garbage)).toThrow(UnableToDecrypt); + }); + + it('rejects JSON missing required fields', () => { + const partial = encodeEnvelope({ + iv: Buffer.alloc(12).toString('base64'), + authTag: Buffer.alloc(16).toString('base64'), + } as never); + expect(() => service.decrypt(partial)).toThrow(UnableToDecrypt); + }); + + it('rejects wrong-length IV', () => { + const encrypted = service.encrypt('hello'); + const env = decodeEnvelope(encrypted); + const bad = encodeEnvelope({ + ...env, + iv: Buffer.alloc(8).toString('base64'), + }); + expect(() => service.decrypt(bad)).toThrow(UnableToDecrypt); + }); + + it('rejects wrong-length auth tag', () => { + const encrypted = service.encrypt('hello'); + const env = decodeEnvelope(encrypted); + const bad = encodeEnvelope({ + ...env, + authTag: Buffer.alloc(8).toString('base64'), + }); + expect(() => service.decrypt(bad)).toThrow(UnableToDecrypt); + }); + }); + + describe('envelope format', () => { + it('returns base64 of JSON envelope with iv (12B), authTag (16B), cipherText', () => { + const encrypted = service.encrypt('hello'); + const env = decodeEnvelope(encrypted); + expect(Buffer.from(env.iv, 'base64')).toHaveLength(12); + expect(Buffer.from(env.authTag, 'base64')).toHaveLength(16); + expect(Buffer.from(env.cipherText, 'base64').length).toBeGreaterThan(0); + }); + }); +}); diff --git a/apps/server/src/integrations/encryption/encryption.service.ts b/apps/server/src/integrations/encryption/encryption.service.ts new file mode 100644 index 000000000..63dc4ce99 --- /dev/null +++ b/apps/server/src/integrations/encryption/encryption.service.ts @@ -0,0 +1,108 @@ +// https://github.com/nhedger/nestjs-encryption - MIT +import { Injectable } from '@nestjs/common'; +import { + createCipheriv, + createDecipheriv, + createHash, + randomBytes, +} from 'node:crypto'; +import { UnableToDecrypt, UnableToInitialize } from './encryption.errors'; +import { EnvironmentService } from '../environment/environment.service'; + +const ALGORITHM = 'aes-256-gcm'; +const KEY_DOMAIN = 'docmost:encryption:v1'; +const IV_LENGTH = 12; +const AUTH_TAG_LENGTH = 16; + +type AEADPayload = { + iv: TFormat; + authTag: TFormat; + cipherText: TFormat; +}; + +@Injectable() +export class EncryptionService { + private readonly key: Buffer; + + constructor(environmentService: EnvironmentService) { + const appSecret = environmentService.getAppSecret(); + if (!appSecret) { + throw new UnableToInitialize('APP_SECRET is not set.'); + } + this.key = createHash('sha256') + .update(KEY_DOMAIN) + .update(appSecret) + .digest(); + } + + public encrypt(plaintext: string): string { + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, this.key, iv); + const cipherText = Buffer.concat([ + cipher.update(plaintext, 'utf8'), + cipher.final(), + ]); + const authTag = cipher.getAuthTag(); + + const aead: AEADPayload = { + iv: iv.toString('base64'), + authTag: authTag.toString('base64'), + cipherText: cipherText.toString('base64'), + }; + + return Buffer.from(JSON.stringify(aead)).toString('base64'); + } + + public decrypt(encrypted: string): string { + try { + const { iv, authTag, cipherText } = this.decodeAEADPayload(encrypted); + const decipher = createDecipheriv(ALGORITHM, this.key, iv); + decipher.setAuthTag(authTag); + const decrypted = Buffer.concat([ + decipher.update(cipherText), + decipher.final(), + ]); + return decrypted.toString('utf8'); + } catch (e: unknown) { + throw new UnableToDecrypt((e as Error).message); + } + } + + private decodeAEADPayload(encodedPayload: string): AEADPayload { + const payload = Buffer.from(encodedPayload, 'base64'); + + let deserializedPkg: Record; + try { + deserializedPkg = JSON.parse(payload.toString()); + } catch { + throw new Error('The decoded AEAD payload is not a valid JSON string.'); + } + + for (const field of ['iv', 'authTag', 'cipherText']) { + if (!Object.prototype.hasOwnProperty.call(deserializedPkg, field)) { + throw new Error(`The AEAD payload is missing the ${field} field.`); + } + } + + const iv = Buffer.from(deserializedPkg.iv as string, 'base64'); + if (iv.length !== IV_LENGTH) { + throw new Error( + `The decoded IV is not the correct length. Expected ${IV_LENGTH} bytes, got ${iv.length} bytes.`, + ); + } + + const authTag = Buffer.from(deserializedPkg.authTag as string, 'base64'); + if (authTag.length !== AUTH_TAG_LENGTH) { + throw new Error( + `The decoded auth tag is not the correct length. Expected ${AUTH_TAG_LENGTH} bytes, got ${authTag.length} bytes.`, + ); + } + + const cipherText = Buffer.from( + deserializedPkg.cipherText as string, + 'base64', + ); + + return { iv, authTag, cipherText }; + } +} diff --git a/apps/server/src/integrations/environment/domain.service.ts b/apps/server/src/integrations/environment/domain.service.ts index fa6b17784..f00a2f515 100644 --- a/apps/server/src/integrations/environment/domain.service.ts +++ b/apps/server/src/integrations/environment/domain.service.ts @@ -18,4 +18,20 @@ export class DomainService { const protocol = this.environmentService.isHttps() ? 'https' : 'http'; return `${protocol}://${hostname}.${domain}`; } + + // Canonical workspace URL: prefers customDomain, falls back to {hostname}.{cloud-domain}, + // falls back to APP_URL for self-hosted. Used for multi-tenant OAuth return-redirects. + getWorkspaceUrl(workspace: { + hostname?: string | null; + customDomain?: string | null; + }): string { + if (!this.environmentService.isCloud()) { + return this.environmentService.getAppUrl(); + } + if (workspace.customDomain) { + const protocol = this.environmentService.isHttps() ? 'https' : 'http'; + return `${protocol}://${workspace.customDomain}`; + } + return this.getUrl(workspace.hostname ?? undefined); + } } diff --git a/apps/server/src/integrations/environment/environment.service.ts b/apps/server/src/integrations/environment/environment.service.ts index 5667bf5a6..de1747bd0 100644 --- a/apps/server/src/integrations/environment/environment.service.ts +++ b/apps/server/src/integrations/environment/environment.service.ts @@ -360,4 +360,8 @@ export class EnvironmentService { .map((o) => o.trim()) .filter(Boolean); } + + getSlackSigningSecret(): string | undefined { + return this.configService.get('INTEGRATION_SLACK_SIGNING_SECRET'); + } } diff --git a/apps/server/src/integrations/queue/constants/queue.constants.ts b/apps/server/src/integrations/queue/constants/queue.constants.ts index 546553438..e43a82440 100644 --- a/apps/server/src/integrations/queue/constants/queue.constants.ts +++ b/apps/server/src/integrations/queue/constants/queue.constants.ts @@ -8,7 +8,15 @@ export enum QueueName { AI_QUEUE = '{ai-queue}', HISTORY_QUEUE = '{history-queue}', NOTIFICATION_QUEUE = '{notification-queue}', + INTEGRATION_QUEUE = '{integration-queue}', AUDIT_QUEUE = '{audit-queue}', + SLACK_INBOUND = '{slack-inbound}', + // Separate queue for /docmost ask: AI work takes seconds and would + // otherwise starve fast inbound event dispatch. + SLACK_ASK = '{slack-ask}', + // Outbound notification DMs; isolated so Slack API latency and retries + // never block inbound event dispatch. + SLACK_NOTIFY = '{slack-notify}', BASE_QUEUE = '{base-queue}', } @@ -85,6 +93,12 @@ export enum QueueJob { PDF_EXPORT_TASK = 'pdf-export-task', PDF_EXPORT_CLEANUP = 'pdf-export-cleanup', + INTEGRATION_EVENT = 'integration-event', + INTEGRATION_TOKEN_REFRESH = 'integration-token-refresh', + SLACK_EVENT = 'slack-event', + SLACK_ASK = 'slack-ask', + SLACK_NOTIFICATION = 'slack-notification', + BASE_TYPE_CONVERSION = 'base-type-conversion', BASE_CELL_GC = 'base-cell-gc', BASE_FORMULA_RECOMPUTE = 'base-formula-recompute', diff --git a/apps/server/src/integrations/queue/queue.module.ts b/apps/server/src/integrations/queue/queue.module.ts index eeb74cbb3..9e6dc9c42 100644 --- a/apps/server/src/integrations/queue/queue.module.ts +++ b/apps/server/src/integrations/queue/queue.module.ts @@ -92,6 +92,14 @@ import { GeneralQueueProcessor } from './processors/general-queue.processor'; attempts: 3, }, }), + BullModule.registerQueue({ + name: QueueName.INTEGRATION_QUEUE, + defaultJobOptions: { + removeOnComplete: true, + removeOnFail: { count: 50 }, + attempts: 3, + }, + }), BullModule.registerQueue({ name: QueueName.BASE_QUEUE, defaultJobOptions: { diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 1c2ccebf1..baea047ec 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -118,6 +118,10 @@ async function bootstrap() { '/api/workspace/create', '/api/workspace/joined', '/api/workspace/find-by-email', + '/api/integrations/oauth', + '/api/integrations/slack/events', + '/api/integrations/slack/commands', + '/api/integrations/slack/interactivity', ]; if ( diff --git a/packages/editor-ext/src/index.ts b/packages/editor-ext/src/index.ts index 80a67f449..f3af33758 100644 --- a/packages/editor-ext/src/index.ts +++ b/packages/editor-ext/src/index.ts @@ -28,6 +28,7 @@ export * from "./lib/heading/heading"; export * from "./lib/unique-id"; export * from "./lib/shared-storage"; export * from "./lib/recreate-transform"; +export * from "./lib/integration-link"; export * from "./lib/columns"; export * from "./lib/status"; export * from "./lib/pdf"; diff --git a/packages/editor-ext/src/lib/integration-link/index.ts b/packages/editor-ext/src/lib/integration-link/index.ts new file mode 100644 index 000000000..bc49fe7b1 --- /dev/null +++ b/packages/editor-ext/src/lib/integration-link/index.ts @@ -0,0 +1,15 @@ +export { IntegrationLink } from "./integration-link"; +export type { + IntegrationLinkOptions, + IntegrationLinkAttributes, +} from "./integration-link"; +export { IntegrationMention } from "./integration-mention"; +export { + integrationLinkPatterns, + matchIntegrationLink, + describeIntegrationLink, +} from "./integration-link-patterns"; +export type { + IntegrationLinkPattern, + IntegrationLinkDescription, +} from "./integration-link-patterns"; diff --git a/packages/editor-ext/src/lib/integration-link/integration-link-patterns.ts b/packages/editor-ext/src/lib/integration-link/integration-link-patterns.ts new file mode 100644 index 000000000..9c095cecf --- /dev/null +++ b/packages/editor-ext/src/lib/integration-link/integration-link-patterns.ts @@ -0,0 +1,383 @@ +export type IntegrationLinkPattern = { + provider: string; + type: string; + regex: RegExp; +}; + +export const integrationLinkPatterns: IntegrationLinkPattern[] = [ + // Slack message permalink (host-specific; must precede the host-agnostic + // GitHub patterns, whose repo form would swallow /archives/) + { + provider: "slack", + type: "slack-message", + regex: + /^https?:\/\/[a-z0-9-]+\.slack\.com\/archives\/([a-zA-Z0-9-]+)\/p(\d+)(?:\?thread_ts=[\d.]+&cid=[A-Za-z\d]+)?$/, + }, + // Slack channel + { + provider: "slack", + type: "slack-channel", + regex: + /^https?:\/\/[a-z0-9-]+\.slack\.com\/archives\/([a-zA-Z0-9-]+)\/?$/, + }, + // Jira issue (cloud + self-hosted): /browse/KEY-123, tolerating ?atlOrigin=… + // Must precede the GitHub repo pattern, which would swallow the two-segment + // /browse/KEY path on any host. + { + provider: "jira", + type: "jira-issue", + regex: /^https?:\/\/[^\/]+\/browse\/([A-Za-z0-9]+-\d+)/, + }, + // Jira legacy board (cloud + self-hosted): RapidBoard.jspa?…selectedIssue=KEY + { + provider: "jira", + type: "jira-issue", + regex: + /^https?:\/\/[^\/]+\/secure\/RapidBoard\.jspa\?(?:.*&)?selectedIssue=([A-Za-z0-9]+-\d+)/, + }, + // Jira cloud board/backlog with a selected issue + { + provider: "jira", + type: "jira-issue", + regex: + /^https?:\/\/[a-z0-9-]+\.atlassian\.net\/jira\/software(?:\/c)?\/projects\/[\w-]+\/boards\/\d+(?:\/\w+)?\?(?:.*&)?selectedIssue=([A-Za-z0-9]+-\d+)/, + }, + // GitHub PR commit (must be before generic PR pattern) + { + provider: "github", + type: "github-pr-commit", + regex: + /^https?:\/\/[^\/]+\/([^\/]+)\/([^\/]+)\/pull\/(\d+)\/commits\/([a-f0-9]+)/, + }, + // GitHub PR (with optional /checks, /commits, /files sub-pages) + { + provider: "github", + type: "github-pr", + regex: + /^https?:\/\/[^\/]+\/([^\/]+)\/([^\/]+)\/pull\/(\d+)/, + }, + // GitHub issue + { + provider: "github", + type: "github-issue", + regex: + /^https?:\/\/[^\/]+\/([^\/]+)\/([^\/]+)\/issues\/(\d+)/, + }, + // GitHub commit + { + provider: "github", + type: "github-commit", + regex: + /^https?:\/\/[^\/]+\/([^\/]+)\/([^\/]+)\/commits?\/([a-f0-9]+)/, + }, + // GitHub file/blob + { + provider: "github", + type: "github-file", + regex: + /^https?:\/\/[^\/]+\/([^\/]+)\/([^\/]+)\/blob\/([^\/]+)\/(.+?)(?:#L(\d+)(?:-L(\d+))?)?$/, + }, + // GitHub pulls list + { + provider: "github", + type: "github-pulls-list", + regex: + /^https?:\/\/[^\/]+\/([^\/]+)\/([^\/]+)\/pulls(?:\/.*)?(?:\?.*)?$/, + }, + // GitHub releases list + { + provider: "github", + type: "github-releases-list", + regex: + /^https?:\/\/[^\/]+\/([^\/]+)\/([^\/]+)\/releases(?:\/.*)?(?:\?.*)?$/, + }, + // GitHub issues list + { + provider: "github", + type: "github-issues-list", + regex: + /^https?:\/\/[^\/]+\/([^\/]+)\/([^\/]+)\/issues(?:\/(?:created_by|assigned)\/[\w.\/-]+)?\/?(?:\?.*)?$/, + }, + // GitHub repo + { + provider: "github", + type: "github-repo", + regex: + /^https?:\/\/[^\/]+\/([a-zA-Z0-9\-_.]+)\/([a-zA-Z0-9\-_.]+)\/?$/, + }, + // GitLab commit in MR diff (must be before generic MR pattern) + { + provider: "gitlab", + type: "gitlab-commit-in-mr", + regex: + /^https?:\/\/[^\/]+\/(.+)\/-\/merge_requests\/(\d+)\/diffs\?.*commit_id=([a-f0-9]+)/, + }, + // GitLab merge request + { + provider: "gitlab", + type: "gitlab-mr", + regex: + /^https?:\/\/[^\/]+\/(.+)\/-\/merge_requests\/(\d+)/, + }, + // GitLab issue + { + provider: "gitlab", + type: "gitlab-issue", + regex: + /^https?:\/\/[^\/]+\/(.+)\/-\/issues\/(\d+)/, + }, + // GitLab work item (new URL format for issues) + { + provider: "gitlab", + type: "gitlab-issue", + regex: + /^https?:\/\/[^\/]+\/(.+)\/-\/work_items\/(\d+)/, + }, + // GitLab work item opened as a drawer over the list (?show=base64 payload) + { + provider: "gitlab", + type: "gitlab-work-item-drawer", + regex: + /^https?:\/\/[^\/]+\/(.+)\/-\/work_items\/?\?(?:.*&)?show=/, + }, + // GitLab commit + { + provider: "gitlab", + type: "gitlab-commit", + regex: + /^https?:\/\/[^\/]+\/(.+)\/-\/commits?\/([a-f0-9]+)/, + }, + // GitLab issues list + { + provider: "gitlab", + type: "gitlab-issues-list", + regex: + /^https?:\/\/[^\/]+\/(.+)\/-\/issues\/?(?:\?.*)?$/, + }, + // GitLab merge requests list + { + provider: "gitlab", + type: "gitlab-merges-list", + regex: + /^https?:\/\/[^\/]+\/(.+)\/-\/merge_requests\/?(?:\?.*)?$/, + }, + // GitLab project + { + provider: "gitlab", + type: "gitlab-project", + regex: + /^https?:\/\/[^\/]+\/([a-zA-Z0-9\-_.]+)\/([a-zA-Z0-9\-_]+)\/?$/, + }, + // Google Docs + { + provider: "google_docs", + type: "google-doc", + regex: /^https?:\/\/docs\.google\.com\/document\/d\/([\w-]+)/, + }, + // Google Sheets + { + provider: "google_docs", + type: "google-sheet", + regex: /^https?:\/\/docs\.google\.com\/spreadsheets\/d\/([\w-]+)/, + }, + // Google Slides + { + provider: "google_docs", + type: "google-slides", + regex: /^https?:\/\/docs\.google\.com\/presentation\/d\/([\w-]+)/, + }, + // Google Forms + { + provider: "google_docs", + type: "google-form", + regex: /^https?:\/\/docs\.google\.com\/forms\/d\/([\w-]+)/, + }, + // Google Drive file + { + provider: "google_docs", + type: "google-drive-file", + regex: /^https?:\/\/drive\.google\.com\/file\/d\/([\w-]+)/, + }, + // Figma file (design, file, proto, board) + { + provider: "figma", + type: "figma-file", + regex: + /^https?:\/\/([\w.-]+\.)?figma\.com\/(file|proto|board|design)\/([0-9a-zA-Z]{22,128})/, + }, + // Linear issue: /team/issue/KEY-123(/:title-slug)? + { + provider: "linear", + type: "linear-issue", + regex: + /^https?:\/\/linear\.app\/([^\/]+)\/issue\/([A-Z]+-\d+)(?:\/([^\/?#]+))?/, + }, + // Linear project: /team/project/:slug(/:tab)? + { + provider: "linear", + type: "linear-project", + regex: /^https?:\/\/linear\.app\/([^\/]+)\/project\/([^\/]+)/, + }, + // Linear initiative: /team/initiative/:slug(/:tab)? + { + provider: "linear", + type: "linear-initiative", + regex: /^https?:\/\/linear\.app\/([^\/]+)\/initiative\/([^\/]+)/, + }, + // Linear view: /team/view/:id(/:tab)? + { + provider: "linear", + type: "linear-view", + regex: /^https?:\/\/linear\.app\/([^\/]+)\/view\/([^\/]+)/, + }, +]; + +export function matchIntegrationLink( + url: string, +): { provider: string; type: string; match: RegExpMatchArray } | null { + for (const pattern of integrationLinkPatterns) { + const match = url.match(pattern.regex); + if (match) { + return { provider: pattern.provider, type: pattern.type, match }; + } + } + return null; +} + +export type IntegrationLinkDescription = { + provider: string; + title: string; + description?: string; +}; + +// Static, offline description of an integration url +export function describeIntegrationLink( + url: string, +): IntegrationLinkDescription | null { + const matched = matchIntegrationLink(url); + if (!matched) return null; + const { provider, type, match } = matched; + + const describe = (title: string, description?: string) => ({ + provider, + title, + description, + }); + const repo = () => `${match[1]}/${match[2]}`; + + switch (type) { + case "jira-issue": + return describe(match[1], hostOf(url)); + + case "github-pr": + return describe(`Pull Request #${match[3]}`, repo()); + case "github-pr-commit": + return describe(`Commit ${match[4].slice(0, 7)}`, repo()); + case "github-issue": + return describe(`Issue #${match[3]}`, repo()); + case "github-commit": + return describe(`Commit ${match[3].slice(0, 7)}`, repo()); + case "github-file": + return describe(match[4], repo()); + case "github-pulls-list": + return describe("Pull Requests", repo()); + case "github-issues-list": + return describe("Issues", repo()); + case "github-releases-list": + return describe("Releases", repo()); + case "github-repo": + return describe(repo()); + + case "gitlab-mr": + return describe(`Merge Request !${match[2]}`, match[1]); + case "gitlab-issue": + return describe(`Issue #${match[2]}`, match[1]); + case "gitlab-work-item-drawer": { + const target = decodeWorkItemShowParam(url); + return target + ? describe(`Issue #${target.iid}`, target.fullPath) + : describe("Work item", match[1]); + } + case "gitlab-commit": + return describe(`Commit ${match[2].slice(0, 8)}`, match[1]); + case "gitlab-commit-in-mr": + return describe(`Commit ${match[3].slice(0, 8)}`, match[1]); + case "gitlab-issues-list": + return describe("Issues", match[1]); + case "gitlab-merges-list": + return describe("Merge Requests", match[1]); + case "gitlab-project": + return describe(repo()); + + case "linear-issue": + return describe( + `Issue ${match[2]}`, + (match[3] && humanizeSlug(match[3])) || match[1], + ); + case "linear-project": + return describe(humanizeSlug(match[2]) ?? "Project", "Project"); + case "linear-initiative": + return describe(humanizeSlug(match[2]) ?? "Initiative", "Initiative"); + case "linear-view": + return describe("View", match[1]); + + case "slack-message": + return describe("Slack message", hostOf(url)); + case "slack-channel": + return describe("Slack channel", hostOf(url)); + case "figma-file": + return describe("Figma file", hostOf(url)); + case "google-doc": + return describe("Google Doc"); + case "google-sheet": + return describe("Google Sheet"); + case "google-slides": + return describe("Google Slides"); + case "google-form": + return describe("Google Form"); + case "google-drive-file": + return describe("Google Drive file"); + + default: + return null; + } +} + +function hostOf(url: string): string | undefined { + try { + return new URL(url).host; + } catch { + return undefined; + } +} + +// "mobile-app-1b9607f47174" -> "Mobile app": slugs end in a hex id segment. +function humanizeSlug(slug: string): string | null { + const name = slug + .replace(/-[a-f0-9]{8,}$/, "") + .replace(/-/g, " ") + .trim(); + if (!name || /^[a-f0-9]{8,}$/.test(name)) return null; + return name.charAt(0).toUpperCase() + name.slice(1); +} + +// The work items list opens an item as a drawer and encodes it in the URL +// as ?show=base64({ iid, full_path, id }). +function decodeWorkItemShowParam( + url: string, +): { fullPath: string; iid: number } | null { + try { + const show = new URL(url).searchParams.get("show"); + if (!show) return null; + const payload = JSON.parse( + atob(show.replace(/-/g, "+").replace(/_/g, "/")), + ); + const iid = parseInt(payload.iid, 10); + if (typeof payload.full_path !== "string" || Number.isNaN(iid)) { + return null; + } + return { fullPath: payload.full_path, iid }; + } catch { + return null; + } +} diff --git a/packages/editor-ext/src/lib/integration-link/integration-link.ts b/packages/editor-ext/src/lib/integration-link/integration-link.ts new file mode 100644 index 000000000..be82f3555 --- /dev/null +++ b/packages/editor-ext/src/lib/integration-link/integration-link.ts @@ -0,0 +1,107 @@ +import { Node, mergeAttributes } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +import { sanitizeUrl } from "../utils"; + +export interface IntegrationLinkOptions { + HTMLAttributes: Record; + view: any; +} + +export interface IntegrationLinkAttributes { + url: string; + provider: string; +} + +declare module "@tiptap/core" { + interface Commands { + integrationLink: { + setIntegrationLink: ( + attributes: Partial, + ) => ReturnType; + }; + } +} + +export const IntegrationLink = Node.create({ + name: "integrationLink", + inline: false, + group: "block", + isolating: true, + atom: true, + defining: true, + draggable: true, + + addOptions() { + return { + HTMLAttributes: {}, + view: null, + }; + }, + + addAttributes() { + return { + url: { + default: "", + parseHTML: (element: HTMLElement) => { + const url = element.getAttribute("data-url"); + return sanitizeUrl(url); + }, + renderHTML: (attributes: IntegrationLinkAttributes) => ({ + "data-url": sanitizeUrl(attributes.url), + }), + }, + provider: { + default: "", + parseHTML: (element: HTMLElement) => + element.getAttribute("data-provider"), + renderHTML: (attributes: IntegrationLinkAttributes) => ({ + "data-provider": attributes.provider, + }), + }, + }; + }, + + parseHTML() { + return [ + { + tag: `div[data-type="${this.name}"]`, + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + const url = HTMLAttributes["data-url"]; + const safeUrl = sanitizeUrl(url); + + return [ + "div", + mergeAttributes( + { "data-type": this.name }, + this.options.HTMLAttributes, + HTMLAttributes, + ), + ["a", { href: safeUrl, target: "_blank", rel: "noopener" }, safeUrl], + ]; + }, + + addCommands() { + return { + setIntegrationLink: + (attrs) => + ({ commands }) => { + return commands.insertContent({ + type: this.name, + attrs: { + ...attrs, + url: sanitizeUrl(attrs.url), + }, + }); + }, + }; + }, + + addNodeView() { + this.editor.isInitialized = true; + return ReactNodeViewRenderer(this.options.view); + }, +}); diff --git a/packages/editor-ext/src/lib/integration-link/integration-mention.ts b/packages/editor-ext/src/lib/integration-link/integration-mention.ts new file mode 100644 index 000000000..9ffa5d60c --- /dev/null +++ b/packages/editor-ext/src/lib/integration-link/integration-mention.ts @@ -0,0 +1,99 @@ +import { Node, mergeAttributes } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +import { sanitizeUrl } from "../utils"; +import { + IntegrationLinkAttributes, + IntegrationLinkOptions, +} from "./integration-link"; + +declare module "@tiptap/core" { + interface Commands { + integrationMention: { + setIntegrationMention: ( + attributes: Partial, + ) => ReturnType; + }; + } +} + +export const IntegrationMention = Node.create({ + name: "integrationMention", + inline: true, + group: "inline", + atom: true, + selectable: true, + draggable: false, + + addOptions() { + return { + HTMLAttributes: {}, + view: null, + }; + }, + + addAttributes() { + return { + url: { + default: "", + parseHTML: (element: HTMLElement) => { + const url = element.getAttribute("data-url"); + return sanitizeUrl(url); + }, + renderHTML: (attributes: IntegrationLinkAttributes) => ({ + "data-url": sanitizeUrl(attributes.url), + }), + }, + provider: { + default: "", + parseHTML: (element: HTMLElement) => + element.getAttribute("data-provider"), + renderHTML: (attributes: IntegrationLinkAttributes) => ({ + "data-provider": attributes.provider, + }), + }, + }; + }, + + parseHTML() { + return [ + { + tag: `span[data-type="${this.name}"]`, + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + const url = HTMLAttributes["data-url"]; + const safeUrl = sanitizeUrl(url); + + return [ + "span", + mergeAttributes( + { "data-type": this.name }, + this.options.HTMLAttributes, + HTMLAttributes, + ), + ["a", { href: safeUrl, target: "_blank", rel: "noopener" }, safeUrl], + ]; + }, + + addCommands() { + return { + setIntegrationMention: + (attrs) => + ({ commands }) => { + return commands.insertContent({ + type: this.name, + attrs: { + ...attrs, + url: sanitizeUrl(attrs.url), + }, + }); + }, + }; + }, + + addNodeView() { + return ReactNodeViewRenderer(this.options.view); + }, +});