From 6852e0427e3e99843062bc0c906eb01c8497780f Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:26:42 +0100 Subject: [PATCH] feat: add GitHub, GitLab, Jira, Linear, Figma, Azure Devops integrations (#2547) --- .env.example | 2 + .../public/locales/en-US/translation.json | 65 +- apps/client/src/App.tsx | 14 + .../components/icons/azure-devops-icon.tsx | 29 + .../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 | 11 +- .../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 | 20 + .../src/ee/audit/lib/audit-event-labels.ts | 20 + apps/client/src/ee/features.ts | 2 + .../security/components/allowed-domains.tsx | 1 + .../common/editor-paste-handler.test.ts | 342 +++++ .../common/editor-paste-handler.tsx | 101 ++ .../integration-link/badge-color.ts | 50 + .../integration-card-view.module.css | 27 + .../integration-card-view.tsx | 586 ++++++++ .../integration-display-picker.module.css | 191 +++ .../integration-display-picker.test.tsx | 188 +++ .../integration-display-picker.tsx | 366 +++++ .../integration-display.test.ts | 290 ++++ .../integration-link/integration-display.ts | 309 ++++ .../integration-link-patterns.test.ts | 345 +++++ .../integration-link-url.test.ts | 122 ++ .../integration-mention-view.module.css | 53 + .../integration-mention-view.tsx | 259 ++++ .../integration-paste-menu.module.css | 71 + .../integration-paste-menu.test.tsx | 213 +++ .../integration-paste-menu.tsx | 335 +++++ .../integration-link/use-unfurl.test.tsx | 118 ++ .../components/integration-link/use-unfurl.ts | 45 + .../editor/components/link/link-view.test.tsx | 225 +++ .../editor/components/link/link-view.tsx | 162 +- .../editor/components/link/link.module.css | 63 + .../features/editor/extensions/extensions.ts | 12 + .../extensions/integration-paste-menu.ts | 47 + .../src/features/editor/page-editor.tsx | 12 + .../integration/components/connection-row.tsx | 91 ++ .../components/integration-icons.tsx | 34 + .../components/integration-list-skeleton.tsx | 66 + .../components/integration-row.tsx | 102 ++ .../integration/pages/connections.tsx | 271 ++++ .../integration/pages/integrations.tsx | 123 ++ .../integration/queries/integration-query.ts | 68 + .../services/integration-service.ts | 70 + .../integration/types/integration.types.ts | 62 + .../integration/utils/connect-prompt.test.ts | 30 + .../integration/utils/connect-prompt.ts | 22 + apps/client/src/lib/app-route.ts | 1 + .../src/collaboration/collaboration.util.ts | 4 + apps/server/src/common/events/audit-events.ts | 11 +- .../src/common/events/event.contants.ts | 1 + apps/server/src/common/features.ts | 2 + apps/server/src/common/helpers/utils.ts | 2 +- apps/server/src/common/proxy-fetch.spec.ts | 64 + apps/server/src/common/proxy-fetch.ts | 37 + .../src/core/comment/comment.controller.ts | 14 +- apps/server/src/core/core.module.ts | 9 + .../integration/dto/integration.dto.spec.ts | 57 + .../core/integration/dto/integration.dto.ts | 45 + .../core/integration/dto/unfurl.dto.spec.ts | 36 + .../integration-connection.service.spec.ts | 73 + .../integration-connection.service.ts | 68 + .../integration/integration.controller.ts | 78 + .../core/integration/integration.listener.ts | 29 + .../core/integration/integration.module.ts | 37 + .../integration/integration.processor.spec.ts | 238 +++ .../core/integration/integration.processor.ts | 131 ++ .../core/integration/integration.service.ts | 63 + .../integration/oauth/credential-env.spec.ts | 23 + .../core/integration/oauth/credential-env.ts | 11 + .../oauth/oauth-complete-auth.filter.ts | 27 + .../integration/oauth/oauth-connect.spec.ts | 28 + .../core/integration/oauth/oauth-connect.ts | 9 + .../oauth/oauth-identity-link.spec.ts | 95 ++ .../oauth/oauth.controller.spec.ts | 553 +++++++ .../integration/oauth/oauth.controller.ts | 333 +++++ .../integration/oauth/oauth.service.spec.ts | 1327 +++++++++++++++++ .../core/integration/oauth/oauth.service.ts | 972 ++++++++++++ .../src/core/integration/oauth/oauth.utils.ts | 6 + .../providers/figma/figma-patterns.ts | 9 + .../providers/figma/figma.module.ts | 21 + .../providers/figma/figma.provider.ts | 45 + .../providers/figma/figma.service.spec.ts | 46 + .../providers/figma/figma.service.ts | 63 + .../providers/github/github-patterns.spec.ts | 143 ++ .../providers/github/github-patterns.ts | 70 + .../providers/github/github.module.ts | 21 + .../providers/github/github.provider.spec.ts | 167 +++ .../providers/github/github.provider.ts | 173 +++ .../providers/github/github.service.spec.ts | 146 ++ .../providers/github/github.service.ts | 282 ++++ .../providers/github/github.utils.ts | 9 + .../providers/linear/linear-patterns.spec.ts | 55 + .../providers/linear/linear-patterns.ts | 29 + .../providers/linear/linear.module.ts | 21 + .../providers/linear/linear.provider.ts | 107 ++ .../providers/linear/linear.service.spec.ts | 58 + .../providers/linear/linear.service.ts | 275 ++++ .../integration-provider.interface.ts | 230 +++ .../registry/integration-registry.spec.ts | 25 + .../registry/integration-registry.ts | 51 + .../repos/integration-connection.repo.spec.ts | 236 +++ .../repos/integration-connection.repo.ts | 371 +++++ .../integration/repos/integration.repo.ts | 119 ++ .../unfurl/unfurl.controller.spec.ts | 59 + .../integration/unfurl/unfurl.controller.ts | 43 + .../integration/unfurl/unfurl.service.spec.ts | 324 ++++ .../core/integration/unfurl/unfurl.service.ts | 251 ++++ .../core/integration/utils/canonical-path.ts | 2 + .../utils/integration.utils.spec.ts | 32 + .../integration/utils/integration.utils.ts | 40 + .../integration/utils/provider-fetch.spec.ts | 181 +++ .../core/integration/utils/provider-fetch.ts | 118 ++ .../integration/utils/refresh-lock.spec.ts | 129 ++ .../core/integration/utils/refresh-lock.ts | 72 + .../core/integration/utils/relative-time.ts | 5 + .../notification/notification.processor.ts | 12 +- .../core/notification/notification.service.ts | 5 + .../page/page-access/page-access.service.ts | 11 + .../workspace/dto/update-workspace.dto.ts | 1 + .../services/workspace.service.spec.ts | 83 ++ .../workspace/services/workspace.service.ts | 22 +- .../src/core/workspace/workspace.module.ts | 3 +- .../20260905T126412-integrations.ts | 124 ++ apps/server/src/database/types/db.d.ts | 30 + .../server/src/database/types/entity.types.ts | 15 + apps/server/src/ee | 2 +- .../encryption/a256cbc-hs512.spec.ts | 64 + .../integrations/encryption/a256cbc-hs512.ts | 82 + .../encryption/encryption.service.spec.ts | 245 ++- .../encryption/encryption.service.ts | 244 ++- .../environment/domain.service.ts | 14 + .../environment/environment.service.ts | 4 + .../queue/constants/queue.constants.ts | 11 + .../src/integrations/queue/queue.module.ts | 8 + .../integrations/throttle/throttle.module.ts | 2 + .../integrations/throttle/throttler-names.ts | 2 + apps/server/src/main.ts | 10 +- packages/editor-ext/src/index.ts | 1 + .../src/lib/integration-link/index.ts | 16 + .../lib/integration-link/integration-card.ts | 102 ++ .../integration-link-patterns.ts | 518 +++++++ .../lib/integration-link/integration-link.ts | 17 + .../integration-link/integration-mention.ts | 100 ++ packages/editor-ext/src/lib/link.ts | 9 + .../src/lib/prosemirror-docx/schema.ts | 33 +- packages/editor-ext/src/lib/utils.ts | 10 + 152 files changed, 15891 insertions(+), 142 deletions(-) create mode 100644 apps/client/src/components/icons/azure-devops-icon.tsx 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/common/editor-paste-handler.test.ts 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-card-view.module.css create mode 100644 apps/client/src/features/editor/components/integration-link/integration-card-view.tsx create mode 100644 apps/client/src/features/editor/components/integration-link/integration-display-picker.module.css create mode 100644 apps/client/src/features/editor/components/integration-link/integration-display-picker.test.tsx create mode 100644 apps/client/src/features/editor/components/integration-link/integration-display-picker.tsx create mode 100644 apps/client/src/features/editor/components/integration-link/integration-display.test.ts create mode 100644 apps/client/src/features/editor/components/integration-link/integration-display.ts create mode 100644 apps/client/src/features/editor/components/integration-link/integration-link-patterns.test.ts create mode 100644 apps/client/src/features/editor/components/integration-link/integration-link-url.test.ts create mode 100644 apps/client/src/features/editor/components/integration-link/integration-mention-view.module.css 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.module.css create mode 100644 apps/client/src/features/editor/components/integration-link/integration-paste-menu.test.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/components/link/link-view.test.tsx 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/queries/integration-query.ts create mode 100644 apps/client/src/features/integration/services/integration-service.ts create mode 100644 apps/client/src/features/integration/types/integration.types.ts create mode 100644 apps/client/src/features/integration/utils/connect-prompt.test.ts create mode 100644 apps/client/src/features/integration/utils/connect-prompt.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/dto/integration.dto.spec.ts create mode 100644 apps/server/src/core/integration/dto/integration.dto.ts create mode 100644 apps/server/src/core/integration/dto/unfurl.dto.spec.ts create mode 100644 apps/server/src/core/integration/integration-connection.service.spec.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.spec.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/credential-env.spec.ts create mode 100644 apps/server/src/core/integration/oauth/credential-env.ts create mode 100644 apps/server/src/core/integration/oauth/oauth-complete-auth.filter.ts create mode 100644 apps/server/src/core/integration/oauth/oauth-connect.spec.ts create mode 100644 apps/server/src/core/integration/oauth/oauth-connect.ts create mode 100644 apps/server/src/core/integration/oauth/oauth-identity-link.spec.ts create mode 100644 apps/server/src/core/integration/oauth/oauth.controller.spec.ts create mode 100644 apps/server/src/core/integration/oauth/oauth.controller.ts create mode 100644 apps/server/src/core/integration/oauth/oauth.service.spec.ts create mode 100644 apps/server/src/core/integration/oauth/oauth.service.ts create mode 100644 apps/server/src/core/integration/oauth/oauth.utils.ts create mode 100644 apps/server/src/core/integration/providers/figma/figma-patterns.ts create mode 100644 apps/server/src/core/integration/providers/figma/figma.module.ts create mode 100644 apps/server/src/core/integration/providers/figma/figma.provider.ts create mode 100644 apps/server/src/core/integration/providers/figma/figma.service.spec.ts create mode 100644 apps/server/src/core/integration/providers/figma/figma.service.ts create mode 100644 apps/server/src/core/integration/providers/github/github-patterns.spec.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.spec.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.spec.ts create mode 100644 apps/server/src/core/integration/providers/github/github.service.ts create mode 100644 apps/server/src/core/integration/providers/github/github.utils.ts create mode 100644 apps/server/src/core/integration/providers/linear/linear-patterns.spec.ts create mode 100644 apps/server/src/core/integration/providers/linear/linear-patterns.ts create mode 100644 apps/server/src/core/integration/providers/linear/linear.module.ts create mode 100644 apps/server/src/core/integration/providers/linear/linear.provider.ts create mode 100644 apps/server/src/core/integration/providers/linear/linear.service.spec.ts create mode 100644 apps/server/src/core/integration/providers/linear/linear.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.spec.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.spec.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.spec.ts create mode 100644 apps/server/src/core/integration/unfurl/unfurl.controller.ts create mode 100644 apps/server/src/core/integration/unfurl/unfurl.service.spec.ts create mode 100644 apps/server/src/core/integration/unfurl/unfurl.service.ts create mode 100644 apps/server/src/core/integration/utils/canonical-path.ts create mode 100644 apps/server/src/core/integration/utils/integration.utils.spec.ts create mode 100644 apps/server/src/core/integration/utils/integration.utils.ts create mode 100644 apps/server/src/core/integration/utils/provider-fetch.spec.ts create mode 100644 apps/server/src/core/integration/utils/provider-fetch.ts create mode 100644 apps/server/src/core/integration/utils/refresh-lock.spec.ts create mode 100644 apps/server/src/core/integration/utils/refresh-lock.ts create mode 100644 apps/server/src/core/integration/utils/relative-time.ts create mode 100644 apps/server/src/core/workspace/services/workspace.service.spec.ts create mode 100644 apps/server/src/database/migrations/20260905T126412-integrations.ts create mode 100644 apps/server/src/integrations/encryption/a256cbc-hs512.spec.ts create mode 100644 apps/server/src/integrations/encryption/a256cbc-hs512.ts create mode 100644 packages/editor-ext/src/lib/integration-link/index.ts create mode 100644 packages/editor-ext/src/lib/integration-link/integration-card.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 6c5756fa..0f8b37d9 100644 --- a/.env.example +++ b/.env.example @@ -68,3 +68,5 @@ DEBUG_DB=false # Log http requests LOG_HTTP=false + +# See all environment variables: https://docmost.com/docs/self-hosting/environment-variables diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index a6c18a1f..3f4bde97 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,8 @@ "Can view": "Can view", "Can view pages in space but not edit.": "Can view pages in space but not edit.", "Cancel": "Cancel", + "Card": "Card", + "Cards aren't available in this position": "Cards aren't available in this position", "Change email": "Change email", "Change password": "Change password", "Change photo": "Change photo", @@ -30,6 +33,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,11 +46,25 @@ "Dark": "Dark", "Date": "Date", "Delete": "Delete", + "Initiative": "Initiative", + "Integration link actions": "Integration link actions", + "Last modified by {{name}}": "Last modified by {{name}}", + "Open in Slack": "Open in Slack", + "Open link": "Open link", + "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.", + "This Slack workspace is already connected to another Docmost workspace. Disconnect it there first, or install Docmost into a different Slack workspace.": "This Slack workspace is already connected to another Docmost workspace. Disconnect it there first, or install Docmost into a different Slack workspace.", "Remove from page": "Remove from page", "Base options": "Base options", "Delete group": "Delete group", "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.", "Description": "Description", + "Display as": "Display as", "Details": "Details", "e.g ACME": "e.g. ACME", "e.g ACME Inc": "e.g. ACME Inc", @@ -151,6 +170,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.", @@ -193,6 +214,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", @@ -216,6 +239,7 @@ "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", "Unable to import pages. Please try again.": "Unable to import pages. Please try again.", + "Unassigned": "Unassigned", "untitled": "untitled", "Untitled": "Untitled", "Updated successfully": "Updated successfully", @@ -649,6 +673,8 @@ "Search in all your spaces": "Search in all your spaces", "Type": "Type", "Enterprise": "Enterprise", + "Business": "Business", + "Standard": "Standard", "Download attachment": "Download attachment", "Allowed email domains": "Allowed email domains", "Only users with email addresses from these domains can signup via SSO.": "Only users with email addresses from these domains can sign up via SSO.", @@ -1457,6 +1483,11 @@ "Defaults to this instance's hostname": "Defaults to this instance's hostname", "Delete destination": "Delete destination", "Deleted SIEM destination": "Deleted SIEM destination", + "Integrations": "Integrations", + "Installed integration": "Installed integration", + "Uninstalled integration": "Uninstalled integration", + "Connected integration account": "Connected integration account", + "Disconnected integration account": "Disconnected integration account", "Destination created": "Destination created", "Destination deleted": "Destination deleted", "Destination updated": "Destination updated", @@ -1499,9 +1530,41 @@ "Updated SIEM destination": "Updated SIEM destination", "Verify TLS certificate": "Verify TLS certificate", "e.g. Splunk prod": "e.g. Splunk prod", + "Manage workspace integrations.": "Manage workspace integrations.", + "No integrations available.": "No integrations available.", + "Install": "Install", + "Uninstall": "Uninstall", + "Failed to start installation": "Failed to start installation", + "Integration uninstalled successfully": "Integration uninstalled successfully", + "Failed to uninstall integration": "Failed to uninstall integration", + "Connections": "Connections", + "Manage the apps you have connected to your account.": "Manage the apps you have connected to your account.", + "Connect": "Connect", + "Connected": "Connected", + "Disconnect": "Disconnect", + "Reconnect": "Reconnect", + "Connection expired": "Connection expired", + "Integration disconnected": "Integration disconnected", + "Failed to disconnect integration": "Failed to disconnect integration", + "Failed to start OAuth connection": "Failed to start OAuth connection", + "OAuth connection failed. Please try again.": "OAuth connection failed. Please try again.", + "The account you signed in with does not use your Docmost email address. Sign in to the provider with the account that matches your Docmost email and try again.": "The account you signed in with does not use your Docmost email address. Sign in to the provider with the account that matches your Docmost email and try again.", + "The Slack account you signed in with is not in the Slack workspace connected to Docmost. Sign in to Slack with your account in that workspace and try again.": "The Slack account you signed in with is not in the Slack workspace connected to Docmost. Sign in to Slack with your account in that workspace and try again.", + "Connect your {{name}} account so links you paste show rich previews.": "Connect your {{name}} account so links you paste show rich previews.", + "Connect {{name}} to Docmost": "Connect {{name}} to Docmost", + "Link your Slack account to your Docmost account to:": "Link your Slack account to your Docmost account to:", + "Search and open Docmost pages from Slack": "Search and open Docmost pages from Slack", + "Create pages and save Slack messages to Docmost": "Create pages and save Slack messages to Docmost", + "Ask AI questions about your workspace docs": "Ask AI questions about your workspace docs", + "Get your Docmost notifications as Slack DMs": "Get your Docmost notifications as Slack DMs", + "See previews of Docmost links in Slack and Slack links in Docmost": "See previews of Docmost links in Slack and Slack links in Docmost", + "You will sign in with Slack to confirm which Slack account is yours. It must use the same email as your Docmost account.": "You will sign in with Slack to confirm which Slack account is yours. It must use the same email as your Docmost account.", + "Updated comment": "Updated comment", + "Resolved comment": "Resolved comment", + "Reopened comment": "Reopened comment", "Something went wrong": "Something went wrong", "This page failed to load. Reload to try again.": "This page failed to load. Reload to try again.", - "Reload": "Reload" + "Reload": "Reload", "Tabs": "Tabs", "Insert a multi-tab content block.": "Insert a multi-tab content block.", "Add tab": "Add tab", diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index d5c3a5d1..ac7b410f 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -87,6 +87,12 @@ const AiChat = lazyWithPrefetch(() => import("@/ee/ai-chat/pages/ai-chat.tsx")); const VerifyEmail = lazyWithPrefetch(() => import("@/ee/pages/verify-email.tsx")); const LabelPage = lazyWithPrefetch(() => import("@/pages/label/label-page")); const OAuthConsent = lazyWithPrefetch(() => import("@/ee/oauth/pages/oauth-consent.tsx")); +const Integrations = lazyWithPrefetch( + () => import("@/features/integration/pages/integrations.tsx"), +); +const Connections = lazyWithPrefetch( + () => import("@/features/integration/pages/connections.tsx"), +); export default function App() { const { t } = useTranslation(); @@ -146,6 +152,12 @@ export default function App() { } /> } /> } /> + + } + /> }> } /> @@ -179,6 +191,7 @@ export default function App() { path={"account/api-keys/authorized-apps"} element={} /> + } /> } /> } /> } /> @@ -196,6 +209,7 @@ export default function App() { element={} /> } /> + } /> {!isCloud() && } />} {isCloud() && } />} diff --git a/apps/client/src/components/icons/azure-devops-icon.tsx b/apps/client/src/components/icons/azure-devops-icon.tsx new file mode 100644 index 00000000..02b1b1af --- /dev/null +++ b/apps/client/src/components/icons/azure-devops-icon.tsx @@ -0,0 +1,29 @@ +import { rem } from '@mantine/core'; + +type Props = { + size?: number | string; +}; + +export function AzureDevOpsIcon({ size }: Props) { + return ( + + + + + + + + + + + + + ); +} 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 00000000..fcac089b --- /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 00000000..db155579 --- /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 00000000..e7e32c0c --- /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 0f5fd1ad..5fd03e99 100644 --- a/apps/client/src/components/icons/index.ts +++ b/apps/client/src/components/icons/index.ts @@ -1,10 +1,17 @@ export { AirtableIcon } from "./airtable-icon.tsx"; +export { AzureDevOpsIcon } from "./azure-devops-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 00000000..c7164c14 --- /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 00000000..b44ecce7 --- /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 9d07898a..d62b4ef3 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 00000000..d26e9f2c --- /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 1a1aec85..5039b377 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"; @@ -43,6 +44,7 @@ import AppVersion from "@/components/settings/app-version.tsx"; import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts"; import { useSettingsNavigation } from "@/hooks/use-settings-navigation"; +import { useInstalledIntegrations } from "@/features/integration/queries/integration-query"; type DataItem = { label: string; @@ -51,6 +53,7 @@ type DataItem = { feature?: string; role?: "admin" | "owner"; env?: "cloud" | "selfhosted"; + requiresInstalledIntegration?: boolean; }; type DataGroup = { @@ -74,6 +77,12 @@ const groupedData: DataGroup[] = [ path: "/settings/account/api-keys", feature: Feature.API_KEYS, }, + { + label: "Connections", + icon: IconPlug, + path: "/settings/account/connections", + requiresInstalledIntegration: true, + }, ], }, { @@ -125,6 +134,12 @@ const groupedData: DataGroup[] = [ role: "owner", env: "selfhosted", }, + { + label: "Integrations", + icon: IconPlug, + path: "/settings/integrations", + role: "admin", + }, ], }, { @@ -154,6 +169,8 @@ export default function SettingsSidebar() { setActive(location.pathname); }, [location.pathname]); + const { data: installedIntegrations } = useInstalledIntegrations(); + const hasFeature = (f: string) => entitlements?.features?.includes(f) ?? false; @@ -162,6 +179,9 @@ export default function SettingsSidebar() { if (item.env === "selfhosted" && isCloud()) return false; if (item.role === "admin" && !isAdmin) return false; if (item.role === "owner" && !isOwner) return false; + if (item.requiresInstalledIntegration && !installedIntegrations?.length) { + return false; + } return true; }; diff --git a/apps/client/src/ee/audit/lib/audit-event-labels.ts b/apps/client/src/ee/audit/lib/audit-event-labels.ts index ffa81470..e25850e4 100644 --- a/apps/client/src/ee/audit/lib/audit-event-labels.ts +++ b/apps/client/src/ee/audit/lib/audit-event-labels.ts @@ -34,6 +34,11 @@ export const auditEventLabels: Record = { "api_key.updated": "Updated API key", "api_key.deleted": "Deleted API key", + "integration.installed": "Installed integration", + "integration.uninstalled": "Uninstalled integration", + "integration.connected": "Connected integration account", + "integration.disconnected": "Disconnected integration account", + "scim_token.created": "Created SCIM token", "scim_token.updated": "Updated SCIM token", "scim_token.deleted": "Deleted SCIM token", @@ -52,6 +57,9 @@ export const auditEventLabels: Record = { "group.member_added": "Added group member", "group.member_removed": "Removed group member", + "comment.updated": "Updated comment", + "comment.resolved": "Resolved comment", + "comment.reopened": "Reopened comment", "comment.deleted": "Deleted comment", "attachment.deleted": "Deleted attachment", @@ -147,6 +155,9 @@ export const eventFilterOptions: EventGroup[] = [ { group: "Comment", items: [ + { value: "comment.updated", label: "Updated comment" }, + { value: "comment.resolved", label: "Resolved comment" }, + { value: "comment.reopened", label: "Reopened comment" }, { value: "comment.deleted", label: "Deleted comment" }, ], }, @@ -203,6 +214,15 @@ export const eventFilterOptions: EventGroup[] = [ { value: "api_key.deleted", label: "Deleted API key" }, ], }, + { + group: "Integrations", + items: [ + { value: "integration.installed", label: "Installed integration" }, + { value: "integration.uninstalled", label: "Uninstalled integration" }, + { value: "integration.connected", label: "Connected integration account" }, + { value: "integration.disconnected", label: "Disconnected integration account" }, + ], + }, { group: "SCIM token", items: [ diff --git a/apps/client/src/ee/features.ts b/apps/client/src/ee/features.ts index 87bb559d..437c8cfe 100644 --- a/apps/client/src/ee/features.ts +++ b/apps/client/src/ee/features.ts @@ -22,6 +22,8 @@ export const Feature = { PERSONAL_SPACES: 'spaces:personal', DOCX_EXPORT: 'export:docx', BASES: 'bases', + INTEGRATIONS: 'integrations', + INTEGRATIONS_ADVANCED: 'integrations:advanced', OAUTH: 'oauth', AI_CONTROLS: 'ai:controls', MCP_CONTROLS: 'mcp:controls', diff --git a/apps/client/src/ee/security/components/allowed-domains.tsx b/apps/client/src/ee/security/components/allowed-domains.tsx index 8c22b001..49dbf48d 100644 --- a/apps/client/src/ee/security/components/allowed-domains.tsx +++ b/apps/client/src/ee/security/components/allowed-domains.tsx @@ -35,6 +35,7 @@ export default function AllowedDomains() { emailDomains: data.emailDomains, }); setWorkspace(updatedWorkspace); + form.setValues({ emailDomains: updatedWorkspace.emailDomains }); notifications.show({ message: t("Updated successfully"), diff --git a/apps/client/src/features/editor/components/common/editor-paste-handler.test.ts b/apps/client/src/features/editor/components/common/editor-paste-handler.test.ts new file mode 100644 index 00000000..17971bfc --- /dev/null +++ b/apps/client/src/features/editor/components/common/editor-paste-handler.test.ts @@ -0,0 +1,342 @@ +import { IntegrationCard, IntegrationMention } from "@docmost/editor-ext"; +import { Editor, Node, type JSONContent } from "@tiptap/core"; +import { StarterKit } from "@tiptap/starter-kit"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { queryClient } from "@/main.tsx"; +import { TiptapDocument } from "@/features/editor/extensions/document"; +import { + IntegrationPasteMenuExtension, + integrationPasteMenuKey, +} from "@/features/editor/extensions/integration-paste-menu"; +import { + convertIntegrationDisplay, + type IntegrationDisplay, +} from "@/features/editor/components/integration-link/integration-display"; +import { handlePaste } from "./editor-paste-handler"; + +vi.mock("@/main.tsx", async () => { + const { QueryClient } = await import("@tanstack/react-query"); + return { queryClient: new QueryClient() }; +}); + +const PR_URL = "https://github.com/org/repo/pull/12"; +const text = (value: string, marks?: JSONContent["marks"]): JSONContent => ({ + type: "text", + text: value, + ...(marks && { marks }), +}); +const paragraph = (...content: JSONContent[]): JSONContent => ({ + type: "paragraph", + ...(content.length && { content }), +}); +const card = (): JSONContent => ({ + type: "integrationCard", + attrs: { url: PR_URL, provider: "github" }, +}); +const mention = (): JSONContent => ({ + type: "integrationMention", + attrs: { url: PR_URL, provider: "github" }, +}); + +// The real footnote nodes bring plugins that clash with editor-ext's CJS build in vitest. +const Footnotes = Node.create({ + name: "footnotes", + group: "", + content: "footnote*", + isolating: true, + renderHTML() { + return ["ol", { class: "footnotes" }, 0]; + }, +}); + +const Footnote = Node.create({ + name: "footnote", + content: "paragraph+", + isolating: true, + renderHTML() { + return ["li", 0]; + }, +}); + +let editor: Editor; + +function createEditor(content: object[], cursor: number) { + editor = new Editor({ + extensions: [ + TiptapDocument, + StarterKit.configure({ document: false }), + Footnotes, + Footnote, + IntegrationCard, + IntegrationMention, + IntegrationPasteMenuExtension, + ], + content: { type: "doc", content }, + }); + editor.commands.setTextSelection(cursor); + return editor; +} + +function paste(text: string, html = "") { + const event = { + clipboardData: { + getData: (type: string) => + type === "text/plain" ? text : type === "text/html" ? html : "", + files: [], + }, + preventDefault: vi.fn(), + }; + const handled = handlePaste( + editor, + event as unknown as ClipboardEvent, + "page-id", + ); + return { handled, preventDefault: event.preventDefault }; +} + +function choosePasteOption(display: IntegrationDisplay) { + const { pos, joinBefore, joinAfter } = integrationPasteMenuKey.getState( + editor.state, + ); + return convertIntegrationDisplay( + editor, + { kind: "node", pos, joinBefore, joinAfter }, + display, + ); +} + +function hasIntegrationCard() { + let found = false; + editor.state.doc.descendants((node) => { + if (node.type.name === "integrationCard") found = true; + }); + return found; +} + +function expectPassThrough(text = PR_URL, html = "") { + const before = editor.getJSON(); + const { handled, preventDefault } = paste(text, html); + + expect(handled).toBe(false); + expect(preventDefault).not.toHaveBeenCalled(); + expect(hasIntegrationCard()).toBe(false); + expect(editor.getJSON()).toEqual(before); +} + +describe("handlePaste integration links", () => { + beforeEach(() => { + queryClient.setQueryData( + ["installed-integrations"], + [{ type: "github", unfurlHosts: ["github.com"] }], + ); + }); + + afterEach(() => { + editor?.destroy(); + queryClient.clear(); + }); + + it("inserts a card in a paragraph when the provider is installed", () => { + createEditor([paragraph(text("Hello"))], 6); + + const { handled, preventDefault } = paste(PR_URL); + + expect(handled).toBe(true); + expect(preventDefault).toHaveBeenCalled(); + const pasteMenu = integrationPasteMenuKey.getState(editor.state); + expect(pasteMenu).not.toBeNull(); + expect(editor.state.doc.nodeAt(pasteMenu.pos)?.type.name).toBe( + "integrationCard", + ); + expect(editor.state.doc.nodeAt(pasteMenu.pos)?.attrs).toMatchObject({ + url: PR_URL, + provider: "github", + }); + }); + + it("pastes a GitHub-like path on a host GitHub does not serve as a link", () => { + createEditor([paragraph(text("Hello"))], 6); + + expectPassThrough("https://gitea.example.com/team/app/pulls/7"); + }); + + it.each(["integrationCard", "integrationMention"])( + "leaves a copied %s to ProseMirror instead of pasting its URL as a card", + (type) => { + createEditor([paragraph(text("Hello world"))], 7); + + expectPassThrough( + PR_URL, + `

`, + ); + }, + ); + + it("inserts a card for a repo root on a GitHub Enterprise host", () => { + queryClient.setQueryData( + ["installed-integrations"], + [{ type: "github", unfurlHosts: ["github.acme.com"] }], + ); + createEditor([paragraph(text("Hello"))], 6); + + const { handled } = paste("https://github.acme.com/org/repo"); + + expect(handled).toBe(true); + expect(hasIntegrationCard()).toBe(true); + }); + + it("splits the paragraph around a card pasted mid-sentence", () => { + createEditor([paragraph(text("Hello world"))], 7); + + const { handled, preventDefault } = paste(PR_URL); + + expect(handled).toBe(true); + expect(preventDefault).toHaveBeenCalled(); + expect(editor.getJSON().content).toEqual([ + paragraph(text("Hello ")), + card(), + paragraph(text("world")), + ]); + }); + + it("rejoins the sentence when a mid-sentence paste becomes a mention", () => { + createEditor([paragraph(text("Fix in today"))], 8); + paste(PR_URL); + + expect(choosePasteOption("mention")).toBe(true); + expect(editor.getJSON().content).toEqual([ + paragraph(text("Fix in "), mention(), text(" today")), + ]); + expect(editor.state.selection.from).toBe(10); + }); + + it("adds no second space when the rest of the sentence starts with one", () => { + createEditor([paragraph(text("Fix in today"))], 7); + paste(PR_URL); + + expect(choosePasteOption("mention")).toBe(true); + expect(editor.getJSON().content).toEqual([ + paragraph(text("Fix in"), mention(), text(" today")), + ]); + }); + + it("rejoins the sentence when a mid-sentence paste becomes a URL", () => { + createEditor([paragraph(text("Fix in today"))], 8); + paste(PR_URL); + + expect(choosePasteOption("url")).toBe(true); + expect(editor.getJSON().content).toHaveLength(1); + expect(editor.getJSON().content?.[0]).toMatchObject( + paragraph( + text("Fix in "), + text(PR_URL, [{ type: "link", attrs: { href: PR_URL } }]), + text("today"), + ), + ); + }); + + it("keeps an end-of-paragraph paste turned into a mention in its paragraph", () => { + createEditor([paragraph(text("Fix in ")), paragraph(text("Next"))], 8); + paste(PR_URL); + + expect(choosePasteOption("mention")).toBe(true); + expect(editor.getJSON().content).toEqual([ + paragraph(text("Fix in "), mention(), text(" ")), + paragraph(text("Next")), + ]); + }); + + it("joins a start-of-paragraph paste turned into a mention with the rest of the line", () => { + createEditor([paragraph(text("today"))], 1); + paste(PR_URL); + + expect(choosePasteOption("mention")).toBe(true); + expect(editor.getJSON().content).toEqual([ + paragraph(mention(), text(" today")), + ]); + }); + + it("keeps a paste on an empty line in its own paragraph when it becomes a mention", () => { + createEditor( + [paragraph(text("Before")), paragraph(), paragraph(text("After"))], + 9, + ); + paste(PR_URL); + + expect(choosePasteOption("mention")).toBe(true); + expect(editor.getJSON().content).toEqual([ + paragraph(text("Before")), + paragraph(mention(), text(" ")), + paragraph(text("After")), + ]); + }); + + it("pastes a mention inside a footnote, which cannot hold a card", () => { + createEditor( + [ + paragraph(text("Body")), + { + type: "footnotes", + content: [ + { + type: "footnote", + content: [paragraph(text("See "))], + }, + ], + }, + ], + 13, + ); + + const { handled, preventDefault } = paste(PR_URL); + + expect(handled).toBe(true); + expect(preventDefault).toHaveBeenCalled(); + expect(hasIntegrationCard()).toBe(false); + expect(editor.getJSON().content?.[1]).toMatchObject({ + type: "footnotes", + content: [ + { + type: "footnote", + content: [paragraph(text("See "), mention())], + }, + ], + }); + }); + + it("leaves a code block paste to the default handler", () => { + createEditor( + [ + { + type: "codeBlock", + content: [text("const a = 1;")], + }, + ], + 4, + ); + + expectPassThrough(); + }); + + it("leaves a heading paste to the default handler", () => { + createEditor( + [ + { + type: "heading", + attrs: { level: 2 }, + content: [text("Title")], + }, + ], + 3, + ); + + expectPassThrough(); + }); + + it("leaves an inline code paste to the default handler", () => { + createEditor([paragraph(text("run "), text("cmd", [{ type: "code" }]))], 6); + + expect(editor.isActive("code")).toBe(true); + expectPassThrough(); + }); +}); 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 b01e2b81..642d05a9 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,11 @@ 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 { canPlaceIntegrationCard } from "@/features/editor/components/integration-link/integration-display"; +import { queryClient } from "@/main.tsx"; +import { Integration } from "@/features/integration/types/integration.types"; import { getAttachmentInfo, uploadFile, @@ -22,6 +27,24 @@ const ATTACHMENT_NODE_TYPES = [ const ATTACHMENT_URL_RE = /\/api\/files\/([0-9a-f-]+)\//; +// copied cards and mentions paste back as nodes +const INTEGRATION_NODE_HTML_RE = /data-type="integration(Card|Mention)"/; + +// Reads the cache the page editor prefetches; a cold cache pastes an ordinary link. +function matchInstalledIntegrationLink(url: string) { + const installed = + queryClient.getQueryData(["installed-integrations"]) ?? []; + const unfurlHosts = Object.fromEntries( + installed + .filter((integration) => integration.unfurlHosts) + .map((integration) => [integration.type, integration.unfurlHosts]), + ); + const match = matchIntegrationLink(url, unfurlHosts); + return match && installed.some((i) => i.type === match.provider) + ? match + : null; +} + export const handlePaste = ( editor: Editor, event: ClipboardEvent, @@ -30,6 +53,84 @@ export const handlePaste = ( ) => { const clipboardData = event.clipboardData.getData("text/plain"); + const integrationMatch = matchInstalledIntegrationLink(clipboardData.trim()); + if ( + integrationMatch && + !INTEGRATION_NODE_HTML_RE.test(event.clipboardData.getData("text/html")) && + editor.state.selection.empty && + editor.state.selection.$from.parent.type.name === "paragraph" && + !editor.isActive("code") + ) { + const pastedUrl = clipboardData.trim(); + const { $from } = editor.state.selection; + + if (canPlaceIntegrationCard(editor, $from.pos)) { + event.preventDefault(); + editor + .chain() + .focus() + .setIntegrationCard({ + url: pastedUrl, + provider: integrationMatch.provider, + }) + // Set the meta in this transaction (BubbleMenu ignores meta-only ones) and find the node by changed range, not url. + .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 === "integrationCard") { + pastedPos = pos; + } + }, + ); + if (pastedPos !== null) { + tr.setMeta(integrationPasteMenuKey, { + pos: pastedPos, + joinBefore: $from.parentOffset > 0, + joinAfter: $from.parentOffset < $from.parent.content.size, + }); + } + return true; + }) + .run(); + return true; + } + + // Containers such as footnotes only hold paragraphs, but a mention still fits. + const mentionType = editor.schema.nodes.integrationMention; + if ( + mentionType && + $from.parent.canReplaceWith($from.index(), $from.index(), mentionType) + ) { + event.preventDefault(); + editor + .chain() + .focus() + .setIntegrationMention({ + url: pastedUrl, + provider: integrationMatch.provider, + }) + .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 00000000..a0caad00 --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/badge-color.ts @@ -0,0 +1,50 @@ +// Light-scheme badge text; hexes replace shade 9 for hues that miss AA 4.5:1. +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"; + } + // Only palette names with a contrast-checked text colour pass through. + return Object.prototype.hasOwnProperty.call(BADGE_TEXT_LIGHT, raw) + ? raw + : "gray"; +} diff --git a/apps/client/src/features/editor/components/integration-link/integration-card-view.module.css b/apps/client/src/features/editor/components/integration-link/integration-card-view.module.css new file mode 100644 index 00000000..76f0189e --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/integration-card-view.module.css @@ -0,0 +1,27 @@ +.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); +} + diff --git a/apps/client/src/features/editor/components/integration-link/integration-card-view.tsx b/apps/client/src/features/editor/components/integration-link/integration-card-view.tsx new file mode 100644 index 00000000..6e21f5ed --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/integration-card-view.tsx @@ -0,0 +1,586 @@ +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, isHttpUrl } 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-card-view.module.css"; + +const SLACK_TEXT_CLAMP_LINES = 4; + +function SlackMessageCard({ + url, + unfurlData, + onSelect, +}: { + url: string; + unfurlData: Record; + onSelect: React.MouseEventHandler; +}) { + 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, + onSelect, +}: { + url: string; + unfurlData: Record; + onSelect: React.MouseEventHandler; +}) { + 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, + onSelect, +}: { + url: string; + unfurlData: Record; + onSelect: React.MouseEventHandler; +}) { + const { t } = useTranslation(); + // Figma thumbnail urls are pre-signed and expire. + 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 IntegrationCardView(props: any) { + const { editor, getPos, node } = props; + const { url, provider } = node.attrs; + const safeUrl = isHttpUrl(url) ? url : ""; + const { t } = useTranslation(); + + const unfurl = useUnfurl(safeUrl); + const [connecting, setConnecting] = useState(false); + + const needsConnection = + unfurl.state === "needsConnection" ? unfurl.needsConnection : null; + + const selectForEditing = useCallback( + (event: React.MouseEvent) => { + if (!editor.isEditable || event.metaKey || event.ctrlKey) return; + + const target = event.target as Element; + if ( + target.closest( + "button, [role='button'], [data-integration-action='open']", + ) + ) { + return; + } + + const pos = getPos(); + if (typeof pos !== "number") return; + + event.preventDefault(); + event.stopPropagation(); + editor + .chain() + .focus(undefined, { scrollIntoView: false }) + .setNodeSelection(pos) + .run(); + }, + [editor, getPos], + ); + + 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} + + )} + + + +
+
+
+ ); + } + + if (unfurl.state === "loading") { + return ( + + + + + + + + + + + + ); + } + + if (unfurl.state !== "loaded") { + // Without unfurl data, describe the link from its url. + const described = describeIntegrationLink(safeUrl); + if (described) { + return ( + + + +
+ {getIntegrationIcon(provider, 28)} +
+ + + {described.title} + + {described.description && ( + + {described.description} + + )} + +
+
+
+ ); + } + return ( + + + {safeUrl ? ( + + {safeUrl} + + ) : ( + {url} + )} + + + ); + } + + const unfurlData = unfurl.data; + + // A message unfurl without metadata.type is identified by metadata.ts. + 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(IntegrationCardView); diff --git a/apps/client/src/features/editor/components/integration-link/integration-display-picker.module.css b/apps/client/src/features/editor/components/integration-link/integration-display-picker.module.css new file mode 100644 index 00000000..6391ae3c --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/integration-display-picker.module.css @@ -0,0 +1,191 @@ +.picker { + display: inline-flex; + align-items: center; + gap: 2px; + flex: 0 0 auto; +} + +.option { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + min-width: 32px; + height: 32px; + padding: 0; + border-radius: 6px; + color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-3)); + background: transparent; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: + color 120ms ease, + background-color 120ms ease; +} + +.option svg { + width: 18px; + height: 18px; +} + +.option:hover:not([aria-disabled="true"]):not([aria-checked="true"]) { + background: light-dark( + var(--mantine-color-gray-0), + var(--mantine-color-dark-5) + ); +} + +.option[aria-checked="true"] { + color: light-dark(var(--mantine-color-blue-7), var(--mantine-color-blue-3)); + background: light-dark( + var(--mantine-color-blue-0), + color-mix(in srgb, var(--mantine-color-blue-9) 55%, transparent) + ); +} + +.option:focus-visible { + outline: 2px solid var(--mantine-color-blue-6); + outline-offset: -2px; +} + +.option[aria-disabled="true"] { + opacity: 0.42; + cursor: not-allowed; +} + +.menuTrigger { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 1px; + width: 40px; + min-width: 40px; + height: 32px; + padding: 0 4px; + border-radius: 6px; + color: light-dark(var(--mantine-color-blue-7), var(--mantine-color-blue-3)); + background: light-dark( + var(--mantine-color-blue-0), + color-mix(in srgb, var(--mantine-color-blue-9) 55%, transparent) + ); + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: + color 120ms ease, + background-color 120ms ease; +} + +.menuTrigger:hover { + background: light-dark( + var(--mantine-color-blue-1), + color-mix(in srgb, var(--mantine-color-blue-8) 70%, transparent) + ); +} + +.menuTrigger:focus-visible { + outline: 2px solid var(--mantine-color-blue-6); + outline-offset: -2px; +} + +.menuChevron { + margin-inline-end: -2px; +} + +.menuDropdown { + border-color: light-dark( + var(--mantine-color-gray-3), + var(--mantine-color-dark-4) + ); + border-radius: 9px; +} + +.menuLabel { + padding: 5px 9px 4px; + color: var(--mantine-color-dimmed); + font-size: var(--mantine-font-size-xs); + font-weight: 600; +} + +.menuItem { + display: grid; + grid-template-columns: 18px minmax(0, 1fr) 16px; + align-items: center; + gap: 9px; + width: 100%; + min-height: 36px; + padding: 6px 9px; + border-radius: 6px; + color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-gray-2)); + font-size: var(--mantine-font-size-sm); + text-align: start; + cursor: pointer; +} + +.menuItem:hover:not(:disabled) { + background: light-dark( + var(--mantine-color-gray-0), + var(--mantine-color-dark-5) + ); +} + +.menuItem[aria-checked="true"] { + color: light-dark(var(--mantine-color-blue-7), var(--mantine-color-blue-3)); + background: light-dark( + var(--mantine-color-blue-0), + color-mix(in srgb, var(--mantine-color-blue-9) 45%, transparent) + ); +} + +.menuItem:focus-visible { + outline: 2px solid var(--mantine-color-blue-6); + outline-offset: -2px; +} + +.menuItem:disabled { + opacity: 0.44; + cursor: not-allowed; +} + +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@media (prefers-reduced-motion: reduce) { + .option, + .menuTrigger { + transition: none; + } +} + +@media (pointer: coarse) { + .option { + width: 44px; + min-width: 44px; + height: 44px; + border-radius: 8px; + } + + .option svg { + width: 21px; + height: 21px; + } + + .menuTrigger { + width: 50px; + min-width: 50px; + height: 44px; + border-radius: 8px; + } + + .menuItem { + min-height: 44px; + } +} diff --git a/apps/client/src/features/editor/components/integration-link/integration-display-picker.test.tsx b/apps/client/src/features/editor/components/integration-link/integration-display-picker.test.tsx new file mode 100644 index 00000000..3c7aab01 --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/integration-display-picker.test.tsx @@ -0,0 +1,188 @@ +import { MantineProvider } from "@mantine/core"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + IntegrationDisplayMenu, + IntegrationDisplayPicker, +} from "./integration-display-picker"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (value: string) => value }), +})); + +function setMediaMatch() { + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + +function renderPicker( + props: Partial> = {}, +) { + const onChange = vi.fn(); + render( + + + , + ); + return { onChange }; +} + +function renderMenu( + props: Partial> = {}, +) { + const onChange = vi.fn(); + render( + + + , + ); + return { onChange }; +} + +describe("IntegrationDisplayPicker", () => { + beforeEach(setMediaMatch); + afterEach(cleanup); + + it("exposes every display mode directly and identifies the selected mode", () => { + renderPicker({ current: "mention" }); + + expect(screen.getByRole("radiogroup", { name: "Display as" })).toBeTruthy(); + + const card = screen.getByRole("radio", { name: "Card" }); + const mention = screen.getByRole("radio", { name: "Mention" }); + const url = screen.getByRole("radio", { name: "URL" }); + + expect(card.getAttribute("aria-checked")).toBe("false"); + expect(card.getAttribute("tabindex")).toBe("-1"); + expect(mention.getAttribute("aria-checked")).toBe("true"); + expect(mention.getAttribute("tabindex")).toBe("0"); + expect(url.getAttribute("aria-checked")).toBe("false"); + }); + + it("changes mode with standard radio-group arrow navigation", () => { + const { onChange } = renderPicker({ current: "card" }); + const card = screen.getByRole("radio", { name: "Card" }); + + card.focus(); + fireEvent.keyDown(card, { key: "ArrowRight" }); + + expect(document.activeElement).toBe( + screen.getByRole("radio", { name: "Mention" }), + ); + expect(onChange).toHaveBeenCalledWith("mention"); + }); + + it("keeps an unavailable card understandable and skips it during navigation", () => { + const { onChange } = renderPicker({ + current: "url", + canUseCard: false, + }); + + const card = screen.getByRole("radio", { name: "Card" }); + expect(card.getAttribute("aria-disabled")).toBe("true"); + const reasonId = card.getAttribute("aria-describedby"); + expect(reasonId).toBeTruthy(); + expect(document.getElementById(reasonId!)?.textContent).toBe( + "Cards aren't available in this position", + ); + + fireEvent.click(card); + expect(onChange).not.toHaveBeenCalled(); + + const url = screen.getByRole("radio", { name: "URL" }); + url.focus(); + fireEvent.keyDown(url, { key: "ArrowRight" }); + expect(document.activeElement).toBe( + screen.getByRole("radio", { name: "Mention" }), + ); + expect(onChange).toHaveBeenCalledWith("mention"); + }); + + it("reports activation of the current mode so the paste nudge can close", () => { + const { onChange } = renderPicker({ current: "card" }); + + fireEvent.click(screen.getByRole("radio", { name: "Card" })); + + expect(onChange).toHaveBeenCalledWith("card"); + }); +}); + +describe("IntegrationDisplayMenu", () => { + beforeEach(setMediaMatch); + afterEach(cleanup); + + it("identifies the current display and changes it from the dropdown", async () => { + const { onChange } = renderMenu(); + const trigger = screen.getByRole("button", { name: "Display as: URL" }); + + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + fireEvent.click(trigger); + + expect(trigger.getAttribute("aria-expanded")).toBe("true"); + expect( + ( + await screen.findByRole("menuitemradio", { + name: "URL", + hidden: true, + }) + ).getAttribute("aria-checked"), + ).toBe("true"); + + fireEvent.click( + screen.getByRole("menuitemradio", { name: "Mention", hidden: true }), + ); + expect(onChange).toHaveBeenCalledWith("mention"); + }); + + it("supports keyboard opening, option navigation, and Escape", async () => { + renderMenu({ canUseCard: false }); + const trigger = screen.getByRole("button", { name: "Display as: URL" }); + + trigger.focus(); + fireEvent.keyDown(trigger, { key: "ArrowDown" }); + + const url = await screen.findByRole("menuitemradio", { + name: "URL", + hidden: true, + }); + await waitFor(() => expect(document.activeElement).toBe(url)); + + fireEvent.keyDown(url, { key: "ArrowDown" }); + expect(document.activeElement).toBe( + screen.getByRole("menuitemradio", { name: "Mention", hidden: true }), + ); + + fireEvent.keyDown(document.activeElement!, { key: "Escape" }); + await waitFor(() => expect(document.activeElement).toBe(trigger)); + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + }); +}); diff --git a/apps/client/src/features/editor/components/integration-link/integration-display-picker.tsx b/apps/client/src/features/editor/components/integration-link/integration-display-picker.tsx new file mode 100644 index 00000000..45bc4782 --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/integration-display-picker.tsx @@ -0,0 +1,366 @@ +import { Menu, Tooltip, UnstyledButton } from "@mantine/core"; +import { + IconAt, + IconCheck, + IconChevronDown, + IconLink, + type IconProps, +} from "@tabler/icons-react"; +import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react"; +import { useTranslation } from "react-i18next"; +import type { IntegrationDisplay } from "./integration-display"; +import classes from "./integration-display-picker.module.css"; + +export type IntegrationDisplayPickerProps = { + current: IntegrationDisplay; + label: string; + canUseCard: boolean; + onChange: (display: IntegrationDisplay) => void; +}; + +const DISPLAY_ORDER: IntegrationDisplay[] = ["card", "mention", "url"]; + +const DISPLAY_ICONS = { + card: CardDisplayIcon, + mention: IconAt, + url: IconLink, +} as const; + +function CardDisplayIcon({ size = 18, stroke = 1.75, ...props }: IconProps) { + return ( + + + + + + ); +} + +function useDisplayLabels() { + const { t } = useTranslation(); + + return { + labels: { + card: t("Card"), + mention: t("Mention"), + url: t("URL"), + } satisfies Record, + getCardUnavailableLabel: () => t("Cards aren't available in this position"), + }; +} + +export function IntegrationDisplayPicker({ + current, + label, + canUseCard, + onChange, +}: IntegrationDisplayPickerProps) { + const { labels, getCardUnavailableLabel } = useDisplayLabels(); + const cardReasonId = useId(); + const optionRefs = useRef< + Record + >({ + card: null, + mention: null, + url: null, + }); + + const cardUnavailableLabel = getCardUnavailableLabel(); + + const isUnavailable = (value: IntegrationDisplay) => + value === "card" && !canUseCard; + + const choose = (value: IntegrationDisplay) => { + if (isUnavailable(value)) return; + onChange(value); + }; + + const handleKeyDown = ( + event: KeyboardEvent, + value: IntegrationDisplay, + ) => { + if ( + ![ + "ArrowLeft", + "ArrowRight", + "ArrowUp", + "ArrowDown", + "Home", + "End", + ].includes(event.key) + ) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + const available = DISPLAY_ORDER.filter( + (display) => !isUnavailable(display), + ); + const currentIndex = available.indexOf(value); + const nextIndex = + event.key === "Home" + ? 0 + : event.key === "End" + ? available.length - 1 + : ["ArrowRight", "ArrowDown"].includes(event.key) + ? (currentIndex + 1 + available.length) % available.length + : (currentIndex - 1 + available.length) % available.length; + const next = available[nextIndex]; + if (!next) return; + + optionRefs.current[next]?.focus(); + onChange(next); + }; + + return ( +
+ {DISPLAY_ORDER.map((value) => { + const Icon = DISPLAY_ICONS[value]; + const selected = value === current; + const unavailable = isUnavailable(value); + const tooltipLabel = unavailable ? cardUnavailableLabel : labels[value]; + + return ( + + { + optionRefs.current[value] = element; + }} + type="button" + role="radio" + aria-label={labels[value]} + aria-checked={selected} + aria-disabled={unavailable || undefined} + aria-describedby={unavailable ? cardReasonId : undefined} + tabIndex={selected ? 0 : -1} + className={classes.option} + onMouseDown={(event) => { + event.preventDefault(); + event.stopPropagation(); + }} + onClick={(event) => { + event.stopPropagation(); + choose(value); + }} + onKeyDown={(event) => handleKeyDown(event, value)} + > + + + ); + })} + + {!canUseCard ? ( + + {cardUnavailableLabel} + + ) : null} +
+ ); +} + +export function IntegrationDisplayMenu({ + current, + label, + canUseCard, + onChange, +}: IntegrationDisplayPickerProps) { + const { labels, getCardUnavailableLabel } = useDisplayLabels(); + const [opened, setOpened] = useState(false); + const cardReasonId = useId(); + const triggerRef = useRef(null); + const optionRefs = useRef< + Record + >({ + card: null, + mention: null, + url: null, + }); + const CurrentIcon = DISPLAY_ICONS[current]; + const cardUnavailableLabel = getCardUnavailableLabel(); + const availableOptions = DISPLAY_ORDER.filter( + (value) => value !== "card" || canUseCard, + ); + const initialOption = availableOptions.includes(current) + ? current + : availableOptions[0]; + + useEffect(() => { + if (!opened) return; + + let focusFrame = 0; + const layoutFrame = requestAnimationFrame(() => { + focusFrame = requestAnimationFrame(() => { + if (initialOption) optionRefs.current[initialOption]?.focus(); + }); + }); + + return () => { + cancelAnimationFrame(layoutFrame); + cancelAnimationFrame(focusFrame); + }; + }, [initialOption, opened]); + + const openFromKeyboard = () => { + setOpened(true); + }; + + const closeAndRestoreFocus = () => { + setOpened(false); + triggerRef.current?.focus(); + }; + + const handleOptionKeyDown = ( + event: KeyboardEvent, + value: IntegrationDisplay, + ) => { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + closeAndRestoreFocus(); + return; + } + + if (!["ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + const currentIndex = availableOptions.indexOf(value); + const nextIndex = + event.key === "Home" + ? 0 + : event.key === "End" + ? availableOptions.length - 1 + : event.key === "ArrowDown" + ? (currentIndex + 1 + availableOptions.length) % + availableOptions.length + : (currentIndex - 1 + availableOptions.length) % + availableOptions.length; + const next = availableOptions[nextIndex]; + if (next) optionRefs.current[next]?.focus(); + }; + + return ( + + + { + event.preventDefault(); + event.stopPropagation(); + }} + onClick={(event) => { + event.stopPropagation(); + }} + onKeyDown={(event) => { + if ( + !opened && + ["ArrowDown", "ArrowUp", "Enter", " "].includes(event.key) + ) { + event.preventDefault(); + event.stopPropagation(); + openFromKeyboard(); + } + }} + > + + + + event.stopPropagation()} + > +
{label}
+ {DISPLAY_ORDER.map((value) => { + const Icon = DISPLAY_ICONS[value]; + const selected = value === current; + const unavailable = value === "card" && !canUseCard; + + return ( + { + optionRefs.current[value] = element; + }} + type="button" + role="menuitemradio" + aria-checked={selected} + aria-describedby={unavailable ? cardReasonId : undefined} + disabled={unavailable} + className={classes.menuItem} + onMouseDown={(event) => { + event.preventDefault(); + event.stopPropagation(); + }} + onClick={(event) => { + event.stopPropagation(); + setOpened(false); + onChange(value); + }} + onKeyDown={(event) => handleOptionKeyDown(event, value)} + > + + ); + })} + + {!canUseCard ? ( + + {cardUnavailableLabel} + + ) : null} +
+
+ ); +} diff --git a/apps/client/src/features/editor/components/integration-link/integration-display.test.ts b/apps/client/src/features/editor/components/integration-link/integration-display.test.ts new file mode 100644 index 00000000..07933322 --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/integration-display.test.ts @@ -0,0 +1,290 @@ +import { IntegrationCard, IntegrationMention } from "@docmost/editor-ext"; +import { Editor, type JSONContent } from "@tiptap/core"; +import TiptapLink from "@tiptap/extension-link"; +import { StarterKit } from "@tiptap/starter-kit"; +import { afterEach, describe, expect, it } from "vitest"; +import { + convertIntegrationDisplay, + getIntegrationCardAvailability, + getSelectedIntegrationDisplay, +} from "./integration-display"; + +const url = "https://github.com/docmost/docmost/pull/2475"; +const text = (value: string, marks?: JSONContent["marks"]): JSONContent => ({ + type: "text", + text: value, + ...(marks && { marks }), +}); +const paragraph = (...content: JSONContent[]): JSONContent => ({ + type: "paragraph", + ...(content.length && { content }), +}); +const card = (): JSONContent => ({ + type: "integrationCard", + attrs: { url, provider: "github" }, +}); +const mention = (): JSONContent => ({ + type: "integrationMention", + attrs: { url, provider: "github" }, +}); + +const IntegrationAwareLink = TiptapLink.extend({ + addAttributes() { + return { + ...this.parent?.(), + integrationProvider: { default: null }, + }; + }, +}); + +let activeEditor: Editor; +afterEach(() => activeEditor?.destroy()); + +function createEditor(...content: JSONContent[]) { + activeEditor = new Editor({ + extensions: [ + StarterKit.configure({ link: false }), + IntegrationAwareLink, + IntegrationCard, + IntegrationMention, + ], + content: { type: "doc", content }, + }); + return activeEditor; +} + +describe("convertIntegrationDisplay", () => { + it("turns a card into an inline mention without losing its source", () => { + const editor = createEditor(card()); + + expect( + convertIntegrationDisplay(editor, { kind: "node", pos: 0 }, "mention"), + ).toBe(true); + expect(editor.getJSON()).toEqual({ + type: "doc", + content: [paragraph(mention(), text(" "))], + }); + }); + + it("turns a card into a normal linked URL", () => { + const editor = createEditor(card()); + + expect( + convertIntegrationDisplay(editor, { kind: "node", pos: 0 }, "url"), + ).toBe(true); + expect(editor.getJSON().content?.[0]).toMatchObject( + paragraph( + text(url, [ + { + type: "link", + attrs: { href: url, integrationProvider: "github" }, + }, + ]), + ), + ); + }); + + it("keeps the paragraphs around a card apart when it becomes a mention", () => { + const editor = createEditor( + paragraph(text("Intro")), + card(), + paragraph(text("Outro")), + ); + + expect( + convertIntegrationDisplay(editor, { kind: "node", pos: 7 }, "mention"), + ).toBe(true); + expect(editor.getJSON().content).toEqual([ + paragraph(text("Intro")), + paragraph(mention(), text(" ")), + paragraph(text("Outro")), + ]); + }); + + it("turns a standalone mention back into a card and ignores surrounding whitespace", () => { + const editor = createEditor(paragraph(text(" "), mention(), text(" "))); + + expect( + convertIntegrationDisplay(editor, { kind: "node", pos: 2 }, "card"), + ).toBe(true); + expect(editor.getJSON().content?.[0]).toEqual(card()); + }); + + it("splits inline prose around a mention converted to a card", () => { + const editor = createEditor( + paragraph(text("Review "), mention(), text(" before release.")), + ); + + expect( + convertIntegrationDisplay(editor, { kind: "node", pos: 8 }, "card"), + ).toBe(true); + expect(editor.getJSON()).toEqual({ + type: "doc", + content: [ + paragraph(text("Review")), + card(), + paragraph(text("before release.")), + ], + }); + }); + + it("turns a mention inside prose into a URL without disturbing the sentence", () => { + const editor = createEditor( + paragraph(text("Review "), mention(), text(" before release.")), + ); + + expect( + convertIntegrationDisplay(editor, { kind: "node", pos: 8 }, "url"), + ).toBe(true); + expect(editor.getText()).toBe(`Review ${url} before release.`); + expect(editor.getJSON().content?.[0]?.content?.[1]).toMatchObject( + text(url, [ + { + type: "link", + attrs: { href: url, integrationProvider: "github" }, + }, + ]), + ); + }); + + it("turns an existing linked URL into a mention in place", () => { + const from = 8; + const editor = createEditor( + paragraph( + text("Review "), + text(url, [{ type: "link", attrs: { href: url } }]), + text(" before release."), + ), + ); + + expect( + convertIntegrationDisplay( + editor, + { kind: "link", from, to: from + url.length, url, provider: "github" }, + "mention", + ), + ).toBe(true); + expect(editor.getJSON().content?.[0]?.content?.[1]).toEqual(mention()); + expect(editor.getText()).toBe(`Review ${url} before release.`); + }); + + it("converts an entire link when inline formatting splits it into text nodes", () => { + const splitAt = 19; + const editor = createEditor( + paragraph( + text(url.slice(0, splitAt), [{ type: "link", attrs: { href: url } }]), + text(url.slice(splitAt), [ + { type: "link", attrs: { href: url } }, + { type: "bold" }, + ]), + ), + ); + + expect( + convertIntegrationDisplay( + editor, + { + kind: "link", + from: 1, + to: 1 + url.length, + url, + provider: "github", + }, + "mention", + ), + ).toBe(true); + expect(editor.getJSON().content?.[0]?.content).toEqual([mention()]); + }); + + it("turns a standalone linked URL into a card", () => { + const editor = createEditor( + paragraph(text(url, [{ type: "link", attrs: { href: url } }]), text(" ")), + ); + + expect( + convertIntegrationDisplay( + editor, + { kind: "link", from: 1, to: 1 + url.length, url, provider: "github" }, + "card", + ), + ).toBe(true); + expect(editor.getJSON().content?.[0]).toEqual(card()); + }); + + it("splits a paragraph around a linked URL converted to a card", () => { + const before = "Review "; + const after = " before release."; + const editor = createEditor( + paragraph( + text(before), + text(url, [{ type: "link", attrs: { href: url } }]), + text(after), + ), + ); + const from = 1 + before.length; + + expect( + convertIntegrationDisplay( + editor, + { kind: "link", from, to: from + url.length, url, provider: "github" }, + "card", + ), + ).toBe(true); + expect(editor.getJSON()).toEqual({ + type: "doc", + content: [ + paragraph(text("Review")), + card(), + paragraph(text("before release.")), + ], + }); + }); + + it("reports whether an inline source can safely become a card", () => { + const editor = createEditor( + paragraph(text("Review "), mention()), + paragraph(mention()), + ); + + expect( + getIntegrationCardAvailability(editor, { kind: "node", pos: 8 }), + ).toEqual({ canUseCard: true }); + expect( + getIntegrationCardAvailability(editor, { kind: "node", pos: 11 }), + ).toEqual({ canUseCard: true }); + }); + + it("distinguishes unsupported containers from inline prose", () => { + const editor = createEditor({ + type: "bulletList", + content: [ + { + type: "listItem", + content: [paragraph(mention())], + }, + ], + }); + + expect( + getIntegrationCardAvailability(editor, { kind: "node", pos: 3 }), + ).toEqual({ canUseCard: false }); + }); + + it("resolves the selected rich integration to its current display", () => { + const editor = createEditor(card(), paragraph(mention())); + + editor.commands.setNodeSelection(0); + expect(getSelectedIntegrationDisplay(editor)).toEqual({ + current: "card", + source: { kind: "node", pos: 0 }, + url, + }); + + editor.commands.setNodeSelection(2); + expect(getSelectedIntegrationDisplay(editor)).toEqual({ + current: "mention", + source: { kind: "node", pos: 2 }, + url, + }); + }); +}); diff --git a/apps/client/src/features/editor/components/integration-link/integration-display.ts b/apps/client/src/features/editor/components/integration-link/integration-display.ts new file mode 100644 index 00000000..61b6e330 --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/integration-display.ts @@ -0,0 +1,309 @@ +import { getMarkRange, type Editor } from "@tiptap/core"; +import { Fragment, type Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { NodeSelection } from "@tiptap/pm/state"; + +export type IntegrationDisplay = "card" | "mention" | "url"; + +export type IntegrationCardAvailability = { canUseCard: boolean }; + +export type IntegrationDisplaySource = + | { + kind: "node"; + pos: number; + // Set when a paste split a paragraph around the node. + joinBefore?: boolean; + joinAfter?: boolean; + } + | { + kind: "link"; + from: number; + to: number; + url: string; + provider: string; + }; + +export type SelectedIntegrationDisplay = { + current: Exclude; + source: Extract; + url: string; +}; + +export function getSelectedIntegrationDisplay( + editor: Editor, +): SelectedIntegrationDisplay | null { + const { selection } = editor.state; + if (!(selection instanceof NodeSelection)) return null; + + const current = + selection.node.type.name === "integrationCard" + ? "card" + : selection.node.type.name === "integrationMention" + ? "mention" + : null; + + if (!current || typeof selection.node.attrs.url !== "string") return null; + + return { + current, + source: { kind: "node", pos: selection.from }, + url: selection.node.attrs.url, + }; +} + +function getCardPlacement( + editor: Editor, + from: number, + to: number, + attrs: Record, +): + | { + canPlace: true; + range: { from: number; to: number }; + content: Fragment; + } + | { + canPlace: false; + range: null; + content: null; + } { + const $from = editor.state.doc.resolve(from); + if ($from.parent.type.name !== "paragraph") { + return { canPlace: false, range: null, content: null }; + } + + const parentStart = $from.start(); + const parentEnd = parentStart + $from.parent.content.size; + if (from < parentStart || to > parentEnd || from > to) { + return { canPlace: false, range: null, content: null }; + } + + const depth = $from.depth; + const parentContainer = $from.node(depth - 1); + const parentIndex = $from.index(depth - 1); + const cardType = editor.state.schema.nodes.integrationCard; + if (!cardType) { + return { canPlace: false, range: null, content: null }; + } + + const before = trimInlineBoundary( + $from.parent.content.cut(0, from - parentStart), + "end", + ); + const after = trimInlineBoundary( + $from.parent.content.cut(to - parentStart), + "start", + ); + const replacement: ProseMirrorNode[] = []; + if (before.size) replacement.push($from.parent.copy(before)); + replacement.push(cardType.create(attrs)); + if (after.size) replacement.push($from.parent.copy(after)); + + const content = Fragment.fromArray(replacement); + if (!parentContainer.canReplace(parentIndex, parentIndex + 1, content)) { + return { canPlace: false, range: null, content: null }; + } + + return { + canPlace: true, + range: { from: $from.before(depth), to: $from.after(depth) }, + content, + }; +} + +function trimInlineBoundary( + fragment: Fragment, + boundary: "start" | "end", +): Fragment { + const children: ProseMirrorNode[] = []; + fragment.forEach((child) => children.push(child)); + + while (children.length) { + const index = boundary === "start" ? 0 : children.length - 1; + const child = children[index]; + if (!child?.isText) break; + + const text = + boundary === "start" + ? child.text?.replace(/^\s+/, "") + : child.text?.replace(/\s+$/, ""); + if (text) { + children[index] = child.type.schema.text(text, child.marks); + break; + } + children.splice(index, 1); + } + + return Fragment.fromArray(children); +} + +function replaceWithCard( + editor: Editor, + placement: Extract, { canPlace: true }>, +): boolean { + return editor.commands.command(({ tr }) => { + tr.replaceWith(placement.range.from, placement.range.to, placement.content); + return true; + }); +} + +export function getIntegrationCardAvailability( + editor: Editor, + source: IntegrationDisplaySource, +): IntegrationCardAvailability { + if (source.kind === "link") { + const placement = getCardPlacement(editor, source.from, source.to, { + url: source.url, + provider: source.provider, + }); + return { canUseCard: placement.canPlace }; + } + + const node = editor.state.doc.nodeAt(source.pos); + if (node?.type.name === "integrationCard") { + return { canUseCard: true }; + } + if (node?.type.name !== "integrationMention") { + return { canUseCard: false }; + } + + const placement = getCardPlacement( + editor, + source.pos, + source.pos + node.nodeSize, + { ...node.attrs }, + ); + return { canUseCard: placement.canPlace }; +} + +export function canPlaceIntegrationCard(editor: Editor, pos: number): boolean { + return getCardPlacement(editor, pos, pos, {}).canPlace; +} + +export function convertIntegrationDisplay( + editor: Editor, + source: IntegrationDisplaySource, + target: IntegrationDisplay, +): boolean { + if (source.kind === "link") { + const linkType = editor.state.schema.marks.link; + const sourceRange = linkType + ? getMarkRange(editor.state.doc.resolve(source.from), linkType, { + href: source.url, + }) + : null; + if ( + !sourceRange || + sourceRange.from !== source.from || + sourceRange.to !== source.to + ) { + return false; + } + + if (target === "card") { + const paragraphRange = getCardPlacement(editor, source.from, source.to, { + url: source.url, + provider: source.provider, + }); + if (!paragraphRange.canPlace) return false; + return replaceWithCard(editor, paragraphRange); + } + + if (target !== "mention") return false; + return editor.commands.insertContentAt( + { from: source.from, to: source.to }, + { + type: "integrationMention", + attrs: { url: source.url, provider: source.provider }, + }, + ); + } + + const node = editor.state.doc.nodeAt(source.pos); + if (!node) return false; + + if (node.type.name === "integrationMention" && target === "card") { + const paragraphRange = getCardPlacement( + editor, + source.pos, + source.pos + node.nodeSize, + { ...node.attrs }, + ); + if (!paragraphRange.canPlace) return false; + return replaceWithCard(editor, paragraphRange); + } + + if (node.type.name === "integrationMention" && target === "url") { + return editor.commands.insertContentAt( + { from: source.pos, to: source.pos + node.nodeSize }, + { + type: "text", + text: node.attrs.url, + marks: [ + { + type: "link", + attrs: { + href: node.attrs.url, + integrationProvider: node.attrs.provider, + }, + }, + ], + }, + ); + } + + if (node.type.name !== "integrationCard") return false; + + const $card = editor.state.doc.resolve(source.pos); + const before = source.joinBefore ? $card.nodeBefore : null; + const after = source.joinAfter + ? $card.parent.maybeChild($card.index() + 1) + : null; + const joinBefore = before?.type.name === "paragraph"; + const joinAfter = after?.type.name === "paragraph"; + const spaceFollows = joinAfter && /^\s/.test(after?.textContent ?? ""); + + const content = + target === "mention" + ? [ + { type: "integrationMention", attrs: { ...node.attrs } }, + ...(spaceFollows ? [] : [{ type: "text", text: " " }]), + ] + : target === "url" + ? [ + { + type: "text", + text: node.attrs.url, + marks: [ + { + type: "link", + attrs: { + href: node.attrs.url, + integrationProvider: node.attrs.provider, + }, + }, + ], + }, + ] + : null; + + if (!content) return false; + + return editor + .chain() + .insertContentAt( + { from: source.pos, to: source.pos + node.nodeSize }, + { + type: "paragraph", + attrs: (joinBefore ? before : joinAfter ? after : null)?.attrs, + content, + }, + ) + .command(({ tr }) => { + const paragraph = tr.doc.nodeAt(source.pos); + if (paragraph?.type.name !== "paragraph") return false; + if (joinAfter) tr.join(source.pos + paragraph.nodeSize); + if (joinBefore) tr.join(source.pos); + return true; + }) + .run(); +} diff --git a/apps/client/src/features/editor/components/integration-link/integration-link-patterns.test.ts b/apps/client/src/features/editor/components/integration-link/integration-link-patterns.test.ts new file mode 100644 index 00000000..f74e2c46 --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/integration-link-patterns.test.ts @@ -0,0 +1,345 @@ +import { + describeIntegrationLink, + matchIntegrationLink, +} from "@docmost/editor-ext"; +import { describe, expect, it } from "vitest"; + +const AZURE = "https://dev.azure.com/contoso"; +const LEGACY_AZURE = "https://contoso.visualstudio.com"; +const JIRA_HOSTS = { jira: ["jira.acme.com"] }; + +// Exercise matching through the public API; regex order and counts are implementation details. +describe("integration link matching", () => { + it.each([ + ["https://github.com/docmost/docmost", "github", "github-repo"], + ["https://gitlab.com/docmost/docmost", "gitlab", "gitlab-project"], + ["https://code.example.com/o/r/pull/1", "github", "github-pr"], + ["https://code.example.com/o/r/-/merge_requests/1", "gitlab", "gitlab-mr"], + ])("identifies %s as %s", (url, provider, type) => { + expect(matchIntegrationLink(url)).toMatchObject({ provider, type }); + }); + + it.each([ + "https://example.com/foo/bar", + "https://github.com/o/r/-/merge_requests/1", + "https://gitlab.com/o/r/pull/1", + ])("does not misclassify %s", (url) => { + expect(matchIntegrationLink(url)).toBeNull(); + }); + + it("restricts GitHub links to the configured Enterprise host", () => { + const hosts = { github: ["github.acme.com"] }; + expect( + matchIntegrationLink("https://github.acme.com/o/r", hosts), + ).toMatchObject({ provider: "github", type: "github-repo" }); + expect( + matchIntegrationLink("https://github.acme.com/o/r/pull/1", hosts), + ).toMatchObject({ provider: "github", type: "github-pr" }); + expect( + matchIntegrationLink("https://github.com/o/r/pull/1", hosts), + ).toBeNull(); + expect( + matchIntegrationLink("https://gitea.example.com/o/r/pull/1", hosts), + ).toBeNull(); + }); + + it("recognizes a self-managed GitLab project root", () => { + expect( + matchIntegrationLink("https://gitlab.acme.com/g/p", { + gitlab: ["gitlab.acme.com"], + }), + ).toMatchObject({ provider: "gitlab", type: "gitlab-project" }); + }); + + it("matches Jira wildcard subdomains without accepting lookalike hosts", () => { + const hosts = { jira: ["*.atlassian.net", "*.jira.com"] }; + expect( + matchIntegrationLink("https://acme.atlassian.net/browse/AB-1", hosts), + ).toMatchObject({ provider: "jira", type: "jira-issue" }); + for (const host of [ + "atlassian.net", + "evilatlassian.net", + "jira.acme.com", + ]) { + expect( + matchIntegrationLink(`https://${host}/browse/AB-1`, hosts), + ).toBeNull(); + } + }); + + it("keeps default matching for providers without a configured host", () => { + expect( + matchIntegrationLink("https://code.example.com/o/r/pull/1", JIRA_HOSTS), + ).toMatchObject({ provider: "github", type: "github-pr" }); + expect( + matchIntegrationLink("https://acme.slack.com/archives/C123", {}), + ).toMatchObject({ provider: "slack", type: "slack-channel" }); + }); + + it.each([ + "https://github.com/o/r/pull/1\n", + "https://github.com/o/r/pulls?" + "a".repeat(9000), + ])("rejects whitespace or oversized input: %s", (url) => { + expect(matchIntegrationLink(url)).toBeNull(); + }); + + it.each([null, undefined])("rejects a link mark without an href: %s", (url) => { + expect(matchIntegrationLink(url)).toBeNull(); + }); + + it.each([ + ["https://github.com/o/r/pulls?q=is%3Aopen", "github-pulls-list"], + ["https://github.com/o/r/pulls/extra", "github-pulls-list"], + ["https://github.com/o/r/releases/tag/v1.0.0", "github-releases-list"], + ])("recognizes the collection at %s", (url, type) => { + expect(matchIntegrationLink(url)?.type).toBe(type); + }); + + it("does not treat a longer segment as a collection", () => { + expect(matchIntegrationLink("https://github.com/o/r/pullsx")).toBeNull(); + }); +}); + +describe("Azure DevOps links", () => { + it.each([ + [ + `${AZURE}/Fabrikam/_git/web-app/pullrequest/318`, + "azure-devops-pr", + ["contoso", "Fabrikam", "web-app", "318"], + ], + [ + `${AZURE}/Fabrikam/_workitems/edit/318`, + "azure-devops-work-item", + ["contoso", "Fabrikam", "318"], + ], + [ + `${AZURE}/Fabrikam/_boards/board/?workitem=318`, + "azure-devops-work-item", + ["contoso", "Fabrikam", "318"], + ], + [ + `${LEGACY_AZURE}/Fabrikam/_git/web-app/pullrequest/318`, + "azure-devops-pr", + ["contoso", "Fabrikam", "web-app", "318"], + ], + [ + `${LEGACY_AZURE}/DefaultCollection/Fabrikam/_workitems/edit/318`, + "azure-devops-work-item", + ["contoso", "Fabrikam", "318"], + ], + [ + `${LEGACY_AZURE}/DefaultCollection/Fabrikam/_boards/board/?workitem=318`, + "azure-devops-work-item", + ["contoso", "Fabrikam", "318"], + ], + [ + `${AZURE}/_git/Fabrikam/pullrequest/318`, + "azure-devops-pr", + ["contoso", undefined, "Fabrikam", "318"], + ], + [ + `${LEGACY_AZURE}/_workitems/edit/318`, + "azure-devops-work-item", + ["contoso", undefined, "318"], + ], + ] as const)( + "captures routing fields from %s and rejects invalid IDs", + (url, type, groups) => { + const result = matchIntegrationLink(url); + expect(result).toMatchObject({ provider: "azure_devops", type }); + expect(result?.match.slice(1)).toEqual(groups); + expect(matchIntegrationLink(`${url}x`)).toBeNull(); + expect(matchIntegrationLink(`${url}00000000`)).toBeNull(); + }, + ); + + it("matches Azure links when other providers report their hosts", () => { + expect( + matchIntegrationLink(`${AZURE}/Fabrikam/_git/web-app/pullrequest/318`, { + github: ["github.com"], + jira: ["*.atlassian.net"], + }), + ).toMatchObject({ provider: "azure_devops", type: "azure-devops-pr" }); + expect( + matchIntegrationLink( + "https://dev.azure.com/browse/AB-1/_workitems/edit/5", + ), + ).toMatchObject({ + provider: "azure_devops", + type: "azure-devops-work-item", + }); + }); + + it.each([ + "http://dev.azure.com/contoso/Fabrikam/_workitems/edit/1", + "http://contoso.visualstudio.com/Fabrikam/_workitems/edit/1", + "https://dev.azure.com.evil.com/contoso/Fabrikam/_workitems/edit/1", + "https://contoso.visualstudio.com.evil.com/Fabrikam/_workitems/edit/1", + "https://dev.azure.com/con_toso/Fabrikam/_workitems/edit/1", + "https://dev.azure.com/con%2Ftoso/Fabrikam/_workitems/edit/1", + `${AZURE}/_Fabrikam/_workitems/edit/1`, + `${AZURE}/Fabrikam/_git/web-app?workitem=42`, + `${AZURE}/Fabrikam/_boardsx/board/?workitem=42`, + `${AZURE}/Fabrikam/_boards/board/?xworkitem=42`, + `${LEGACY_AZURE}/Fabrikam/boards/board/?workitem=42`, + ])("rejects an unsupported or misleading URL: %s", (url) => { + expect(matchIntegrationLink(url)).toBeNull(); + }); + + it("reads work items from board, backlog and sprint hubs on both hosts", () => { + for (const host of [AZURE, LEGACY_AZURE]) { + for (const hub of [ + "_boards/board", + "_backlogs/backlog", + "_sprints/taskboard", + ]) { + const url = `${host}/Fabrikam/${hub}/Team?showParents=true&workitem=42`; + expect(matchIntegrationLink(url)?.match.slice(1), url).toEqual([ + "contoso", + "Fabrikam", + "42", + ]); + } + } + }); + + it.each([ + [ + `${AZURE}/Fabrikam%20Fiber/_workitems/edit/1234`, + "Work Item #1234", + "contoso/Fabrikam Fiber", + ], + [ + `${AZURE}/Fabrikam/_git/web%20app/pullrequest/318`, + "Pull Request #318", + "contoso/Fabrikam/web app", + ], + [ + `${AZURE}/_git/Fabrikam/pullrequest/9`, + "Pull Request #9", + "contoso/Fabrikam", + ], + [`${AZURE}/_workitems/edit/7`, "Work Item #7", "contoso"], + ])("describes %s without a connection", (url, title, description) => { + expect(describeIntegrationLink(url)).toEqual({ + provider: "azure_devops", + title, + description, + }); + }); + + it.each([ + ["Fab%0Arikam", "Fab%0Arikam"], + ["Fab%E2%80%AErikam", "Fab%E2%80%AErikam"], + ["Fab%E2%80%A8rikam", "Fab%E2%80%A8rikam"], + ["Fab%ZZ", "Fab%ZZ"], + ["Fab\u202erikam", "Fab%E2%80%AErikam"], + ["a%2Fb", "a%2Fb"], + ["Caf%C3%A9", "Café"], + ])("keeps decoded project names safe: %s", (project, expected) => { + expect( + describeIntegrationLink(`${AZURE}/${project}/_workitems/edit/1`) + ?.description, + ).toBe(`contoso/${expected}`); + }); +}); + +describe("URL path boundaries", () => { + // Representative providers and failure classes instead of every route permutation. + it.each([ + "https://github.com/a/b/pull/1/../../../../evil/x/pull/2", + "https://github.com/a/b/pull/1/%2e%2e/evil", + "https://github.com/a/b/pull/1\\..\\evil", + "https://gitlab.com/a/b/../../evil/x/-/issues/1", + "https://linear.app/acme/issue/ABC-1/../../../evil/issue/XYZ-2", + "https://acme.atlassian.net/browse/AB-1/%2e%2e/browse/EVIL-2", + `${AZURE}/Fabrikam/_git/web-app/pullrequest/318/../../evil`, + `${LEGACY_AZURE}/Fabrikam/_workitems/edit/1\\..\\evil`, + ])("rejects paths the browser resolves elsewhere: %s", (url) => { + expect(matchIntegrationLink(url)).toBeNull(); + }); + + it.each([ + ["https://github.com/o/.github/pull/1", "github-pr"], + ["https://github.com/o/my..repo/issues/2", "github-issue"], + [ + "https://github.com/o/r/blob/main/.github/workflows/ci.yml#L1-L3", + "github-file", + ], + ["https://github.com/o/r/pulls?q=../../x", "github-pulls-list"], + ["https://linear.app/acme/issue/ABC-1#comment-1/../..", "linear-issue"], + ["https://acme.atlassian.net/browse/AB-1?atlOrigin=x/../..", "jira-issue"], + [ + `${AZURE}/Fabrikam/_git/web-app/pullrequest/318?_a=files&path=/../x`, + "azure-devops-pr", + ], + ])("allows dots in names, query strings and fragments: %s", (url, type) => { + expect(matchIntegrationLink(url)?.type).toBe(type); + }); + + it("applies the same path guard on an Enterprise host", () => { + expect( + matchIntegrationLink("https://github.acme.com/a/b/pull/1/../../evil", { + github: ["github.acme.com"], + }), + ).toBeNull(); + }); + + it.each([ + "https://github.com/o/r?x=/pull/1", + "https://github.com/o/r#x/pull/1", + "https://github.com/o?x=/r/issues/1", + ])("does not read a route from an owner or repository query: %s", (url) => { + expect(matchIntegrationLink(url)).toBeNull(); + }); + + it("captures ordinary dots, dashes and underscores in repository names", () => { + expect( + matchIntegrationLink( + "https://github.com/my-org_1/repo.name-x/pull/1", + )?.match.slice(1), + ).toEqual(["my-org_1", "repo.name-x", "1"]); + }); +}); + +describe("Jira Data Center context paths", () => { + it.each([ + "https://jira.acme.com/jira/browse/AB-1", + "https://jira.acme.com/tools/jira/browse/AB-1", + "https://jira.acme.com/jira/secure/RapidBoard.jspa?rapidView=1&selectedIssue=AB-1", + ])("reads the issue under an installed context path: %s", (url) => { + const result = matchIntegrationLink(url, JIRA_HOSTS); + expect(result).toMatchObject({ provider: "jira", type: "jira-issue" }); + expect(result?.match.slice(1)).toEqual(["AB-1"]); + expect(matchIntegrationLink(url)).toBeNull(); + expect(matchIntegrationLink(url, { jira: ["jira.other.com"] })).toBeNull(); + }); + + it("takes the first issue key after the context path", () => { + expect( + matchIntegrationLink( + "https://jira.acme.com/jira/browse/AB-1/browse/CD-2", + JIRA_HOSTS, + )?.match[1], + ).toBe("AB-1"); + }); + + it("handles an uppercase Cloud host when matching a selected board issue", () => { + expect( + matchIntegrationLink( + "https://ACME.Atlassian.net/jira/software/projects/AB/boards/1?selectedIssue=AB-12", + { + jira: ["*.atlassian.net"], + }, + ), + ).toMatchObject({ provider: "jira", type: "jira-issue" }); + }); + + it.each([ + "https://jira.acme.com/jira/../browse/AB-1", + "https://jira.acme.com/jira/%2e%2e/browse/AB-1", + "https://jira.acme.com/jira\\..\\browse/AB-1", + ])("rejects context paths the browser resolves elsewhere: %s", (url) => { + expect(matchIntegrationLink(url, JIRA_HOSTS)).toBeNull(); + }); +}); diff --git a/apps/client/src/features/editor/components/integration-link/integration-link-url.test.ts b/apps/client/src/features/editor/components/integration-link/integration-link-url.test.ts new file mode 100644 index 00000000..a8b9ea4f --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/integration-link-url.test.ts @@ -0,0 +1,122 @@ +import { IntegrationCard, IntegrationMention } from "@docmost/editor-ext"; +import { Editor, generateText } from "@tiptap/core"; +import { StarterKit } from "@tiptap/starter-kit"; +import { afterEach, describe, expect, it } from "vitest"; + +const PR_URL = "https://github.com/o/r/pull/1"; +const SCRIPT_URLS = [" JaVaScRiPt:alert(1)", "java\tscript:alert(1)"]; +const ENCODED_URL = "https://github.com/o/r/issues?q=label%3A%22a%2520b%22"; + +let editor: Editor; + +function createEditor(content: string | Record = "") { + editor = new Editor({ + extensions: [StarterKit, IntegrationCard, IntegrationMention], + content, + }); + return editor; +} + +function findAttrs(typeName: string) { + const found: Record[] = []; + editor.state.doc.descendants((node) => { + if (node.type.name === typeName) found.push(node.attrs); + }); + return found[0]; +} + +afterEach(() => editor?.destroy()); + +describe.each([ + { typeName: "integrationCard", tag: "div", command: "setIntegrationCard" }, + { + typeName: "integrationMention", + tag: "span", + command: "setIntegrationMention", + }, +] as const)("$typeName url attr", ({ typeName, tag, command }) => { + it.each(SCRIPT_URLS)("stores an empty url when inserting %j", (url) => { + createEditor(); + editor.commands[command]({ url, provider: "github" }); + + expect(findAttrs(typeName)).toMatchObject({ url: "" }); + }); + + it("preserves percent encoding when inserting", () => { + createEditor(); + editor.commands[command]({ url: ENCODED_URL, provider: "github" }); + + expect(findAttrs(typeName)).toMatchObject({ url: ENCODED_URL }); + }); + + it("trims whitespace around an inserted url", () => { + createEditor(); + editor.commands[command]({ url: ` ${PR_URL}\n`, provider: "github" }); + + expect(findAttrs(typeName)).toMatchObject({ url: PR_URL }); + }); + + it("preserves percent encoding through HTML", () => { + createEditor( + `

x

<${tag} data-type="${typeName}" data-url="${ENCODED_URL}">`, + ); + + expect(findAttrs(typeName)).toMatchObject({ url: ENCODED_URL }); + expect(editor.getHTML()).toContain(`href="${ENCODED_URL}"`); + }); + + it("drops a non-http data-url when parsing HTML", () => { + createEditor( + `

x

<${tag} data-type="${typeName}" data-url="file:///x" data-provider="github">`, + ); + + expect(findAttrs(typeName)).toMatchObject({ url: "", provider: "github" }); + }); + + it("renders no unsafe href for JSON content that bypassed parsing", () => { + const node = { type: typeName, attrs: { url: "file:///etc/passwd" } }; + createEditor({ + type: "doc", + content: [ + typeName === "integrationCard" + ? node + : { type: "paragraph", content: [node] }, + ], + }); + + const html = editor.getHTML(); + expect(html).not.toContain("file:"); + expect(html).toContain('href=""'); + }); +}); + +describe("integration node plain text", () => { + it("renders each node as its url", () => { + const issueUrl = "https://github.com/o/r/issues/2"; + const text = generateText( + { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "See " }, + { + type: "integrationMention", + attrs: { url: PR_URL, provider: "github" }, + }, + { type: "text", text: " today" }, + ], + }, + { + type: "integrationCard", + attrs: { url: issueUrl, provider: "github" }, + }, + ], + }, + [StarterKit, IntegrationCard, IntegrationMention], + ); + + expect(text).toBe(`See ${PR_URL} today\n\n${issueUrl}`); + }); +}); diff --git a/apps/client/src/features/editor/components/integration-link/integration-mention-view.module.css b/apps/client/src/features/editor/components/integration-link/integration-mention-view.module.css new file mode 100644 index 00000000..9638902f --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/integration-mention-view.module.css @@ -0,0 +1,53 @@ +.mention { + display: inline-flex; + align-items: center; + gap: 4px; + max-width: 100%; + vertical-align: baseline; + 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); +} + +/* Text runs share the paragraph baseline; icons and badges center on the line. */ +.mentionMeta, +.mentionText { + align-self: baseline; +} + +.mentionMeta { + color: var(--mantine-color-dimmed); + white-space: nowrap; +} + +.mentionText { + text-decoration: underline; + text-decoration-thickness: 0.07em; + text-decoration-color: light-dark( + var(--mantine-color-dark-0), + var(--mantine-color-dark-2) + ); + text-underline-offset: 0.2em; + 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-mention-view.tsx b/apps/client/src/features/editor/components/integration-link/integration-mention-view.tsx new file mode 100644 index 00000000..473f9317 --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/integration-mention-view.tsx @@ -0,0 +1,259 @@ +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, isHttpUrl } 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-mention-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 { editor, getPos, node } = props; + const { url, provider } = node.attrs; + const safeUrl = isHttpUrl(url) ? url : ""; + const { t } = useTranslation(); + + const unfurl = useUnfurl(safeUrl); + const data = unfurl.state === "loaded" ? unfurl.data : null; + const needsConnection = + unfurl.state === "needsConnection" ? unfurl.needsConnection : null; + const [connecting, setConnecting] = useState(false); + + const selectForEditing = useCallback( + (event: React.MouseEvent) => { + if (!editor.isEditable || event.metaKey || event.ctrlKey) return; + + const pos = getPos(); + if (typeof pos !== "number") return; + + event.preventDefault(); + event.stopPropagation(); + editor + .chain() + .focus(undefined, { scrollIntoView: false }) + .setNodeSelection(pos) + .run(); + }, + [editor, getPos], + ); + + 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) { + // Without unfurl data, describe the link from its url. + content = ( + <> + {getIntegrationIcon(provider, 14)} + + {needsConnection?.title ?? + describeIntegrationLink(safeUrl)?.title ?? + (safeUrl ? shortUrl(safeUrl) : 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 issue + 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} + + + + + + + ) : ( + anchor + )} + + ); +} + +export default memo(IntegrationMentionView); diff --git a/apps/client/src/features/editor/components/integration-link/integration-paste-menu.module.css b/apps/client/src/features/editor/components/integration-link/integration-paste-menu.module.css new file mode 100644 index 00000000..25a41674 --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/integration-paste-menu.module.css @@ -0,0 +1,71 @@ +.toolbar { + display: inline-flex; + align-items: center; + gap: 1px; + padding: 4px; + border: 1px solid + light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); + border-radius: 10px; + color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-2)); + background: light-dark( + var(--mantine-color-white), + var(--mantine-color-dark-6) + ); + box-shadow: 0 5px 18px light-dark(rgba(31, 35, 40, 0.12), rgba(0, 0, 0, 0.36)); +} + +.divider { + width: 1px; + height: 22px; + margin: 0 3px; + flex: 0 0 1px; + background: light-dark( + var(--mantine-color-gray-3), + var(--mantine-color-dark-3) + ); +} + +.actionButton { + --ai-size: 32px !important; + + flex: 0 0 32px; + color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-2)); + border-radius: 8px; + -webkit-tap-highlight-color: transparent; +} + +.actionButton svg { + width: 18px; + height: 18px; +} + +.actionButton:focus-visible { + outline: 2px solid var(--mantine-color-blue-6); + outline-offset: -2px; +} + +.pasteOption { + min-height: 30px; +} + +@media (pointer: coarse) { + .pasteOption { + min-height: 44px; + } + + .actionButton { + --ai-size: 44px !important; + + flex-basis: 44px; + } + + .actionButton svg { + width: 21px; + height: 21px; + } + + .divider { + height: 26px; + margin-inline: 4px; + } +} diff --git a/apps/client/src/features/editor/components/integration-link/integration-paste-menu.test.tsx b/apps/client/src/features/editor/components/integration-link/integration-paste-menu.test.tsx new file mode 100644 index 00000000..b2f4faba --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/integration-paste-menu.test.tsx @@ -0,0 +1,213 @@ +import { MantineProvider } from "@mantine/core"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import type { ComponentProps } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { IntegrationPasteMenu } from "./integration-paste-menu"; + +const mocks = vi.hoisted(() => ({ + pasteState: null as { + pos: number; + joinBefore?: boolean; + joinAfter?: boolean; + } | null, + selectedDisplay: null as Record | null, + convertDisplay: vi.fn(), + hasFocus: true, + blurHandler: null as ((props: { event: FocusEvent }) => void) | null, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (value: string) => value }), +})); + +vi.mock("@tiptap/react", () => ({ + posToDOMRect: vi.fn(), + useEditorState: ({ editor, selector }: Record) => + selector({ editor }), +})); + +vi.mock("@tiptap/react/menus", () => ({ + BubbleMenu: ({ children }: { children: React.ReactNode }) => children, +})); + +vi.mock("@/features/editor/extensions/integration-paste-menu", () => ({ + integrationPasteMenuKey: { + getState: () => mocks.pasteState, + }, +})); + +vi.mock("./integration-display", () => ({ + convertIntegrationDisplay: mocks.convertDisplay, + getIntegrationCardAvailability: () => ({ canUseCard: true }), + getSelectedIntegrationDisplay: () => mocks.selectedDisplay, +})); + +function setMediaMatch() { + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + +function renderMenu(nodeType = "integrationCard") { + const editor = { + state: { + doc: { + nodeAt: () => ({ + type: { name: nodeType }, + attrs: { + url: "https://gitlab.com/gitlab-org/gitlab/-/issues/609576", + }, + nodeSize: 1, + }), + }, + tr: { setMeta: vi.fn() }, + }, + view: { + dispatch: vi.fn(), + nodeDOM: vi.fn(), + hasFocus: () => mocks.hasFocus, + }, + commands: { focus: vi.fn() }, + isDestroyed: false, + on: (name: string, handler: (props: { event: FocusEvent }) => void) => { + if (name === "blur") mocks.blurHandler = handler; + }, + off: vi.fn(), + }; + + render( + + ["editor"] + } + /> + , + ); + + return editor; +} + +describe("IntegrationPasteMenu", () => { + beforeEach(() => { + setMediaMatch(); + mocks.pasteState = null; + mocks.selectedDisplay = null; + mocks.convertDisplay.mockReset(); + mocks.hasFocus = true; + mocks.blurHandler = null; + }); + + afterEach(cleanup); + + it("shows the original vertical Paste as chooser only for a fresh paste", () => { + mocks.pasteState = { pos: 4 }; + + renderMenu(); + + const chooser = screen.getByRole("listbox", { name: "Paste as" }); + expect(chooser).toBeTruthy(); + expect(chooser.getAttribute("aria-orientation")).toBe("vertical"); + expect(screen.getByRole("option", { name: "Card" })).toBeTruthy(); + expect(screen.getByRole("option", { name: "Mention" })).toBeTruthy(); + expect(screen.getByRole("option", { name: "URL" })).toBeTruthy(); + expect( + screen.queryByRole("toolbar", { name: "Integration link actions" }), + ).toBeNull(); + }); + + it("keeps the horizontal display toolbar for an existing node", () => { + mocks.selectedDisplay = { + current: "mention", + source: { kind: "node", pos: 4 }, + url: "https://gitlab.com/gitlab-org/gitlab/-/issues/609576", + }; + + renderMenu("integrationMention"); + + expect( + screen.getByRole("toolbar", { name: "Integration link actions" }), + ).toBeTruthy(); + expect(screen.getByRole("radiogroup", { name: "Display as" })).toBeTruthy(); + expect(screen.queryByRole("listbox", { name: "Paste as" })).toBeNull(); + }); + + it("handles Paste as keys while the editor has focus", () => { + mocks.pasteState = { pos: 4 }; + + renderMenu(); + fireEvent.keyDown(window, { key: "ArrowDown" }); + fireEvent.keyDown(window, { key: "Enter" }); + + expect(mocks.convertDisplay).toHaveBeenCalledWith( + expect.anything(), + { kind: "node", pos: 4 }, + "mention", + ); + }); + + it("hands the paste split to the conversion so the sentence can rejoin", () => { + mocks.pasteState = { pos: 4, joinBefore: true, joinAfter: false }; + + renderMenu(); + fireEvent.click(screen.getByRole("option", { name: "URL" })); + + expect(mocks.convertDisplay).toHaveBeenCalledWith( + expect.anything(), + { kind: "node", pos: 4, joinBefore: true, joinAfter: false }, + "url", + ); + }); + + it("leaves keys alone when focus is outside the editor and the prompt", () => { + mocks.pasteState = { pos: 4 }; + mocks.hasFocus = false; + + renderMenu(); + const enter = new KeyboardEvent("keydown", { + key: "Enter", + cancelable: true, + }); + window.dispatchEvent(enter); + + expect(enter.defaultPrevented).toBe(false); + expect(mocks.convertDisplay).not.toHaveBeenCalled(); + }); + + it("dismisses the prompt when the editor loses focus elsewhere", () => { + mocks.pasteState = { pos: 4 }; + + const editor = renderMenu(); + mocks.blurHandler?.({ + event: new FocusEvent("blur", { relatedTarget: document.body }), + }); + + expect(editor.state.tr.setMeta).toHaveBeenCalledWith(expect.anything(), null); + expect(editor.view.dispatch).toHaveBeenCalled(); + }); + + it("keeps the prompt when focus moves into it", () => { + mocks.pasteState = { pos: 4 }; + + const editor = renderMenu(); + mocks.blurHandler?.({ + event: new FocusEvent("blur", { + relatedTarget: screen.getByRole("option", { name: "Card" }), + }), + }); + + expect(editor.view.dispatch).not.toHaveBeenCalled(); + }); +}); 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 00000000..43fdc945 --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/integration-paste-menu.tsx @@ -0,0 +1,335 @@ +import { + ActionIcon, + Button, + Paper, + Stack, + Text, + Tooltip, + VisuallyHidden, +} from "@mantine/core"; +import { notifications } from "@mantine/notifications"; +import { IconCopy, IconExternalLink } from "@tabler/icons-react"; +import { posToDOMRect, useEditorState } from "@tiptap/react"; +import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; +import { copyToClipboard } from "@docmost/editor-ext"; +import { useCallback, useEffect, useId, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts"; +import { integrationPasteMenuKey } from "@/features/editor/extensions/integration-paste-menu"; +import { + convertIntegrationDisplay, + getIntegrationCardAvailability, + getSelectedIntegrationDisplay, + type IntegrationDisplay, + type IntegrationDisplaySource, +} from "./integration-display"; +import { IntegrationDisplayPicker } from "./integration-display-picker"; +import classes from "./integration-paste-menu.module.css"; + +const PASTE_OPTIONS: { value: IntegrationDisplay; label: string }[] = [ + { value: "card", label: "Card" }, + { value: "mention", label: "Mention" }, + { value: "url", label: "URL" }, +]; + +type DisplayTarget = { + current: Exclude; + source: Extract; + url: string; + isPaste: boolean; +}; + +function getDisplayTarget( + editor: EditorMenuProps["editor"], +): DisplayTarget | null { + const pasteState = integrationPasteMenuKey.getState(editor.state); + if (pasteState) { + const node = editor.state.doc.nodeAt(pasteState.pos); + const current = + node?.type.name === "integrationCard" + ? "card" + : node?.type.name === "integrationMention" + ? "mention" + : null; + + if (current && typeof node?.attrs.url === "string") { + return { + current, + source: { + kind: "node", + pos: pasteState.pos, + joinBefore: pasteState.joinBefore, + joinAfter: pasteState.joinAfter, + }, + url: node.attrs.url, + isPaste: true, + }; + } + } + + const selected = getSelectedIntegrationDisplay(editor); + return selected ? { ...selected, isPaste: false } : null; +} + +export function IntegrationPasteMenu({ editor }: EditorMenuProps) { + const { t } = useTranslation(); + const [selectedPasteIndex, setSelectedPasteIndex] = useState(0); + const pasteOptionIdPrefix = useId(); + const promptRef = useRef(null); + + const target = useEditorState({ + editor, + selector: ({ editor: currentEditor }) => + currentEditor ? getDisplayTarget(currentEditor) : null, + }); + + const findTarget = useCallback(() => getDisplayTarget(editor), [editor]); + const shouldShow = useCallback(() => Boolean(findTarget()), [findTarget]); + + const getReferencedVirtualElement = useCallback(() => { + const currentTarget = findTarget(); + if (!currentTarget) return undefined; + + const { pos } = currentTarget.source; + const node = editor.state.doc.nodeAt(pos); + if (!node) return undefined; + + const dom = editor.view.nodeDOM(pos) as HTMLElement | null; + const domRect = + dom?.getBoundingClientRect?.() ?? + posToDOMRect(editor.view, pos, pos + node.nodeSize); + + return { + getBoundingClientRect: () => domRect, + getClientRects: () => [domRect], + }; + }, [editor, findTarget]); + + const dismissPastePrompt = useCallback(() => { + if (!integrationPasteMenuKey.getState(editor.state)) return; + editor.view.dispatch( + editor.state.tr.setMeta(integrationPasteMenuKey, null), + ); + + requestAnimationFrame(() => { + const activeElement = document.activeElement; + if ( + !activeElement || + activeElement === document.body || + !activeElement.isConnected + ) { + editor.commands.focus(undefined, { scrollIntoView: false }); + } + }); + }, [editor]); + + const changeDisplay = useCallback( + (display: IntegrationDisplay) => { + const currentTarget = findTarget(); + if (!currentTarget) return; + + dismissPastePrompt(); + if (display === currentTarget.current) { + editor.commands.focus(undefined, { scrollIntoView: false }); + return; + } + + if (convertIntegrationDisplay(editor, currentTarget.source, display)) { + editor.commands.focus(undefined, { scrollIntoView: false }); + } + }, + [dismissPastePrompt, editor, findTarget], + ); + + useEffect(() => { + if (target?.isPaste) setSelectedPasteIndex(0); + }, [target?.isPaste, target?.source.pos]); + + // Focus stays in the editor, so capture keys before ProseMirror moves past the atom node. + useEffect(() => { + if (!target?.isPaste) return; + + const ownsFocus = () => + editor.view.hasFocus() || + Boolean(promptRef.current?.contains(document.activeElement)); + + const onKeyDown = (event: KeyboardEvent) => { + if (!ownsFocus()) return; + + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + dismissPastePrompt(); + return; + } + + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + event.stopPropagation(); + const delta = event.key === "ArrowDown" ? 1 : -1; + setSelectedPasteIndex( + (current) => + (current + delta + PASTE_OPTIONS.length) % PASTE_OPTIONS.length, + ); + return; + } + + if (event.key === "Enter") { + event.preventDefault(); + event.stopPropagation(); + const option = PASTE_OPTIONS[selectedPasteIndex]; + if (option) changeDisplay(option.value); + } + }; + + // Leaving the editor dismisses the prompt, unless focus moved into the prompt itself. + const onBlur = ({ event }: { event: FocusEvent }) => { + const next = event.relatedTarget; + if (next instanceof Node && promptRef.current?.contains(next)) return; + if (editor.isDestroyed) return; + editor.view.dispatch( + editor.state.tr.setMeta(integrationPasteMenuKey, null), + ); + }; + + window.addEventListener("keydown", onKeyDown, true); + editor.on("blur", onBlur); + return () => { + window.removeEventListener("keydown", onKeyDown, true); + editor.off("blur", onBlur); + }; + }, [ + changeDisplay, + dismissPastePrompt, + editor, + selectedPasteIndex, + target?.isPaste, + ]); + + const copyLink = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + const currentTarget = findTarget(); + if (!currentTarget) return; + + copyToClipboard(currentTarget.url); + notifications.show({ message: t("Link copied") }); + }, + [findTarget, t], + ); + + const cardAvailability = target + ? getIntegrationCardAvailability(editor, target.source) + : null; + + return ( + + {target?.isPaste ? ( + + + {t(PASTE_OPTIONS[selectedPasteIndex].label)} + + + {t("Paste as")} + + + {PASTE_OPTIONS.map((option, index) => ( + + ))} + + + ) : target ? ( +
+ +
+ ) : 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 00000000..bcd234e5 --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/use-unfurl.test.tsx @@ -0,0 +1,118 @@ +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", + 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 00000000..e0a19fea --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/use-unfurl.ts @@ -0,0 +1,45 @@ +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 }; + +// Fetched per viewer and never written into the doc, so each viewer's third-party access applies. +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/components/link/link-view.test.tsx b/apps/client/src/features/editor/components/link/link-view.test.tsx new file mode 100644 index 00000000..782bd7cb --- /dev/null +++ b/apps/client/src/features/editor/components/link/link-view.test.tsx @@ -0,0 +1,225 @@ +import { MantineProvider } from "@mantine/core"; +import { + cleanup, + fireEvent, + render, + screen, + within, +} from "@testing-library/react"; +import type { ComponentProps } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import LinkView from "./link-view"; + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ t: (value: string) => value }), +})); + +vi.mock("react-router-dom", () => ({ + useLocation: () => ({ pathname: "/s/example/p/page" }), + useNavigate: () => vi.fn(), + useParams: () => ({}), +})); + +const installedIntegrations = vi.hoisted(() => [ + { type: "github", unfurlHosts: ["github.com"] }, +]); + +vi.mock("@/features/integration/queries/integration-query", () => ({ + useInstalledIntegrations: () => ({ data: installedIntegrations }), +})); + +vi.mock("@/features/page/queries/page-query.ts", () => ({ + usePageQuery: () => ({ data: undefined }), +})); + +vi.mock("@/features/share/queries/share-query.ts", () => ({ + useSharePageQuery: () => ({ data: undefined }), +})); + +vi.mock("@/features/public-space/queries/public-space-query.ts", () => ({ + usePublicSpacePageQuery: () => ({ data: undefined }), +})); + +vi.mock("@/features/editor/components/link/link-editor-panel.tsx", () => ({ + LinkEditorPanel: () => null, +})); + +function setMediaMatch() { + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + +function renderDetachedLinkView( + attrs: Record = { + href: "https://github.com/docmost/docmost/pull/2475", + integrationProvider: "github", + internal: false, + }, +) { + const props = { + editor: { + isEditable: true, + state: { + doc: { + content: { size: 100 }, + resolve: (position: number) => { + if (position < 0) { + throw new RangeError(`Position ${position} out of range`); + } + return {}; + }, + }, + }, + view: { posAtDOM: () => -1 }, + }, + mark: { + attrs, + type: { name: "link" }, + }, + }; + + const renderView = () => ( + + )} /> + + ); + + return { ...render(renderView()), renderView }; +} + +describe("LinkView", () => { + beforeEach(setMediaMatch); + afterEach(cleanup); + + it("does not resolve the mark position while the popover is closed", () => { + const { rerender, renderView } = renderDetachedLinkView(); + + expect(() => rerender(renderView())).not.toThrow(); + }); + + it("does not resolve a negative mark-view position after opening", () => { + const { container } = renderDetachedLinkView(); + const link = container.querySelector("a"); + expect(link).not.toBeNull(); + + expect(() => fireEvent.click(link!)).not.toThrow(); + }); + + it("opens URL-current display choices from the existing link action row", async () => { + const { container } = renderDetachedLinkView(); + const link = container.querySelector("a"); + const popoverTarget = container.querySelector('[aria-haspopup="dialog"]'); + expect(link).not.toBeNull(); + expect(popoverTarget).not.toBeNull(); + + fireEvent.click(link!); + + const menu = await screen.findByRole("dialog", { hidden: true }); + const menuQueries = within(menu); + const display = menuQueries.getByRole("button", { + name: "Display as: URL", + hidden: true, + }); + fireEvent.click(display); + + const displayMenu = await screen.findByRole("menu", { hidden: true }); + expect( + within(displayMenu) + .getByRole("menuitemradio", { name: "URL", hidden: true }) + .getAttribute("aria-checked"), + ).toBe("true"); + fireEvent.mouseDown( + within(displayMenu).getByRole("menuitemradio", { + name: "Mention", + hidden: true, + }), + ); + expect(popoverTarget?.getAttribute("aria-expanded")).toBe("true"); + }); + + it("offers display choices for a plain link on a host the provider serves", async () => { + const { container } = renderDetachedLinkView({ + href: "https://github.com/docmost/docmost/pull/2475", + internal: false, + }); + + fireEvent.click(container.querySelector("a")!); + + const menu = await screen.findByRole("dialog", { hidden: true }); + expect( + within(menu).getByRole("button", { + name: "Display as: URL", + hidden: true, + }), + ).toBeTruthy(); + }); + + it("hides display choices for a GitHub-like path on a host GitHub does not serve", async () => { + const { container } = renderDetachedLinkView({ + href: "https://gitea.example.com/team/app/pulls/7", + internal: false, + }); + + fireEvent.click(container.querySelector("a")!); + + const menu = await screen.findByRole("dialog", { hidden: true }); + expect( + within(menu).getByRole("button", { name: "Edit link", hidden: true }), + ).toBeTruthy(); + expect( + within(menu).queryByRole("button", { + name: "Display as: URL", + hidden: true, + }), + ).toBeNull(); + }); + + it("supports keyboard-generated clicks on link-menu actions", async () => { + const { container } = renderDetachedLinkView(); + const link = container.querySelector("a"); + expect(link).not.toBeNull(); + + fireEvent.click(link!); + + const menu = await screen.findByRole("dialog", { hidden: true }); + const edit = within(menu).getByRole("button", { + name: "Edit link", + hidden: true, + }); + fireEvent.click(edit); + + expect(await within(menu).findByText("Page or URL")).toBeTruthy(); + }); + + it("closes the integrated link menu when Escape is pressed on the selector", async () => { + const { container } = renderDetachedLinkView(); + const link = container.querySelector("a"); + const target = container.querySelector('[aria-haspopup="dialog"]'); + expect(link).not.toBeNull(); + expect(target).not.toBeNull(); + + fireEvent.click(link!); + expect(target?.getAttribute("aria-expanded")).toBe("true"); + + const menu = await screen.findByRole("dialog", { hidden: true }); + const display = within(menu).getByRole("button", { + name: "Display as: URL", + hidden: true, + }); + fireEvent.keyDown(display, { key: "Escape" }); + + expect(target?.getAttribute("aria-expanded")).toBe("false"); + }); +}); diff --git a/apps/client/src/features/editor/components/link/link-view.tsx b/apps/client/src/features/editor/components/link/link-view.tsx index 39de1d66..0722f4ae 100644 --- a/apps/client/src/features/editor/components/link/link-view.tsx +++ b/apps/client/src/features/editor/components/link/link-view.tsx @@ -1,4 +1,5 @@ import { MarkViewContent, MarkViewProps } from "@tiptap/react"; +import { getMarkRange } from "@tiptap/core"; import { useNavigate, useLocation, useParams } from "react-router-dom"; import { IconFileDescription, @@ -8,7 +9,7 @@ import { IconPencil, IconWorld, } from "@tabler/icons-react"; -import { useState, useCallback, useRef, useEffect } from "react"; +import { useState, useCallback, useRef, useEffect, useMemo } from "react"; import { notifications } from "@mantine/notifications"; import { Divider, @@ -33,8 +34,20 @@ import { buildSharedPageUrl, } from "@/features/page/page.utils.ts"; import { extractPageSlugId } from "@/lib"; -import { sanitizeUrl, copyToClipboard, isEditorReady } from "@docmost/editor-ext"; +import { + matchIntegrationLink, + sanitizeUrl, + copyToClipboard, + isEditorReady, +} from "@docmost/editor-ext"; import { normalizeUrl } from "@/lib/utils"; +import { useInstalledIntegrations } from "@/features/integration/queries/integration-query"; +import { + convertIntegrationDisplay, + getIntegrationCardAvailability, + type IntegrationDisplaySource, +} from "@/features/editor/components/integration-link/integration-display"; +import { IntegrationDisplayMenu } from "@/features/editor/components/integration-link/integration-display-picker"; const parseInternalLink = ( href: string, @@ -80,6 +93,31 @@ export default function LinkView(props: MarkViewProps) { const wrapperRef = useRef(null); const dropdownRef = useRef(null); const isEditable = editor.isEditable; + const integrationMatch = useMemo(() => matchIntegrationLink(href), [href]); + const markIntegrationProvider = + typeof mark.attrs.integrationProvider === "string" + ? mark.attrs.integrationProvider + : null; + const { data: installedIntegrations } = useInstalledIntegrations( + Boolean(markIntegrationProvider || integrationMatch) && isEditable, + ); + // Only hosts an installed provider unfurls get "Display as". + const hostIntegrationProvider = useMemo(() => { + if (!installedIntegrations) return null; + const unfurlHosts = Object.fromEntries( + installedIntegrations + .filter((integration) => integration.unfurlHosts) + .map((integration) => [integration.type, integration.unfurlHosts]), + ); + return matchIntegrationLink(href, unfurlHosts)?.provider ?? null; + }, [href, installedIntegrations]); + const detectedIntegrationProvider = + markIntegrationProvider ?? hostIntegrationProvider; + const integrationProvider = installedIntegrations?.some( + (integration) => integration.type === detectedIntegrationProvider, + ) + ? detectedIntegrationProvider + : null; const { isInternal, slugId, @@ -124,12 +162,36 @@ export default function LinkView(props: MarkViewProps) { const getLinkPos = useCallback((): number | null => { if (!wrapperRef.current) return null; try { - return editor.view.posAtDOM(wrapperRef.current, 0); + const pos = editor.view.posAtDOM(wrapperRef.current, 0); + return pos >= 0 && pos <= editor.state.doc.content.size ? pos : null; } catch { return null; } }, [editor]); + const getIntegrationSource = useCallback((): Extract< + IntegrationDisplaySource, + { kind: "link" } + > | null => { + if (!integrationProvider) return null; + + const pos = getLinkPos(); + if (pos === null) return null; + + const range = getMarkRange(editor.state.doc.resolve(pos), mark.type, { + href, + }); + if (!range) return null; + + return { + kind: "link", + from: range.from, + to: range.to, + url: href, + provider: integrationProvider, + }; + }, [editor, getLinkPos, href, integrationProvider, mark.type]); + const handleUpdateLinkTitle = useCallback( (newTitle: string) => { if (!newTitle) return; @@ -225,7 +287,9 @@ export default function LinkView(props: MarkViewProps) { const target = e.target as Node; if ( wrapperRef.current?.contains(target) || - dropdownRef.current?.contains(target) + dropdownRef.current?.contains(target) || + (target instanceof Element && + target.closest("[data-integration-display-menu-dropdown]")) ) { return; } @@ -233,6 +297,12 @@ export default function LinkView(props: MarkViewProps) { }; const handleEscape = (e: KeyboardEvent) => { if (e.key === "Escape") { + if ( + e.target instanceof Element && + e.target.closest("[data-integration-display-menu-dropdown]") + ) { + return; + } setPopoverState("closed"); } }; @@ -366,6 +436,22 @@ export default function LinkView(props: MarkViewProps) { setPopoverState("closed"); }, [editor]); + const handleIntegrationDisplay = useCallback( + (display: "card" | "mention" | "url") => { + const source = getIntegrationSource(); + setPopoverState("closed"); + if (!source || display === "url") { + editor.commands.focus(undefined, { scrollIntoView: false }); + return; + } + + if (convertIntegrationDisplay(editor, source, display)) { + editor.commands.focus(undefined, { scrollIntoView: false }); + } + }, + [editor, getIntegrationSource], + ); + const internalHref = () => { if (isShareRoute && slugId) { return buildSharedPageUrl({ shareId, pageSlugId: slugId, pageTitle }); @@ -440,7 +526,13 @@ export default function LinkView(props: MarkViewProps) { return ( e.stopPropagation()} > {activeView === "edit" ? ( @@ -560,7 +658,7 @@ export default function LinkView(props: MarkViewProps) { )} ) : ( - + { e.preventDefault(); handleNavigate(); @@ -591,15 +683,43 @@ export default function LinkView(props: MarkViewProps) { - + + + {isPopoverVisible && + integrationProvider && + (() => { + const source = getIntegrationSource(); + const cardAvailability = source + ? getIntegrationCardAvailability(editor, source) + : null; + + return ( + + ); + })()} { e.preventDefault(); e.stopPropagation(); + }} + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); setShowSearch(false); setPopoverState("edit"); }} @@ -612,9 +732,16 @@ export default function LinkView(props: MarkViewProps) { { e.preventDefault(); e.stopPropagation(); + }} + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); handleCopy(e); }} > @@ -626,9 +753,16 @@ export default function LinkView(props: MarkViewProps) { { e.preventDefault(); e.stopPropagation(); + }} + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); handleRemoveLink(); }} > diff --git a/apps/client/src/features/editor/components/link/link.module.css b/apps/client/src/features/editor/components/link/link.module.css index dab288c2..616390c4 100644 --- a/apps/client/src/features/editor/components/link/link.module.css +++ b/apps/client/src/features/editor/components/link/link.module.css @@ -3,6 +3,69 @@ display: inline; } +.linkWrapper[data-active="true"] > a { + border-radius: 4px; + outline: 2px solid + light-dark(var(--mantine-color-blue-4), var(--mantine-color-blue-6)); + outline-offset: 2px; +} + +.integrationPopover { + overflow: hidden; + border-color: light-dark( + var(--mantine-color-gray-3), + var(--mantine-color-dark-4) + ); + border-radius: 10px; + box-shadow: 0 5px 18px light-dark(rgba(31, 35, 40, 0.12), rgba(0, 0, 0, 0.36)); +} + +.previewRow { + min-width: 0; +} + +.previewDestination { + min-width: 0; + max-width: min(250px, calc(100vw - 170px)); + flex: 1 1 auto; + cursor: pointer; + text-decoration: none; + color: inherit; + user-select: none; +} + +.previewDivider { + height: 22px; + margin: 0 3px; +} + +.previewAction { + --ai-size: 32px !important; + + flex: 0 0 32px; + border-radius: 6px; + -webkit-tap-highlight-color: transparent; +} + +.previewAction:focus-visible { + outline: 2px solid var(--mantine-color-blue-6); + outline-offset: -2px; +} + +@media (pointer: coarse) { + .previewDivider { + height: 26px; + margin-inline: 4px; + } + + .previewAction { + --ai-size: 44px !important; + + flex-basis: 44px; + border-radius: 8px; + } +} + .linkInput { border: 1.5px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index 6543f777..8079eaf7 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, + IntegrationCard, + IntegrationMention, Columns, Column, Status, @@ -99,6 +101,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 IntegrationCardView from "@/features/editor/components/integration-link/integration-card-view.tsx"; +import IntegrationMentionView from "@/features/editor/components/integration-link/integration-mention-view.tsx"; +import { IntegrationPasteMenuExtension } from "@/features/editor/extensions/integration-paste-menu"; import TabsView from "@/features/editor/components/tabs/tabs-view.tsx"; import TransclusionView from "@/features/editor/components/transclusion/transclusion-view.tsx"; import TransclusionReferenceView from "@/features/editor/components/transclusion/transclusion-reference-view.tsx"; @@ -396,6 +401,13 @@ export const mainExtensions = [ Subpages.configure({ view: SubpagesView, }), + IntegrationCard.configure({ + view: IntegrationCardView, + }), + 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 00000000..07883d21 --- /dev/null +++ b/apps/client/src/features/editor/extensions/integration-paste-menu.ts @@ -0,0 +1,47 @@ +import { Extension } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; + +export type IntegrationPasteMenuState = { + pos: number; + joinBefore: boolean; + joinAfter: boolean; +} | null; + +export const integrationPasteMenuKey = new PluginKey( + "integrationPasteMenu", +); + +// Position of a just-pasted integration node, where the "Paste as" menu anchors, +// and which sides of it hold the halves of the paragraph the paste split. +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; + // Follow-up transactions (unique ids, trailing node) keep the anchor. + if (tr.docChanged) { + const pos = tr.mapping.map(prev.pos); + const node = tr.doc.nodeAt(pos); + const isIntegrationNode = + node && + (node.type.name === "integrationCard" || + node.type.name === "integrationMention"); + return isIntegrationNode ? { ...prev, 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 643b7b8d..4f7a8b18 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -82,6 +82,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 { @@ -388,6 +390,15 @@ function CollabPageEditor({ [pageId, editable, extensions], ); + useEffect(() => { + // Read by the paste handler with no observer, so it must not be garbage collected. + queryClient.prefetchQuery({ + queryKey: ["installed-integrations"], + queryFn: getInstalledIntegrations, + gcTime: Infinity, + }); + }, []); + useLayoutEffect(() => { if (editor && !editor.isDestroyed) { // @ts-ignore @@ -515,6 +526,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 00000000..f5408a34 --- /dev/null +++ b/apps/client/src/features/integration/components/connection-row.tsx @@ -0,0 +1,91 @@ +import { Group, Text, Button, Box, Stack } 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} + + {connection?.invalidatedAt ? ( + + {t("Connection expired")} + + ) : connection && connectedLabel ? ( + + {t("Connected as {{label}}", { label: connectedLabel })} + + ) : ( + + {definition.description} + + )} + + + + + {connection ? ( + <> + {connection.invalidatedAt && ( + + )} + + + ) : ( + + )} + + + + ); +} 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 00000000..a4fd0260 --- /dev/null +++ b/apps/client/src/features/integration/components/integration-icons.tsx @@ -0,0 +1,34 @@ +import { ReactNode } from "react"; +import { + AzureDevOpsIcon, + 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) => , + azure_devops: (size) => , +}; + +export function getIntegrationIcon( + type: string, + size: number, +): ReactNode { + // The type can come from a document; ignore inherited keys like "constructor". + if (Object.prototype.hasOwnProperty.call(integrationIconMap, type)) { + return integrationIconMap[type](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 00000000..798def1a --- /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 00000000..ab6e0204 --- /dev/null +++ b/apps/client/src/features/integration/components/integration-row.tsx @@ -0,0 +1,102 @@ +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 { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label"; +import { Feature } from "@/ee/features"; +import { isCloud } from "@/lib/config.ts"; + +// Keep in sync with apps/server/src/ee/licence/feature-registry.ts. +const MINIMUM_PLAN: Record = { + [Feature.INTEGRATIONS]: { selfHosted: "Business", cloud: "Standard" }, + [Feature.INTEGRATIONS_ADVANCED]: { selfHosted: "Enterprise", cloud: "Business" }, +}; + +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(definition.requiresFeature ?? ""); + const locked = !!definition.requiresFeature && !hasAccess; + const upgradeLabel = useUpgradeLabel(); + const minimumPlan = MINIMUM_PLAN[definition.requiresFeature ?? ""]; + + return ( + + + + {getIntegrationIcon(definition.type, 28)} + + + + {definition.name} + + {locked && ( + + {minimumPlan + ? t(isCloud() ? minimumPlan.cloud : minimumPlan.selfHosted) + : 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 00000000..18763442 --- /dev/null +++ b/apps/client/src/features/integration/pages/connections.tsx @@ -0,0 +1,271 @@ +import { useEffect, useRef } from "react"; +import { Text, Alert, Stack, Button, Group, List } from "@mantine/core"; +import { modals } from "@mantine/modals"; +import { Helmet } from "react-helmet-async"; +import { useTranslation } from "react-i18next"; +import { useSearchParams } from "react-router-dom"; +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"; +import { resolveConnectPrompt } from "../utils/connect-prompt"; +import type { IntegrationDefinition } from "../types/integration.types"; + +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 [searchParams, setSearchParams] = useSearchParams(); + const connectType = searchParams.get("connect"); + const prompted = useRef(false); + const mounted = useRef(true); + + 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 otherwise return to the admin integrations page. + 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", + }); + } + }; + + useEffect(() => { + if (isLoading) return; + const definition = resolveConnectPrompt( + connectType, + available, + installed, + myConnections, + ); + if (!definition) { + if (connectType) { + modals.close("connect-prompt"); + setSearchParams({}, { replace: true }); + } else { + prompted.current = false; + } + return; + } + if (prompted.current) return; + prompted.current = true; + modals.open({ + modalId: "connect-prompt", + title: t("Connect {{name}} to Docmost", { name: definition.name }), + centered: true, + // Mantine also calls onClose on unmount, and navigating then would bounce the user back here. + onClose: () => { + if (mounted.current) setSearchParams({}, { replace: true }); + }, + children: ( + { + modals.close("connect-prompt"); + handleConnect(definition.type); + }} + onCancel={() => modals.close("connect-prompt")} + /> + ), + }); + }, [connectType, available, installed, myConnections, isLoading]); + + // The modal lives on the app-root provider, so it must not outlive this page. + useEffect( + () => () => { + mounted.current = false; + modals.close("connect-prompt"); + }, + [], + ); + + 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. + 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.")} + + )} + + {error === "identity_mismatch" && ( + + {t( + "The account you signed in with does not use your Docmost email address. Sign in to the provider with the account that matches your Docmost email and try again.", + )} + + )} + + {error === "tenant_mismatch" && ( + + {t( + "The Slack account you signed in with is not in the Slack workspace connected to Docmost. Sign in to Slack with your account in that workspace and 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 ( + + ); + })} + + )} + + ); +} + +type ConnectPromptBodyProps = { + definition: IntegrationDefinition; + onConnect: () => void; + onCancel: () => void; +}; + +function ConnectPromptBody({ + definition, + onConnect, + onCancel, +}: ConnectPromptBodyProps) { + const { t } = useTranslation(); + + return ( + + {definition.type === "slack" ? ( + <> + + {t("Link your Slack account to your Docmost account to:")} + + + + {t("Search and open Docmost pages from Slack")} + + + {t("Create pages and save Slack messages to Docmost")} + + + {t("Ask AI questions about your workspace docs")} + + + {t("Get your Docmost notifications as Slack DMs")} + + + {t( + "See previews of Docmost links in Slack and Slack links in Docmost", + )} + + + + {t( + "You will sign in with Slack to confirm which Slack account is yours. It must use the same email as your Docmost account.", + )} + + + ) : ( + + {t( + "Connect your {{name}} account so links you paste show rich previews.", + { name: definition.name }, + )} + + )} + + + + + + + ); +} 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 00000000..4f3ae4a2 --- /dev/null +++ b/apps/client/src/features/integration/pages/integrations.tsx @@ -0,0 +1,123 @@ +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, + 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 uninstallMutation = useUninstallIntegration(); + + const handleInstall = useCallback( + async (type: string) => { + 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", + }); + } + }, + [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.")} + + )} + + {error === "tenant_in_use" && ( + + {t( + "This Slack workspace is already connected to another Docmost workspace. Disconnect it there first, or install Docmost into a different Slack workspace.", + )} + + )} + + {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/queries/integration-query.ts b/apps/client/src/features/integration/queries/integration-query.ts new file mode 100644 index 00000000..b487a42c --- /dev/null +++ b/apps/client/src/features/integration/queries/integration-query.ts @@ -0,0 +1,68 @@ +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(enabled = true) { + return useQuery({ + queryKey: ["installed-integrations"], + queryFn: integrationService.getInstalledIntegrations, + enabled, + }); +} + +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 useDisconnectIntegration() { + const qc = useQueryClient(); + const { t } = useTranslation(); + return useMutation({ + mutationFn: integrationService.disconnectIntegration, + onSuccess: () => { + notifications.show({ message: t("Integration disconnected") }); + qc.invalidateQueries({ queryKey: ["my-connections"] }); + // refetchOnMount is false, so invalidated inactive queries would stay stale. + 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 00000000..7b63f42c --- /dev/null +++ b/apps/client/src/features/integration/services/integration-service.ts @@ -0,0 +1,70 @@ +import api from "@/lib/api-client"; +import { + IntegrationDefinition, + Integration, + 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"); + 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"); + 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; +} + +// The integration row is only created when the OAuth callback succeeds. +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/types/integration.types.ts b/apps/client/src/features/integration/types/integration.types.ts new file mode 100644 index 00000000..cb39d46b --- /dev/null +++ b/apps/client/src/features/integration/types/integration.types.ts @@ -0,0 +1,62 @@ +export type IntegrationCapability = "oauth" | "unfurl" | "actions"; + +export type OAuthConfig = { + authUrl: string; + tokenUrl: string; + scopes: string[]; + connectionScope?: 'workspace' | 'user'; + credentialsKey?: string; +}; + +export type IntegrationDefinition = { + type: string; + name: string; + description: string; + icon: string; + capabilities: IntegrationCapability[]; + oauth?: OAuthConfig; + requiresFeature?: string; +}; + +export type Integration = { + id: string; + workspaceId: string; + type: string; + settings: Record | null; + installedById: string | null; + createdAt: string; + updatedAt: string; + // Hostnames the provider unfurls; "*.example.com" means any subdomain. + unfurlHosts?: 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; +}; + +export type UnfurlNeedsConnection = { + needsConnection: true; + integrationId: string; + integrationType: string; + integrationName: string; + title: string; + description?: string; +}; diff --git a/apps/client/src/features/integration/utils/connect-prompt.test.ts b/apps/client/src/features/integration/utils/connect-prompt.test.ts new file mode 100644 index 00000000..c95c377d --- /dev/null +++ b/apps/client/src/features/integration/utils/connect-prompt.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { resolveConnectPrompt } from "./connect-prompt"; + +const slack = { type: "slack", name: "Slack", capabilities: ["oauth", "unfurl"] } as any; +const github = { type: "github", name: "GitHub", capabilities: ["oauth", "unfurl"] } as any; +const installed = [{ id: "i-slack", type: "slack" }] as any; + +describe("resolveConnectPrompt", () => { + it("returns nothing without a connect parameter", () => { + expect(resolveConnectPrompt(null, [slack], installed, [])).toBeNull(); + }); + + it("returns nothing for a provider that is not installed", () => { + expect(resolveConnectPrompt("github", [slack, github], installed, [])).toBeNull(); + }); + + it("returns nothing when the user already has a live connection", () => { + const live = [{ type: "slack", invalidatedAt: null }] as any; + expect(resolveConnectPrompt("slack", [slack], installed, live)).toBeNull(); + }); + + it("prompts when the user is unlinked", () => { + expect(resolveConnectPrompt("slack", [slack], installed, [])).toBe(slack); + }); + + it("prompts when the existing connection is invalidated", () => { + const dead = [{ type: "slack", invalidatedAt: "2026-09-01T00:00:00Z" }] as any; + expect(resolveConnectPrompt("slack", [slack], installed, dead)).toBe(slack); + }); +}); diff --git a/apps/client/src/features/integration/utils/connect-prompt.ts b/apps/client/src/features/integration/utils/connect-prompt.ts new file mode 100644 index 00000000..81a25a28 --- /dev/null +++ b/apps/client/src/features/integration/utils/connect-prompt.ts @@ -0,0 +1,22 @@ +import type { + Integration, + IntegrationDefinition, + UserConnection, +} from "../types/integration.types"; + +// The provider a `?connect=` deep link should prompt for, if any. +export function resolveConnectPrompt( + connectType: string | null, + available: IntegrationDefinition[] | undefined, + installed: Integration[] | undefined, + myConnections: UserConnection[] | undefined, +): IntegrationDefinition | null { + if (!connectType) return null; + const definition = available?.find((d) => d.type === connectType); + if (!definition || !definition.capabilities.includes("oauth")) return null; + if (!installed?.some((i) => i.type === connectType)) return null; + const live = myConnections?.find( + (c) => c.type === connectType && !c.invalidatedAt, + ); + return live ? null : definition; +} diff --git a/apps/client/src/lib/app-route.ts b/apps/client/src/lib/app-route.ts index 48c1b87d..6a907c09 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/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts index 312d7762..c59f18b2 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, + IntegrationCard, + IntegrationMention, Tabs, Tab, TabLabel, @@ -137,6 +139,8 @@ export const tiptapExtensions = [ Footnotes, Footnote, FootnoteReference, + IntegrationCard, + 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 3ad2ddd7..4d563991 100644 --- a/apps/server/src/common/events/audit-events.ts +++ b/apps/server/src/common/events/audit-events.ts @@ -29,6 +29,12 @@ export const AuditEvent = { OAUTH_GRANT_CREATED: 'oauth_grant.created', OAUTH_GRANT_REVOKED: 'oauth_grant.revoked', + // Integrations + INTEGRATION_INSTALLED: 'integration.installed', + INTEGRATION_UNINSTALLED: 'integration.uninstalled', + INTEGRATION_CONNECTED: 'integration.connected', + INTEGRATION_DISCONNECTED: 'integration.disconnected', + // SCIM Tokens SCIM_TOKEN_CREATED: 'scim_token.created', SCIM_TOKEN_UPDATED: 'scim_token.updated', @@ -124,10 +130,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, AuditEvent.ATTACHMENT_UPLOADED, AuditEvent.SIEM_DESTINATION_TEST, ]); @@ -144,6 +146,7 @@ export const AuditResource = { API_KEY: 'api_key', OAUTH_CLIENT: 'oauth_client', OAUTH_GRANT: 'oauth_grant', + INTEGRATION: 'integration', SCIM_TOKEN: 'scim_token', SSO_PROVIDER: 'sso_provider', WORKSPACE_INVITATION: 'workspace_invitation', diff --git a/apps/server/src/common/events/event.contants.ts b/apps/server/src/common/events/event.contants.ts index b52a3b72..8c3c2b9b 100644 --- a/apps/server/src/common/events/event.contants.ts +++ b/apps/server/src/common/events/event.contants.ts @@ -17,6 +17,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 ac446d32..961e31fb 100644 --- a/apps/server/src/common/features.ts +++ b/apps/server/src/common/features.ts @@ -23,6 +23,8 @@ export const Feature = { PERSONAL_SPACES: 'spaces:personal', DOCX_EXPORT: 'export:docx', BASES: 'bases', + INTEGRATIONS: 'integrations', + INTEGRATIONS_ADVANCED: 'integrations:advanced', OAUTH: 'oauth', AI_CONTROLS: 'ai:controls', MCP_CONTROLS: 'mcp:controls', diff --git a/apps/server/src/common/helpers/utils.ts b/apps/server/src/common/helpers/utils.ts index aebe0538..b0e341fd 100644 --- a/apps/server/src/common/helpers/utils.ts +++ b/apps/server/src/common/helpers/utils.ts @@ -189,7 +189,7 @@ export function isUserDisabled(user: { return !!(user.deactivatedAt || user.deletedAt); } -const SENSITIVE_URL_PREFIXES = ['/api/sso/']; +const SENSITIVE_URL_PREFIXES = ['/api/sso/', '/api/integrations/oauth/']; export function redactSensitiveUrl(url: string): string { if (url && SENSITIVE_URL_PREFIXES.some((prefix) => url.includes(prefix))) { 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 00000000..25cbc5f8 --- /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 00000000..23b5b177 --- /dev/null +++ b/apps/server/src/common/proxy-fetch.ts @@ -0,0 +1,37 @@ +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; +} + +export const proxyFetch: typeof fetch = (input, init) => + (getProxyAwareFetch() ?? fetch)(input, init); diff --git a/apps/server/src/core/comment/comment.controller.ts b/apps/server/src/core/comment/comment.controller.ts index 3902c584..fbe0e064 100644 --- a/apps/server/src/core/comment/comment.controller.ts +++ b/apps/server/src/core/comment/comment.controller.ts @@ -145,7 +145,19 @@ export class CommentController { await this.pageAccessService.validateCanComment(page, user, workspace.id); - return this.commentService.update(comment, dto, user); + const updatedComment = await this.commentService.update(comment, dto, user); + + this.auditService.log({ + event: AuditEvent.COMMENT_UPDATED, + resourceType: AuditResource.COMMENT, + resourceId: comment.id, + spaceId: comment.spaceId, + metadata: { + pageId: comment.pageId, + }, + }); + + return updatedComment; } @HttpCode(HttpStatus.OK) diff --git a/apps/server/src/core/core.module.ts b/apps/server/src/core/core.module.ts index 5bd9be1c..e60a15b3 100644 --- a/apps/server/src/core/core.module.ts +++ b/apps/server/src/core/core.module.ts @@ -22,6 +22,10 @@ import { PublicSpaceModule } from './public-space/public-space.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 { FigmaModule } from './integration/providers/figma/figma.module'; +import { LinearModule } from './integration/providers/linear/linear.module'; import { FavoriteModule } from './favorite/favorite.module'; import { SessionModule } from './session/session.module'; import { ClsMiddleware } from 'nestjs-cls'; @@ -45,6 +49,10 @@ import { ClsMiddleware } from 'nestjs-cls'; LabelModule, NotificationModule, WatcherModule, + IntegrationModule, + GitHubModule, + FigmaModule, + LinearModule, SessionModule, ], }) @@ -55,6 +63,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/dto/integration.dto.spec.ts b/apps/server/src/core/integration/dto/integration.dto.spec.ts new file mode 100644 index 00000000..fad2af0b --- /dev/null +++ b/apps/server/src/core/integration/dto/integration.dto.spec.ts @@ -0,0 +1,57 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { + OAuthAuthorizeDto, + OAuthDisconnectDto, + UninstallIntegrationDto, +} from './integration.dto'; + +describe.each([ + ['UninstallIntegrationDto', UninstallIntegrationDto], + ['OAuthAuthorizeDto', OAuthAuthorizeDto], + ['OAuthDisconnectDto', OAuthDisconnectDto], +])('%s integrationId', (_name, Dto) => { + const violations = async (integrationId: unknown) => { + const errors = await validate(plainToInstance(Dto, { integrationId })); + return errors.flatMap((e) => Object.keys(e.constraints ?? {})); + }; + + it('accepts a uuid7', async () => { + expect(await violations('019a1b2c-3d4e-7f00-8a00-123456789abc')).toEqual([]); + }); + + it.each([ + ['a non-UUID string', 'integration-1'], + ['an empty string', ''], + ['a number', 42], + ['a missing value', undefined], + ])('rejects %s', async (_label, integrationId) => { + expect(await violations(integrationId)).toContain('isUuid'); + }); +}); + +describe('OAuthAuthorizeDto returnPath', () => { + const violations = async (returnPath: string) => { + const errors = await validate( + plainToInstance(OAuthAuthorizeDto, { + integrationId: '019a1b2c-3d4e-7f00-8a00-123456789abc', + returnPath, + }), + ); + return errors.flatMap((e) => Object.keys(e.constraints ?? {})); + }; + + it('accepts a workspace path with a query', async () => { + expect(await violations('/settings/account/connections?connect=github')).toEqual([]); + }); + + it.each([ + ['a protocol-relative path', '//evil.example'], + ['a backslash', '/\\evil.example'], + ['an escape character', '/settings\x1b[2J'], + ['a NUL byte', '/settings\x00'], + ['DEL', '/settings\x7f'], + ])('rejects %s', async (_label, returnPath) => { + expect(await violations(returnPath)).toContain('matches'); + }); +}); 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 00000000..689400e0 --- /dev/null +++ b/apps/server/src/core/integration/dto/integration.dto.ts @@ -0,0 +1,45 @@ +import { + IsNotEmpty, + IsOptional, + IsString, + IsUUID, + Matches, + MaxLength, +} from 'class-validator'; + +export class UninstallIntegrationDto { + @IsUUID() + integrationId: string; +} + +export class UnfurlDto { + // Caps the input the unfurl regexes run on + @IsNotEmpty() + @IsString() + @MaxLength(8192) + @Matches(/^[^\s\x00-\x1f\x7f]+$/) + url: string; +} + +export class OAuthAuthorizeDto { + @IsUUID() + integrationId: string; + + // A single leading slash, so "//host" can't redirect off the workspace origin + @IsOptional() + @IsString() + @MaxLength(512) + @Matches(/^\/(?!\/)[^\s\\\x00-\x1f\x7f]*$/) + returnPath?: string; +} + +export class OAuthDisconnectDto { + @IsUUID() + integrationId: string; +} + +export class OAuthInstallDto { + @IsNotEmpty() + @IsString() + type: string; +} diff --git a/apps/server/src/core/integration/dto/unfurl.dto.spec.ts b/apps/server/src/core/integration/dto/unfurl.dto.spec.ts new file mode 100644 index 00000000..344475f1 --- /dev/null +++ b/apps/server/src/core/integration/dto/unfurl.dto.spec.ts @@ -0,0 +1,36 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { UnfurlDto } from './integration.dto'; + +async function violations(url: string): Promise { + const errors = await validate(plainToInstance(UnfurlDto, { url })); + return errors.flatMap((e) => Object.keys(e.constraints ?? {})); +} + +describe('UnfurlDto', () => { + it('accepts an ordinary provider URL', async () => { + expect(await violations('https://github.com/o/r/pulls?q=is%3Aopen')).toEqual([]); + }); + + it('rejects a URL longer than 8192 characters', async () => { + expect(await violations('https://github.com/o/r/pulls?' + 'a'.repeat(9000))).toContain('maxLength'); + }); + + it('rejects a URL containing whitespace', async () => { + expect(await violations('https://github.com/o/r/pulls/' + '?'.repeat(100) + '\n')).toContain('matches'); + }); + + it.each([ + ['an escape', '\x1b'], + ['a NUL', '\x00'], + ['a backspace', '\x08'], + ['a unit separator', '\x1f'], + ['a DEL', '\x7f'], + ])('rejects a URL containing %s character', async (_label, char) => { + expect(await violations(`https://github.com/o/r/pull/1${char}[31m`)).toContain('matches'); + }); + + it('accepts non-ASCII characters in a URL', async () => { + expect(await violations('https://docs.google.com/document/d/abc/edit#heading=été')).toEqual([]); + }); +}); diff --git a/apps/server/src/core/integration/integration-connection.service.spec.ts b/apps/server/src/core/integration/integration-connection.service.spec.ts new file mode 100644 index 00000000..aee95576 --- /dev/null +++ b/apps/server/src/core/integration/integration-connection.service.spec.ts @@ -0,0 +1,73 @@ +import { IntegrationConnectionService } from './integration-connection.service'; + +const connectedAt = new Date('2026-10-07T09:00:00.000Z'); + +function buildService(rows: Record[]) { + const connectionRepo = { + findByUserAndWorkspace: jest.fn().mockResolvedValue(rows), + }; + return new IntegrationConnectionService( + connectionRepo as any, + {} as any, + { log: jest.fn() } as any, + ); +} + +describe('IntegrationConnectionService connections list', () => { + it("shows the Slack link's account name and id", async () => { + const service = buildService([ + { + integrationId: 'integration-slack', + type: 'slack', + providerUserId: 'U04ABCD1234', + metadata: { + account: { id: 'U04ABCD1234', displayName: 'Philip' }, + notifyEnabled: true, + }, + createdAt: connectedAt, + invalidatedAt: null, + }, + ]); + + await expect( + service.getUserConnections('user-1', 'workspace-1'), + ).resolves.toEqual([ + { + integrationId: 'integration-slack', + type: 'slack', + providerUserId: 'U04ABCD1234', + providerDisplayName: 'Philip', + connectedAt, + invalidatedAt: null, + }, + ]); + }); + + it('keeps the account of every other provider out of the list', async () => { + const service = buildService([ + { + integrationId: 'integration-github', + type: 'github', + providerUserId: '583231', + metadata: { + account: { id: '583231', displayName: 'Philip', username: 'Philipinho' }, + }, + createdAt: connectedAt, + invalidatedAt: null, + }, + ]); + + await expect( + service.getUserConnections('user-1', 'workspace-1'), + ).resolves.toEqual([ + { + integrationId: 'integration-github', + type: 'github', + providerUserId: null, + providerDisplayName: null, + connectedAt, + invalidatedAt: null, + }, + ]); + }); +}); 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 00000000..8c973022 --- /dev/null +++ b/apps/server/src/core/integration/integration-connection.service.ts @@ -0,0 +1,68 @@ +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { IntegrationConnectionRepo } from './repos/integration-connection.repo'; +import { IntegrationRepo } from './repos/integration.repo'; +import { + AUDIT_SERVICE, + IAuditService, +} from '../../integrations/audit/audit.service'; +import { + AuditEvent, + AuditResource, +} from '../../common/events/audit-events'; +import { ProviderAccount } from './registry/integration-provider.interface'; + +@Injectable() +export class IntegrationConnectionService { + constructor( + private readonly connectionRepo: IntegrationConnectionRepo, + private readonly integrationRepo: IntegrationRepo, + @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, + ) {} + + async getUserConnections(userId: string, workspaceId: string) { + const rows = await this.connectionRepo.findByUserAndWorkspace( + userId, + workspaceId, + ); + + return rows.map((row) => { + // Only Slack shows its linked account until the connections page uses it. + const isSlack = row.type === 'slack'; + const account = (row.metadata as { account?: ProviderAccount } | null) + ?.account; + return { + integrationId: row.integrationId, + type: row.type, + providerUserId: isSlack ? (row.providerUserId ?? null) : null, + providerDisplayName: isSlack ? (account?.displayName ?? null) : 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'); + } + + const disconnected = await this.connectionRepo.deleteByIntegrationAndUser( + integrationId, + userId, + ); + + if (disconnected) { + this.auditService.log({ + event: AuditEvent.INTEGRATION_DISCONNECTED, + resourceType: AuditResource.INTEGRATION, + resourceId: integrationId, + changes: { before: { provider: integration.type } }, + }); + } + } +} 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 00000000..951cfbe3 --- /dev/null +++ b/apps/server/src/core/integration/integration.controller.ts @@ -0,0 +1,78 @@ +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 { UninstallIntegrationDto } from './dto/integration.dto'; +import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory'; +import { + WorkspaceCaslAction, + WorkspaceCaslSubject, +} from '../casl/interfaces/workspace-ability.type'; + +@Controller('integrations') +export class IntegrationController { + constructor( + private readonly integrationService: IntegrationService, + private readonly connectionService: IntegrationConnectionService, + private readonly workspaceAbility: WorkspaceAbilityFactory, + ) {} + + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @Post('available') + async getAvailableIntegrations() { + return this.integrationService.getAvailableIntegrations(); + } + + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @Post('/') + async getInstalledIntegrations( + @AuthWorkspace() workspace: Workspace, + ) { + return this.integrationService.getInstalledIntegrations(workspace.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') + async getMyConnections( + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + return this.connectionService.getUserConnections(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 00000000..f8af541d --- /dev/null +++ b/apps/server/src/core/integration/integration.listener.ts @@ -0,0 +1,29 @@ +import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue } from 'bullmq'; +import { QueueJob, QueueName } from '../../integrations/queue/constants'; + +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'); + } +} 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 00000000..3b152145 --- /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.spec.ts b/apps/server/src/core/integration/integration.processor.spec.ts new file mode 100644 index 00000000..6bd262ba --- /dev/null +++ b/apps/server/src/core/integration/integration.processor.spec.ts @@ -0,0 +1,238 @@ +import { Logger, NotFoundException } from '@nestjs/common'; +import { Job } from 'bullmq'; +import { IntegrationProcessor } from './integration.processor'; +import { TokenInvalidError } from './registry/integration-provider.interface'; +import { QueueJob } from '../../integrations/queue/constants/queue.constants'; + +describe('IntegrationProcessor token refresh', () => { + const connections = [{ id: 'c1' }, { id: 'c2' }, { id: 'c3' }]; + const manyConnections = (count: number) => + Array.from({ length: count }, (_, i) => ({ id: `c${i + 1}` })); + const flushPromises = () => new Promise((resolve) => setImmediate(resolve)); + const refreshJob = { name: QueueJob.INTEGRATION_TOKEN_REFRESH } as Job; + + function build(refreshAccessToken: jest.Mock, rows: object[] = connections) { + const connectionRepo = { + findExpiringTokens: jest.fn().mockResolvedValue(rows), + invalidate: jest.fn().mockResolvedValue(undefined), + }; + const oauthService = { refreshAccessToken }; + const locks = new Map(); + const redis = { + set: jest.fn(async (key: string, value: string) => { + if (locks.has(key)) return null; + locks.set(key, value); + return 'OK'; + }), + eval: jest.fn(async (_script: string, _numKeys: number, key: string, token: string) => { + if (locks.get(key) !== token) return 0; + locks.delete(key); + return 1; + }), + }; + const processor = new IntegrationProcessor( + connectionRepo as any, + oauthService as any, + { getOrThrow: () => redis } as any, + ); + return { processor, connectionRepo, oauthService, locks }; + } + + it('loads at most 500 connections expiring within 15 minutes', async () => { + const { processor, connectionRepo } = build(jest.fn()); + + await processor.process(refreshJob); + + expect(connectionRepo.findExpiringTokens).toHaveBeenCalledWith( + 15 * 60 * 1000, + 500, + ); + }); + + it('refreshes every selected connection exactly once', async () => { + const rows = manyConnections(12); + const refreshAccessToken = jest.fn().mockResolvedValue(undefined); + const { processor } = build(refreshAccessToken, rows); + + await processor.process(refreshJob); + + expect(refreshAccessToken.mock.calls.map(([row]) => row.id).sort()).toEqual( + rows.map((row) => row.id).sort(), + ); + }); + + it('refreshes five connections at a time', async () => { + let running = 0; + let mostRunning = 0; + const refreshAccessToken = jest.fn(async () => { + running += 1; + mostRunning = Math.max(mostRunning, running); + await flushPromises(); + running -= 1; + }); + const { processor } = build(refreshAccessToken, manyConnections(12)); + + await processor.process(refreshJob); + + expect(refreshAccessToken).toHaveBeenCalledTimes(12); + expect(mostRunning).toBe(5); + }); + + it('keeps refreshing the rest while one refresh hangs', async () => { + const refreshAccessToken = jest + .fn() + .mockImplementationOnce(() => new Promise(() => {})) + .mockResolvedValue(undefined); + const { processor } = build(refreshAccessToken, manyConnections(8)); + const controller = new AbortController(); + + const run = processor.process(refreshJob, undefined, controller.signal); + await flushPromises(); + + expect(refreshAccessToken).toHaveBeenCalledTimes(8); + controller.abort('lock lost'); + await expect(run).rejects.toThrow('lock lost'); + }); + + it('never retires a connection itself and keeps refreshing the rest', async () => { + const refreshAccessToken = jest + .fn() + .mockRejectedValueOnce(new TokenInvalidError()) + .mockRejectedValueOnce(new NotFoundException()) + .mockRejectedValueOnce(new Error('network')); + const { processor, connectionRepo } = build(refreshAccessToken); + + await processor.process({ + name: QueueJob.INTEGRATION_TOKEN_REFRESH, + } as Job); + + expect(refreshAccessToken).toHaveBeenCalledTimes(3); + expect(connectionRepo.invalidate).not.toHaveBeenCalled(); + }); + + it('skips a connection whose refresh lock someone else holds', async () => { + const refreshAccessToken = jest.fn().mockResolvedValue('token'); + const { processor, locks } = build(refreshAccessToken); + locks.set('integration:refresh:c2', 'request-holder'); + + await processor.process(refreshJob); + + expect(refreshAccessToken.mock.calls.map(([row]) => row.id)).toEqual(['c1', 'c3']); + expect([...locks]).toEqual([['integration:refresh:c2', 'request-holder']]); + }); + + it('holds the lock during each refresh and releases it whether the refresh succeeds or fails', async () => { + const heldDuringRefresh: boolean[] = []; + let locks: Map; + const refreshAccessToken = jest.fn(async ({ id }: { id: string }) => { + heldDuringRefresh.push(locks.has(`integration:refresh:${id}`)); + if (id === 'c2') throw new Error('network'); + return 'token'; + }); + const built = build(refreshAccessToken); + locks = built.locks; + + await built.processor.process(refreshJob); + + expect(heldDuringRefresh).toEqual([true, true, true]); + expect(locks.size).toBe(0); + }); + + describe('cancellation', () => { + const job = { id: 'j1', name: QueueJob.INTEGRATION_TOKEN_REFRESH } as Job; + const hang = () => new Promise(() => {}); + + afterEach(() => jest.restoreAllMocks()); + + it('starts no further refreshes once the job is aborted', async () => { + const finishers: Array<(token: string) => void> = []; + const refreshAccessToken = jest.fn( + () => new Promise((resolve) => finishers.push(resolve)), + ); + const { processor } = build(refreshAccessToken, manyConnections(7)); + const controller = new AbortController(); + + const run = processor.process(job, undefined, controller.signal); + await flushPromises(); + expect(refreshAccessToken).toHaveBeenCalledTimes(5); + controller.abort('lock lost'); + + await expect(run).rejects.toThrow('lock lost'); + finishers.forEach((finish) => finish('token')); + await flushPromises(); + expect(refreshAccessToken).toHaveBeenCalledTimes(5); + }); + + it('fails a hung run when the job timeout fires', async () => { + const timeout = new AbortController(); + jest.spyOn(AbortSignal, 'timeout').mockReturnValue(timeout.signal); + const { processor } = build(jest.fn(hang)); + + const run = processor.process( + job, + undefined, + new AbortController().signal, + ); + await flushPromises(); + timeout.abort(new DOMException('timed out', 'TimeoutError')); + + await expect(run).rejects.toThrow('timed out'); + }, 1000); + + it('logs the connections in flight when aborted', async () => { + const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(); + const { processor } = build(jest.fn(hang)); + const controller = new AbortController(); + + const run = processor.process(job, undefined, controller.signal); + await flushPromises(); + controller.abort('lock lost'); + + await expect(run).rejects.toThrow(); + expect(warn.mock.calls).toEqual([ + [ + 'Token refresh aborted while refreshing connections c1, c2, c3: lock lost', + ], + ]); + }); + + it('logs the load step when aborted before any refresh starts', async () => { + const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(); + const { processor, connectionRepo } = build(jest.fn()); + connectionRepo.findExpiringTokens.mockImplementation(hang); + const controller = new AbortController(); + + const run = processor.process(job, undefined, controller.signal); + await flushPromises(); + controller.abort('lock lost'); + + await expect(run).rejects.toThrow(); + expect(warn.mock.calls).toEqual([ + ['Token refresh aborted while loading expiring tokens: lock lost'], + ]); + }); + + it('does not log an abort after the run completes', async () => { + const timeout = new AbortController(); + jest.spyOn(AbortSignal, 'timeout').mockReturnValue(timeout.signal); + const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(); + const { processor } = build(jest.fn().mockResolvedValue('token')); + + await processor.process(job, undefined, new AbortController().signal); + timeout.abort(new DOMException('timed out', 'TimeoutError')); + + expect(warn).not.toHaveBeenCalled(); + }); + + it('cancels every job whose lock renewal failed', () => { + const { processor } = build(jest.fn()); + const cancelJob = jest.fn(); + Object.assign(processor, { _worker: { cancelJob } }); + + processor.onLockRenewalFailed(['j1', 'j2']); + + expect(cancelJob).toHaveBeenCalledWith('j1', 'lock lost'); + expect(cancelJob).toHaveBeenCalledWith('j2', 'lock lost'); + }); + }); +}); 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 00000000..818f3877 --- /dev/null +++ b/apps/server/src/core/integration/integration.processor.ts @@ -0,0 +1,131 @@ +import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq'; +import { Logger } from '@nestjs/common'; +import { Job } from 'bullmq'; +import { QueueJob, QueueName } from '../../integrations/queue/constants'; +import { IntegrationConnectionRepo } from './repos/integration-connection.repo'; +import { OAuthService } from './oauth/oauth.service'; +import { RedisService } from '@nestjs-labs/nestjs-ioredis'; +import { RefreshLock } from './utils/refresh-lock'; + +const TOKEN_REFRESH_WINDOW_MS = 15 * 60 * 1000; +// Even at 2 s per refresh, a full batch finishes inside JOB_TIMEOUT_MS. +const TOKEN_REFRESH_BATCH_SIZE = 500; +const TOKEN_REFRESH_CONCURRENCY = 5; +const JOB_TIMEOUT_MS = 5 * 60 * 1000; + +@Processor(QueueName.INTEGRATION_QUEUE) +export class IntegrationProcessor extends WorkerHost { + private readonly logger = new Logger(IntegrationProcessor.name); + private readonly refreshLock: RefreshLock; + + constructor( + private readonly connectionRepo: IntegrationConnectionRepo, + private readonly oauthService: OAuthService, + private readonly redisService: RedisService, + ) { + super(); + this.refreshLock = new RefreshLock(this.redisService.getOrThrow()); + } + + async process(job: Job, token?: string, signal?: AbortSignal): Promise { + const timeout = AbortSignal.timeout(JOB_TIMEOUT_MS); + const jobSignal = signal ? AbortSignal.any([signal, timeout]) : timeout; + // a hung db query can't be cancelled, so stop waiting on it and free the worker + return Promise.race([ + this.runJob(job, jobSignal), + new Promise((_, reject) => { + jobSignal.addEventListener( + 'abort', + () => + reject(new Error(`Job ${job.id} aborted: ${jobSignal.reason}`)), + { once: true }, + ); + }), + ]); + } + + private async runJob(job: Job, signal: AbortSignal): Promise { + switch (job.name) { + case QueueJob.INTEGRATION_TOKEN_REFRESH: + await this.handleTokenRefresh(signal); + break; + default: + this.logger.warn(`Unknown job: ${job.name}`); + } + } + + @OnWorkerEvent('error') + onError(err: Error): void { + this.logger.error(`Worker error: ${err.message}`); + } + + @OnWorkerEvent('lockRenewalFailed') + onLockRenewalFailed(jobIds: string[]): void { + for (const jobId of jobIds) { + this.worker.cancelJob(jobId, 'lock lost'); + } + } + + private async handleTokenRefresh(signal: AbortSignal): Promise { + const inFlight = new Set(); + const logAbort = () => { + const step = + inFlight.size > 0 + ? `refreshing connections ${[...inFlight].join(', ')}` + : 'loading expiring tokens'; + this.logger.warn(`Token refresh aborted while ${step}: ${signal.reason}`); + }; + signal.addEventListener('abort', logAbort, { once: true }); + + try { + const connections = await this.connectionRepo.findExpiringTokens( + TOKEN_REFRESH_WINDOW_MS, + TOKEN_REFRESH_BATCH_SIZE, + ); + + if (connections.length === 0) { + return; + } + + let next = 0; + let refreshed = 0; + // Each connection is taken once, and refreshes of different rows never conflict. + const refreshRemaining = async () => { + while (next < connections.length) { + signal.throwIfAborted(); + const connection = connections[next++]; + inFlight.add(connection.id); + const lockToken = await this.refreshLock.acquire(connection.id); + // Someone else is refreshing this connection right now. + if (!lockToken) { + inFlight.delete(connection.id); + continue; + } + try { + await this.oauthService.refreshAccessToken(connection); + refreshed += 1; + } catch (err) { + this.logger.error( + `Token refresh failed for connection ${connection.id}: ${(err as Error).message}`, + ); + } finally { + inFlight.delete(connection.id); + await this.refreshLock.release(connection.id, lockToken); + } + } + }; + await Promise.all( + Array.from( + { length: Math.min(TOKEN_REFRESH_CONCURRENCY, connections.length) }, + () => refreshRemaining(), + ), + ); + + this.logger.debug( + `Refreshed ${refreshed} of ${connections.length} expiring token(s)`, + ); + } finally { + signal.removeEventListener('abort', logAbort); + } + } +} 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 00000000..2a2c911e --- /dev/null +++ b/apps/server/src/core/integration/integration.service.ts @@ -0,0 +1,63 @@ +import { Inject, 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'; +import { + AUDIT_SERVICE, + IAuditService, +} from '../../integrations/audit/audit.service'; +import { + AuditEvent, + AuditResource, +} from '../../common/events/audit-events'; + +@Injectable() +export class IntegrationService { + constructor( + @InjectKysely() private readonly db: KyselyDB, + private readonly integrationRepo: IntegrationRepo, + private readonly connectionRepo: IntegrationConnectionRepo, + private readonly registry: IntegrationRegistry, + @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, + ) {} + + async getAvailableIntegrations() { + return this.registry.getAvailableIntegrations(); + } + + async getInstalledIntegrations( + workspaceId: string, + ): Promise<(Integration & { unfurlHosts?: string[] })[]> { + const integrations = + await this.integrationRepo.findAllByWorkspace(workspaceId); + return integrations.map((integration) => { + const unfurlHosts = this.registry + .getProvider(integration.type) + ?.getUnfurlHosts?.((integration.settings as Record) ?? {}); + return unfurlHosts ? { ...integration, unfurlHosts } : integration; + }); + } + + 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'); + } + // Soft delete doesn't cascade to connections + await executeTx(this.db, async (trx) => { + await this.connectionRepo.deleteByIntegration(integrationId, trx); + await this.integrationRepo.softDelete(integrationId, trx); + }); + + this.auditService.log({ + event: AuditEvent.INTEGRATION_UNINSTALLED, + resourceType: AuditResource.INTEGRATION, + resourceId: integrationId, + changes: { before: { provider: integration.type } }, + }); + } +} diff --git a/apps/server/src/core/integration/oauth/credential-env.spec.ts b/apps/server/src/core/integration/oauth/credential-env.spec.ts new file mode 100644 index 00000000..6864e0ac --- /dev/null +++ b/apps/server/src/core/integration/oauth/credential-env.spec.ts @@ -0,0 +1,23 @@ +import { credentialEnvKey } from './credential-env'; + +describe('credentialEnvKey', () => { + it('names credentials after the provider type by default', () => { + expect(credentialEnvKey('CLIENT_ID', 'github')).toBe( + 'INTEGRATION_GITHUB_CLIENT_ID', + ); + expect(credentialEnvKey('CLIENT_SECRET', 'gitlab', {})).toBe( + 'INTEGRATION_GITLAB_CLIENT_SECRET', + ); + }); + + it('names credentials after the shared authorization server when declared', () => { + expect( + credentialEnvKey('CLIENT_ID', 'jira', { credentialsKey: 'atlassian' }), + ).toBe('INTEGRATION_ATLASSIAN_CLIENT_ID'); + expect( + credentialEnvKey('CLIENT_SECRET', 'google_docs', { + credentialsKey: 'google', + }), + ).toBe('INTEGRATION_GOOGLE_CLIENT_SECRET'); + }); +}); diff --git a/apps/server/src/core/integration/oauth/credential-env.ts b/apps/server/src/core/integration/oauth/credential-env.ts new file mode 100644 index 00000000..6e7bea96 --- /dev/null +++ b/apps/server/src/core/integration/oauth/credential-env.ts @@ -0,0 +1,11 @@ +import { OAuthConfig } from '../registry/integration-provider.interface'; + +// Products on one authorization server (Atlassian, Google) share credentials via credentialsKey +export function credentialEnvKey( + field: 'CLIENT_ID' | 'CLIENT_SECRET', + type: string, + oauthConfig?: Pick, +): string { + const key = oauthConfig?.credentialsKey ?? type; + return `INTEGRATION_${key.toUpperCase()}_${field}`; +} diff --git a/apps/server/src/core/integration/oauth/oauth-complete-auth.filter.ts b/apps/server/src/core/integration/oauth/oauth-complete-auth.filter.ts new file mode 100644 index 00000000..4fd75c59 --- /dev/null +++ b/apps/server/src/core/integration/oauth/oauth-complete-auth.filter.ts @@ -0,0 +1,27 @@ +import { + ArgumentsHost, + Catch, + ExceptionFilter, + ForbiddenException, + Logger, + UnauthorizedException, +} from '@nestjs/common'; +import { FastifyReply, FastifyRequest } from 'fastify'; +import { COMPLETION_FALLBACK_REDIRECT } from './oauth.utils'; + +// Redirects a browser back into the app instead of showing a JSON auth error. +@Catch(UnauthorizedException, ForbiddenException) +export class OAuthCompleteAuthFilter implements ExceptionFilter { + private readonly logger = new Logger(OAuthCompleteAuthFilter.name); + + catch( + exception: UnauthorizedException | ForbiddenException, + host: ArgumentsHost, + ) { + const http = host.switchToHttp(); + this.logger.warn( + `OAuth completion on ${http.getRequest().host} refused without a usable session: ${exception.message}`, + ); + http.getResponse().redirect(COMPLETION_FALLBACK_REDIRECT, 302); + } +} diff --git a/apps/server/src/core/integration/oauth/oauth-connect.spec.ts b/apps/server/src/core/integration/oauth/oauth-connect.spec.ts new file mode 100644 index 00000000..325bc1d8 --- /dev/null +++ b/apps/server/src/core/integration/oauth/oauth-connect.spec.ts @@ -0,0 +1,28 @@ +import { supportsOAuthConnect } from './oauth-connect'; + +const base = { authUrl: 'https://p.example/authorize', tokenUrl: 'https://p.example/token', scopes: [] }; + +describe('supportsOAuthConnect', () => { + it('is false for providers without OAuth', () => { + expect(supportsOAuthConnect(undefined)).toBe(false); + }); + + it('is true for per-user providers', () => { + expect(supportsOAuthConnect(base)).toBe(true); + expect(supportsOAuthConnect({ ...base, connectionScope: 'user' })).toBe(true); + }); + + it('is false for workspace-scoped providers without an identity flow', () => { + expect(supportsOAuthConnect({ ...base, connectionScope: 'workspace' })).toBe(false); + }); + + it('is true for workspace-scoped providers with an identity flow', () => { + expect( + supportsOAuthConnect({ + ...base, + connectionScope: 'workspace', + identity: { authUrl: 'https://p.example/oidc', tokenUrl: 'https://p.example/oidc/token', scopes: ['openid'] }, + }), + ).toBe(true); + }); +}); diff --git a/apps/server/src/core/integration/oauth/oauth-connect.ts b/apps/server/src/core/integration/oauth/oauth-connect.ts new file mode 100644 index 00000000..4e19bae1 --- /dev/null +++ b/apps/server/src/core/integration/oauth/oauth-connect.ts @@ -0,0 +1,9 @@ +import { OAuthConfig } from '../registry/integration-provider.interface'; + +// Workspace-scoped providers can only connect through the identity flow +export function supportsOAuthConnect( + oauth: OAuthConfig | undefined, +): boolean { + if (!oauth) return false; + return (oauth.connectionScope ?? 'user') === 'user' || !!oauth.identity; +} diff --git a/apps/server/src/core/integration/oauth/oauth-identity-link.spec.ts b/apps/server/src/core/integration/oauth/oauth-identity-link.spec.ts new file mode 100644 index 00000000..6576c714 --- /dev/null +++ b/apps/server/src/core/integration/oauth/oauth-identity-link.spec.ts @@ -0,0 +1,95 @@ +import { BadRequestException } from '@nestjs/common'; +import { OAuthService } from './oauth.service'; + +const integrationId = 'integration-1'; +const workspaceId = 'workspace-1'; +const providerUserId = 'U-slack-1'; + +function buildService(existingLinkUserId?: string) { + const provider = { + definition: { + oauth: { + connectionScope: 'workspace', + identity: { tokenUrl: 'https://slack.example/token' }, + }, + }, + resolveIdentity: jest.fn().mockResolvedValue({ account: { id: providerUserId } }), + }; + + const findUserLink = jest + .fn() + .mockResolvedValue( + existingLinkUserId ? { userId: existingLinkUserId } : undefined, + ); + const upsertUserLink = jest.fn().mockResolvedValue({ id: 'connection-1' }); + + const service = new OAuthService( + {} as any, + {} as any, + {} as any, + { getProvider: jest.fn().mockReturnValue(provider) } as any, + { + findById: jest.fn().mockResolvedValue({ id: integrationId, workspaceId, settings: {} }), + } as any, + { findUserLink, upsertUserLink } as any, + {} as any, + {} as any, + {} as any, + {} as any, + { log: jest.fn() } as any, + { getOrThrow: () => ({}) } as any, + ); + + // Stub the live token exchange with the provider + (service as any).requestTokens = jest + .fn() + .mockResolvedValue({ access_token: 'token' }); + + return { service, findUserLink, upsertUserLink }; +} + +function connectState(userId: string) { + return { + flow: 'connect' as const, + integrationId, + type: 'slack', + userId, + workspaceId, + }; +} + +describe('OAuthService identity link rebinding', () => { + it('refuses a provider account already linked to another Docmost user', async () => { + const { service, upsertUserLink } = buildService('user-a'); + + await expect( + service.exchangeCodeForTokens('slack', 'code', connectState('user-b')), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(upsertUserLink).not.toHaveBeenCalled(); + }); + + it('allows the same user to relink their own provider account', async () => { + const { service, upsertUserLink } = buildService('user-a'); + + await expect( + service.exchangeCodeForTokens('slack', 'code', connectState('user-a')), + ).resolves.toEqual({ id: 'connection-1' }); + + expect(upsertUserLink).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-a', providerUserId }), + ); + }); + + it('allows a first-time link', async () => { + const { service, upsertUserLink } = buildService(); + + await expect( + service.exchangeCodeForTokens('slack', 'code', connectState('user-b')), + ).resolves.toEqual({ id: 'connection-1' }); + + expect(upsertUserLink).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-b', providerUserId }), + ); + }); +}); diff --git a/apps/server/src/core/integration/oauth/oauth.controller.spec.ts b/apps/server/src/core/integration/oauth/oauth.controller.spec.ts new file mode 100644 index 00000000..4174aeb6 --- /dev/null +++ b/apps/server/src/core/integration/oauth/oauth.controller.spec.ts @@ -0,0 +1,553 @@ +import { + BadRequestException, + ForbiddenException, + Logger, +} from '@nestjs/common'; +import { OAuthController } from './oauth.controller'; +import { OAuthCompleteAuthFilter } from './oauth-complete-auth.filter'; +import { OAuthStatePayload } from './oauth.service'; +import WorkspaceAbilityFactory from '../../casl/abilities/workspace-ability.factory'; +import { UserRole } from '../../../common/helpers/types/permission'; +import { User, Workspace } from '@docmost/db/types/entity.types'; +import { + IdentityEmailMismatchError, + IdentityTenantMismatchError, + IntegrationTenantInUseError, +} from '../registry/integration-provider.interface'; +import { + EncryptionPurpose, + EncryptionService, +} from '../../../integrations/encryption/encryption.service'; +import { Test } from '@nestjs/testing'; +import { + FastifyAdapter, + NestFastifyApplication, +} from '@nestjs/platform-fastify'; +import { JwtStrategy } from '../../auth/strategies/jwt.strategy'; +import { OAuthService } from './oauth.service'; +import { IntegrationConnectionService } from '../integration-connection.service'; +import { IntegrationRegistry } from '../registry/integration-registry'; +import { LicenseCheckService } from '../../../integrations/environment/license-check.service'; +import { EnvironmentService } from '../../../integrations/environment/environment.service'; +import { UserRepo } from '@docmost/db/repos/user/user.repo'; +import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; +import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo'; +import { SessionActivityService } from '../../session/session-activity.service'; + +const workspace = { id: 'workspace-1' } as Workspace; +const encryptionService = new EncryptionService({ + getAppSecret: () => 'test-secret', +} as any); +const environmentService = { + isHttps: () => true, + isCloud: () => false, + getAppUrl: () => 'https://acme.example', + getSubdomainHost: () => undefined, +} as any; +const cloudEnvironment = { + ...environmentService, + isCloud: () => true, + getAppUrl: () => 'https://app.docmost.example', + getSubdomainHost: () => 'docmost.example', +}; +const authorizationUrl = 'https://provider.example/authorize'; +const fallbackRedirect = '/settings/account/connections?error=oauth_failed'; + +function userWithRole(role: UserRole): User { + return { id: `user-${role}`, role } as User; +} + +function cookieReply() { + return { setCookie: jest.fn() } as any; +} + +function redirectReply() { + return { redirect: jest.fn(), clearCookie: jest.fn() } as any; +} + +const signedStatePayload: OAuthStatePayload = { + flow: 'connect', + integrationId: 'integration-1', + type: 'slack', + userId: 'user-1', + workspaceId: 'workspace-1', + returnUrl: 'https://acme.example', + returnPath: '/s/general/p/roadmap', + nonce: 'nonce-1', + exp: Date.now() + 10 * 60_000, +}; + +function buildController( + state: Partial = {}, + environment = environmentService, +) { + const getInstallAuthorizationUrl = jest.fn().mockResolvedValue({ authorizationUrl, nonce: 'install-nonce' }); + const getAuthorizationUrl = jest.fn().mockResolvedValue({ authorizationUrl, type: 'github', nonce: 'connect-nonce' }); + const exchangeCodeForTokens = jest.fn().mockResolvedValue({}); + const verifySignedState = jest.fn((signed: string) => + signed === 'signed-state' ? { ...signedStatePayload, ...state } : null, + ); + + const controller = new OAuthController( + { getInstallAuthorizationUrl, getAuthorizationUrl, exchangeCodeForTokens, verifySignedState } as any, + {} as any, + new WorkspaceAbilityFactory(), + { hasFeature: jest.fn().mockReturnValue(true) } as any, + { getProvider: jest.fn().mockReturnValue({ definition: {} }) } as any, + encryptionService, + environment, + ); + + return { controller, exchangeCodeForTokens, getInstallAuthorizationUrl, getAuthorizationUrl }; +} + +function issueTicket(fields: Record = {}, purpose = EncryptionPurpose.OAUTH_COMPLETION) { + return encryptionService.encrypt( + JSON.stringify({ + purpose: 'oauth-completion', + state: 'signed-state', + code: 'auth-code', + exp: Date.now() + 60_000, + ...fields, + }), + purpose, + ); +} + +let warn: jest.SpyInstance; +beforeAll(() => { + jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); +}); +beforeEach(() => warn.mockClear()); +afterAll(() => jest.restoreAllMocks()); + +describe('OAuthController install authorization', () => { + it('refuses a workspace member', async () => { + const { controller, getInstallAuthorizationUrl } = buildController(); + const res = cookieReply(); + + await expect( + controller.installAndAuthorize( + { type: 'slack' } as any, + userWithRole(UserRole.MEMBER), + workspace, + res, + ), + ).rejects.toBeInstanceOf(ForbiddenException); + + expect(getInstallAuthorizationUrl).not.toHaveBeenCalled(); + expect(res.setCookie).not.toHaveBeenCalled(); + }); + + it.each([UserRole.ADMIN, UserRole.OWNER])('allows a workspace %s', async (role) => { + const { controller, getInstallAuthorizationUrl } = buildController(); + const res = cookieReply(); + + await expect( + controller.installAndAuthorize( + { type: 'slack' } as any, + userWithRole(role), + workspace, + res, + ), + ).resolves.toEqual({ authorizationUrl }); + + expect(getInstallAuthorizationUrl).toHaveBeenCalledWith( + 'slack', + workspace.id, + `user-${role}`, + ); + expect(res.setCookie).toHaveBeenCalledWith('integration_oauth_slack', 'install-nonce', expect.objectContaining({ + httpOnly: true, secure: true, sameSite: 'lax', path: '/api/integrations/oauth', + })); + }); +}); + +describe('OAuthController nonce cookie', () => { + const cookieOptions = { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/api/integrations/oauth', + }; + + it('binds a connect flow to the browser that started it', async () => { + const { controller } = buildController(); + const res = cookieReply(); + + await expect( + controller.authorize( + { integrationId: 'integration-1' } as any, + userWithRole(UserRole.MEMBER), + workspace, + res, + ), + ).resolves.toEqual({ authorizationUrl }); + + expect(res.setCookie).toHaveBeenCalledWith( + 'integration_oauth_github', + 'connect-nonce', + expect.objectContaining(cookieOptions), + ); + const { expires } = res.setCookie.mock.calls[0][2]; + expect(expires.getTime() - Date.now()).toBeGreaterThan(9 * 60_000); + expect(expires.getTime() - Date.now()).toBeLessThanOrEqual(10 * 60_000); + }); +}); + +describe('OAuthController callback', () => { + it('hands the code to the workspace host instead of exchanging it', async () => { + const { controller, exchangeCodeForTokens } = buildController(); + const res = redirectReply(); + const before = Date.now(); + + await controller.callback('slack', 'auth-code', 'signed-state', res); + + expect(exchangeCodeForTokens).not.toHaveBeenCalled(); + const [location, status] = res.redirect.mock.calls[0]; + expect(status).toBe(302); + const url = new URL(location); + expect(`${url.origin}${url.pathname}`).toBe( + 'https://acme.example/api/integrations/oauth/complete', + ); + const ticket = JSON.parse( + encryptionService.decrypt( + url.searchParams.get('ticket') ?? '', + EncryptionPurpose.OAUTH_COMPLETION, + ), + ); + expect(ticket).toEqual({ + purpose: 'oauth-completion', + state: 'signed-state', + code: 'auth-code', + exp: expect.any(Number), + }); + expect(ticket.exp).toBeGreaterThanOrEqual(before + 60_000); + expect(ticket.exp).toBeLessThanOrEqual(Date.now() + 60_000); + }); + + it('sends a cancelled consent back with oauth_failed', async () => { + const { controller, exchangeCodeForTokens } = buildController(); + const res = redirectReply(); + + await controller.callback('slack', undefined, 'signed-state', res); + + expect(exchangeCodeForTokens).not.toHaveBeenCalled(); + expect(res.redirect).toHaveBeenCalledWith( + 'https://acme.example/s/general/p/roadmap?error=oauth_failed', + 302, + ); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('slack')); + }); + + it("logs the provider's error code and the first line of its description when no code comes back", async () => { + const { controller } = buildController(); + const res = redirectReply(); + + await controller.callback( + 'slack', + undefined, + 'signed-state', + res, + 'invalid_client', + "AADSTS650052: The app needs access to a service that your organization hasn't subscribed to.\r\nTrace ID: 1f2e\r\nCorrelation ID: 9a8b", + ); + + expect(warn).toHaveBeenCalledTimes(1); + const line = warn.mock.calls[0][0] as string; + expect(line).toContain('slack'); + expect(line).toContain('invalid_client'); + expect(line).toContain( + "AADSTS650052: The app needs access to a service that your organization hasn't subscribed to.", + ); + expect(line).not.toContain('Trace ID'); + expect(line).not.toContain('Correlation ID'); + expect(res.redirect).toHaveBeenCalledWith( + 'https://acme.example/s/general/p/roadmap?error=oauth_failed', + 302, + ); + }); + + it('logs only a printable, bounded line from the provider error', async () => { + const { controller } = buildController(); + const res = redirectReply(); + + await controller.callback( + 'slack', + undefined, + 'signed-state', + res, + 'Access_Denied\nforged', + `denied\u001b[31m‮${'x'.repeat(1000)}`, + ); + + const line = warn.mock.calls[0][0] as string; + expect(line).not.toContain('Access_Denied'); + expect(line).not.toMatch(/[^\x20-\x7e]/); + expect(line).toContain('denied[31m'); + expect(line.length).toBeLessThan(400); + }); + + it('refuses a state signed for a different provider', async () => { + const { controller, exchangeCodeForTokens } = buildController(); + const res = redirectReply(); + + await controller.callback('github', 'auth-code', 'signed-state', res); + + expect(exchangeCodeForTokens).not.toHaveBeenCalled(); + expect(res.redirect).toHaveBeenCalledTimes(1); + expect(res.redirect).toHaveBeenCalledWith( + 'https://acme.example/s/general/p/roadmap?error=oauth_failed', + 302, + ); + }); + + it('rejects a state that does not verify', async () => { + const { controller } = buildController(); + const res = redirectReply(); + + await expect( + controller.callback('slack', 'auth-code', 'forged-state', res), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(res.redirect).not.toHaveBeenCalled(); + }); + + describe('return host', () => { + it.each([ + ['a custom domain', cloudEnvironment, 'https://wiki.tenant.example'], + ['a lookalike of the cloud domain', cloudEnvironment, 'https://acmedocmost.example'], + ['a userinfo trick', cloudEnvironment, 'https://acme.docmost.example@evil.example'], + [ + 'a subdomain while self-hosted', + { ...environmentService, getSubdomainHost: () => 'docmost.example' }, + 'https://acme.docmost.example', + ], + [ + 'any host when the subdomain host is unset', + { ...cloudEnvironment, getSubdomainHost: () => undefined }, + 'https://acme.undefined', + ], + ['a malformed return URL', cloudEnvironment, 'not a url'], + ])('refuses to hand a ticket to %s', async (_label, environment, returnUrl) => { + const { controller, exchangeCodeForTokens } = buildController( + { returnUrl }, + environment, + ); + const res = redirectReply(); + + await controller.callback('slack', 'auth-code', 'signed-state', res); + + expect(res.redirect.mock.calls[0][0]).not.toContain('ticket='); + expect(exchangeCodeForTokens).not.toHaveBeenCalled(); + expect(res.redirect).toHaveBeenCalledTimes(1); + expect(res.redirect).toHaveBeenCalledWith( + `${returnUrl}/s/general/p/roadmap?error=oauth_failed`, + 302, + ); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('is not a Docmost host'), + ); + }); + + it.each([ + ['the APP_URL host on cloud', cloudEnvironment, 'https://app.docmost.example'], + ['a cloud workspace subdomain', cloudEnvironment, 'https://acme.docmost.example'], + [ + 'a cloud workspace subdomain with a port', + { + ...cloudEnvironment, + getAppUrl: () => 'http://localhost:3000', + getSubdomainHost: () => 'localhost:3000', + }, + 'http://acme.localhost:3000', + ], + ])('hands the ticket to %s', async (_label, environment, returnUrl) => { + const { controller } = buildController({ returnUrl }, environment); + const res = redirectReply(); + + await controller.callback('slack', 'auth-code', 'signed-state', res); + + const url = new URL(res.redirect.mock.calls[0][0]); + expect(`${url.origin}${url.pathname}`).toBe( + `${returnUrl}/api/integrations/oauth/complete`, + ); + expect(url.searchParams.get('ticket')).toBeTruthy(); + }); + }); +}); + +describe('OAuthController complete', () => { + const sessionUser = { id: 'user-1' } as User; + + async function complete( + options: { + ticket?: string; + user?: User; + sessionWorkspace?: Workspace; + cookies?: Record; + state?: Partial; + error?: Error; + } = {}, + ) { + const { + user = sessionUser, + sessionWorkspace = workspace, + cookies = { integration_oauth_slack: 'nonce-1' }, + state = {}, + } = options; + const ticket = 'ticket' in options ? options.ticket : issueTicket(); + const { controller, exchangeCodeForTokens } = buildController(state); + const res = redirectReply(); + if (options.error) exchangeCodeForTokens.mockRejectedValue(options.error); + + await controller.complete( + ticket, + user, + sessionWorkspace, + { cookies } as any, + res, + ); + + return { res, exchangeCodeForTokens }; + } + + it('exchanges the code for the user who started the flow', async () => { + const { controller, exchangeCodeForTokens } = buildController(); + const callbackRes = redirectReply(); + await controller.callback('slack', 'auth-code', 'signed-state', callbackRes); + const ticket = new URL(callbackRes.redirect.mock.calls[0][0]).searchParams.get('ticket'); + const res = redirectReply(); + + await controller.complete( + ticket, + sessionUser, + workspace, + { cookies: { integration_oauth_slack: 'nonce-1' } } as any, + res, + ); + + expect(exchangeCodeForTokens).toHaveBeenCalledWith( + 'slack', + 'auth-code', + expect.objectContaining({ type: 'slack', userId: 'user-1', workspaceId: 'workspace-1' }), + ); + expect(res.clearCookie).toHaveBeenCalledWith('integration_oauth_slack', { + path: '/api/integrations/oauth', + }); + expect(res.redirect).toHaveBeenCalledWith('/s/general/p/roadmap', 302); + }); + + it.each([ + ['a different user', { user: { id: 'user-2' } as User }], + ['a different workspace', { sessionWorkspace: { id: 'workspace-2' } as Workspace }], + ['a wrong nonce cookie', { cookies: { integration_oauth_slack: 'nonce-2' } }], + ['a nonce cookie of a different length', { cookies: { integration_oauth_slack: 'nonce-10' } }], + ['a nonce cookie for another provider', { cookies: { integration_oauth_github: 'nonce-1' } }], + ['a state without a nonce', { state: { nonce: undefined } }], + ])('refuses %s', async (_label, options) => { + const { res, exchangeCodeForTokens } = await complete(options); + + expect(exchangeCodeForTokens).not.toHaveBeenCalled(); + expect(res.clearCookie).not.toHaveBeenCalled(); + expect(res.redirect).toHaveBeenCalledWith('/s/general/p/roadmap?error=oauth_failed', 302); + }); + + it.each([ + ['an expired ticket', () => issueTicket({ exp: Date.now() - 1 })], + ['an undecryptable ticket', () => 'not-a-ticket'], + ['a ticket whose state does not verify', () => issueTicket({ state: 'forged-state' })], + ['a ticket without a code', () => issueTicket({ code: undefined })], + ['a ticket without an expiry', () => issueTicket({ exp: undefined })], + ['a ticket minted for another purpose', () => issueTicket({ purpose: 'session' })], + ['a ticket encrypted for SIEM credentials', () => issueTicket({}, EncryptionPurpose.SIEM_CREDENTIALS)], + ])('refuses %s', async (_label, ticket) => { + const { res, exchangeCodeForTokens } = await complete({ ticket: ticket() }); + + expect(exchangeCodeForTokens).not.toHaveBeenCalled(); + expect(res.clearCookie).not.toHaveBeenCalled(); + expect(res.redirect).toHaveBeenCalledWith(fallbackRedirect, 302); + }); + + it.each([ + [IdentityEmailMismatchError, 'identity_mismatch'], + [IdentityTenantMismatchError, 'tenant_mismatch'], + [IntegrationTenantInUseError, 'tenant_in_use'], + [Error, 'oauth_failed'], + ])('reports %p as %s', async (ErrorClass, code) => { + const { res } = await complete({ error: new ErrorClass() }); + + expect(res.clearCookie).toHaveBeenCalledTimes(1); + expect(res.redirect).toHaveBeenCalledWith( + `/s/general/p/roadmap?error=${code}`, + 302, + ); + }); +}); + +describe('OAuthController complete without a session', () => { + it('redirects a forbidden completion without logging the ticket', () => { + const exception = new ForbiddenException(); + const res = redirectReply(); + const req = { + host: 'acme.example', + url: '/api/integrations/oauth/complete?ticket=sealed-ticket', + }; + + new OAuthCompleteAuthFilter().catch(exception, { + switchToHttp: () => ({ getRequest: () => req, getResponse: () => res }), + } as any); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain('acme.example'); + expect(warn.mock.calls[0][0]).not.toContain('sealed-ticket'); + expect(res.redirect).toHaveBeenCalledTimes(1); + expect(res.redirect).toHaveBeenCalledWith(fallbackRedirect, 302); + }); + + it('redirects instead of returning 401 through the Fastify pipeline', async () => { + const moduleRef = await Test.createTestingModule({ + controllers: [OAuthController], + providers: [ + JwtStrategy, + { + provide: EnvironmentService, + useValue: { ...environmentService, getAppSecret: () => 'test-secret' }, + }, + { provide: EncryptionService, useValue: encryptionService }, + { provide: WorkspaceAbilityFactory, useValue: {} }, + ...[ + OAuthService, + IntegrationConnectionService, + LicenseCheckService, + IntegrationRegistry, + UserRepo, + WorkspaceRepo, + UserSessionRepo, + SessionActivityService, + ].map((provide) => ({ provide, useValue: {} })), + ], + }).compile(); + const app = moduleRef.createNestApplication( + new FastifyAdapter(), + ); + await app.init(); + await app.getHttpAdapter().getInstance().ready(); + + try { + const response = await app.inject({ + method: 'GET', + url: `/integrations/oauth/complete?ticket=${encodeURIComponent(issueTicket())}`, + }); + + expect(response.statusCode).toBe(302); + expect(response.headers.location).toBe(fallbackRedirect); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('refused without a usable session'), + ); + } finally { + await app.close(); + } + }); +}); 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 00000000..3f531e54 --- /dev/null +++ b/apps/server/src/core/integration/oauth/oauth.controller.ts @@ -0,0 +1,333 @@ +import { + BadRequestException, + Body, + Controller, + ForbiddenException, + Get, + HttpCode, + HttpStatus, + Logger, + Param, + Post, + Query, + Req, + Res, + UseFilters, + UseGuards, +} from '@nestjs/common'; +import { FastifyReply, FastifyRequest } from 'fastify'; +import { addMinutes } from 'date-fns'; +import * as crypto from 'crypto'; +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 { RequireSessionAuth } from '../../../common/decorators/require-session-auth.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 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 { + IdentityEmailMismatchError, + IdentityTenantMismatchError, + IntegrationTenantInUseError, +} from '../registry/integration-provider.interface'; +import { + EncryptionPurpose, + EncryptionService, +} from '../../../integrations/encryption/encryption.service'; +import { EnvironmentService } from '../../../integrations/environment/environment.service'; +import { COMPLETION_FALLBACK_REDIRECT, nonceCookieName } from './oauth.utils'; +import { OAuthCompleteAuthFilter } from './oauth-complete-auth.filter'; + +const OAUTH_ROUTE_PREFIX = '/api/integrations/oauth'; +const COMPLETION_TICKET_PURPOSE = 'oauth-completion'; +const COMPLETION_TICKET_TTL_MS = 60_000; + +type CompletionTicket = { + purpose: typeof COMPLETION_TICKET_PURPOSE; + state: string; + code: string; + exp: number; +}; + +@Controller('integrations/oauth') +export class OAuthController { + private readonly logger = new Logger(OAuthController.name); + + constructor( + private readonly oauthService: OAuthService, + private readonly connectionService: IntegrationConnectionService, + private readonly workspaceAbility: WorkspaceAbilityFactory, + private readonly licenseCheckService: LicenseCheckService, + private readonly registry: IntegrationRegistry, + private readonly encryptionService: EncryptionService, + private readonly environmentService: EnvironmentService, + ) {} + + @RequireSessionAuth() + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @Post('authorize') + async authorize( + @Body() dto: OAuthAuthorizeDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + @Res({ passthrough: true }) res: FastifyReply, + ) { + const { authorizationUrl, type, nonce } = + await this.oauthService.getAuthorizationUrl( + dto.integrationId, + workspace.id, + user.id, + dto.returnPath, + ); + + this.setNonceCookie(res, type, nonce); + return { authorizationUrl }; + } + + @RequireSessionAuth() + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @Post('install') + async installAndAuthorize( + @Body() dto: OAuthInstallDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + @Res({ passthrough: true }) res: FastifyReply, + ) { + const ability = this.workspaceAbility.createForUser(user, workspace); + if ( + ability.cannot(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Settings) + ) { + throw new ForbiddenException(); + } + + const requiredFeature = + this.registry.getProvider(dto.type)?.definition.requiresFeature; + if ( + requiredFeature && + !this.licenseCheckService.hasFeature( + workspace.licenseKey, + requiredFeature, + workspace.plan, + ) + ) { + throw new ForbiddenException('This feature requires a valid license'); + } + + const { authorizationUrl, nonce } = + await this.oauthService.getInstallAuthorizationUrl( + dto.type, + workspace.id, + user.id, + ); + + this.setNonceCookie(res, dto.type, nonce); + return { authorizationUrl }; + } + + @Get(':type/callback') + async callback( + @Param('type') type: string, + @Query('code') code: string, + @Query('state') state: string, + @Res() res: FastifyReply, + @Query('error') providerError?: string, + @Query('error_description') providerErrorDescription?: string, + ) { + if (!state) { + throw new BadRequestException('Missing state parameter'); + } + + const statePayload = this.oauthService.verifySignedState(state); + if (!statePayload) { + throw new BadRequestException('Invalid or expired OAuth state'); + } + + // Built server-side and signed into the state, never user input. + const returnUrl = statePayload.returnUrl; + const returnPath = statePayload.returnPath; + + // No code means the provider refused or the user cancelled. + if (!code) { + const errorCode = /^[a-z_]{1,40}$/.test(providerError ?? '') + ? providerError + : 'none'; + const reason = + typeof providerErrorDescription === 'string' + ? providerErrorDescription + .split(/[\r\n]/)[0] + .replace(/[^\x20-\x7e]/g, '') + .slice(0, 200) + : ''; + this.logger.warn( + `OAuth callback for ${statePayload.type} returned no code: error=${errorCode}${reason ? ` ${reason}` : ''}`, + ); + return res.redirect(`${returnUrl}${returnPath}?error=oauth_failed`, 302); + } + + if (statePayload.type !== type) { + this.logger.error( + `OAuth callback error for ${type}: OAuth state does not match this provider`, + ); + return res.redirect(`${returnUrl}${returnPath}?error=oauth_failed`, 302); + } + + // A customDomain is tenant-controlled DNS, so the ticket only goes to hosts Docmost serves. + let returnHost: string | undefined; + try { + returnHost = new URL(returnUrl).host; + } catch { + returnHost = undefined; + } + const subdomainHost = this.environmentService.getSubdomainHost(); + const isDocmostHost = + returnHost === new URL(this.environmentService.getAppUrl()).host || + (this.environmentService.isCloud() && + !!subdomainHost && + !!returnHost?.endsWith(`.${subdomainHost}`)); + + if (!isDocmostHost) { + this.logger.warn( + `OAuth callback refused for ${type}: ${returnUrl} is not a Docmost host`, + ); + return res.redirect(`${returnUrl}${returnPath}?error=oauth_failed`, 302); + } + + // The code is redeemed on the workspace host, where the session and nonce cookie prove who started the flow. + const ticket = this.encryptionService.encrypt( + JSON.stringify({ + purpose: COMPLETION_TICKET_PURPOSE, + state, + code, + exp: Date.now() + COMPLETION_TICKET_TTL_MS, + } satisfies CompletionTicket), + EncryptionPurpose.OAUTH_COMPLETION, + ); + + return res.redirect( + `${returnUrl}${OAUTH_ROUTE_PREFIX}/complete?ticket=${encodeURIComponent(ticket)}`, + 302, + ); + } + + @RequireSessionAuth() + @UseGuards(JwtAuthGuard) + @UseFilters(OAuthCompleteAuthFilter) + @Get('complete') + async complete( + @Query('ticket') ticket: string, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + @Req() req: FastifyRequest, + @Res() res: FastifyReply, + ) { + let completion: Partial | null; + try { + completion = JSON.parse( + this.encryptionService.decrypt(ticket, EncryptionPurpose.OAUTH_COMPLETION), + ); + } catch { + completion = null; + } + + const { purpose, state, code, exp } = completion ?? {}; + const statePayload = + purpose === COMPLETION_TICKET_PURPOSE && + typeof state === 'string' && + typeof code === 'string' && + typeof exp === 'number' && + exp >= Date.now() + ? this.oauthService.verifySignedState(state) + : null; + + if (!statePayload) { + this.logger.warn('OAuth completion refused: invalid or expired ticket'); + return res.redirect(COMPLETION_FALLBACK_REDIRECT, 302); + } + + const returnPath = statePayload.returnPath; + const cookieName = nonceCookieName(statePayload.type); + const cookieBuf = Buffer.from(req.cookies?.[cookieName] ?? ''); + const nonceBuf = Buffer.from(statePayload.nonce ?? ''); + const nonceMatches = + nonceBuf.length > 0 && + cookieBuf.length === nonceBuf.length && + crypto.timingSafeEqual(cookieBuf, nonceBuf); + + if ( + statePayload.userId !== user.id || + statePayload.workspaceId !== workspace.id || + !nonceMatches + ) { + this.logger.warn( + `OAuth completion refused for ${statePayload.type}: the session did not start this flow`, + ); + return res.redirect(`${returnPath}?error=oauth_failed`, 302); + } + + res.clearCookie(cookieName, { path: OAUTH_ROUTE_PREFIX }); + + try { + await this.oauthService.exchangeCodeForTokens( + statePayload.type, + code, + statePayload, + ); + + return res.redirect(returnPath, 302); + } catch (err) { + this.logger.error( + `OAuth completion error for ${statePayload.type}: ${(err as Error).message}`, + ); + let errorCode = 'oauth_failed'; + if (err instanceof IdentityTenantMismatchError) { + errorCode = 'tenant_mismatch'; + } else if (err instanceof IdentityEmailMismatchError) { + errorCode = 'identity_mismatch'; + } else if (err instanceof IntegrationTenantInUseError) { + errorCode = 'tenant_in_use'; + } + return res.redirect(`${returnPath}?error=${errorCode}`, 302); + } + } + + @RequireSessionAuth() + @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 }; + } + + private setNonceCookie(res: FastifyReply, type: string, nonce: string) { + res.setCookie(nonceCookieName(type), nonce, { + httpOnly: true, + path: OAUTH_ROUTE_PREFIX, + secure: this.environmentService.isHttps(), + sameSite: 'lax', + expires: addMinutes(new Date(), 10), + }); + } +} diff --git a/apps/server/src/core/integration/oauth/oauth.service.spec.ts b/apps/server/src/core/integration/oauth/oauth.service.spec.ts new file mode 100644 index 00000000..e75e05aa --- /dev/null +++ b/apps/server/src/core/integration/oauth/oauth.service.spec.ts @@ -0,0 +1,1327 @@ +import { + BadRequestException, + ForbiddenException, + Logger, + NotFoundException, +} from '@nestjs/common'; +import * as crypto from 'crypto'; +import * as http from 'http'; +import { AddressInfo } from 'net'; +import { IntegrationConnection } from '@docmost/db/types/entity.types'; +import WorkspaceAbilityFactory from '../../casl/abilities/workspace-ability.factory'; +import { UserRole } from '../../../common/helpers/types/permission'; +import { OAuthService, OAuthStatePayload } from './oauth.service'; +import { + IdentityEmailMismatchError, + ProviderApiError, + TokenExpiredError, + TokenInvalidError, +} from '../registry/integration-provider.interface'; + +type RecordedRequest = { method: string; url: string; body: string; authorization?: string }; + +type TestServer = { + server: http.Server; + url: string; + requests: RecordedRequest[]; +}; + +type Reply = (req: RecordedRequest, res: http.ServerResponse) => void; + +function startServer(respond: Reply): Promise { + const requests: RecordedRequest[] = []; + const server = http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => (body += chunk)); + req.on('end', () => { + const recorded: RecordedRequest = { method: req.method ?? '', url: req.url ?? '', body }; + if (req.headers.authorization) recorded.authorization = req.headers.authorization; + requests.push(recorded); + respond(recorded, res); + }); + }); + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const { port } = server.address() as AddressInfo; + resolve({ server, url: `http://127.0.0.1:${port}`, requests }); + }); + }); +} + +function stopServer({ server }: TestServer): Promise { + server.closeAllConnections(); + return new Promise((resolve) => server.close(() => resolve())); +} + +function tokenJson(res: http.ServerResponse, body: Record | null) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +const tokenError = (status: number, body: string): Reply => (_req, res) => { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(body); +}; + +// Never ends the body, so the request settles only if the client stops reading. +function streamUntilClosed(res: http.ServerResponse): void { + res.writeHead(200, { 'Content-Type': 'application/json' }); + const chunk = Buffer.from(`{"access_token":"${'a'.repeat(64 * 1024)}"}`); + const pump = () => { + while (!res.destroyed && res.write(chunk)) { /* Keep streaming until backpressure. */ } + if (!res.destroyed) res.once('drain', pump); + }; + pump(); +} + +// Emulates SET NX, the compare-and-delete release script and EXISTS over a Map. +function fakeRedis() { + const keys = new Map(); + return { + keys, + set: jest.fn(async (key: string, value: string) => { + if (keys.has(key)) return null; + keys.set(key, value); + return 'OK'; + }), + eval: jest.fn(async (_script: string, _numKeys: number, key: string, token: string) => { + if (keys.get(key) !== token) return 0; + keys.delete(key); + return 1; + }), + exists: jest.fn(async (key: string) => (keys.has(key) ? 1 : 0)), + }; +} + +function decodeState(authorizationUrl: string): OAuthStatePayload { + const state = new URL(authorizationUrl).searchParams.get('state') ?? ''; + const data = state.substring(0, state.lastIndexOf('.')); + return JSON.parse(Buffer.from(data, 'base64url').toString()); +} + +describe('OAuthService', () => { + const workspaceId = 'workspace-1'; + const userId = 'user-1'; + const integrations: Record = { + 'integration-acme': { id: 'integration-acme', type: 'acme', workspaceId, settings: {} }, + 'integration-chat': { id: 'integration-chat', type: 'chat', workspaceId, settings: { tenantId: 'T-1' } }, + 'integration-legacy': { id: 'integration-legacy', type: 'legacy', workspaceId, settings: {} }, + 'integration-hidden': { id: 'integration-hidden', type: 'hidden', workspaceId, settings: {} }, + 'integration-entra': { id: 'integration-entra', type: 'entra', workspaceId, settings: {} }, + 'integration-design': { id: 'integration-design', type: 'design', workspaceId, settings: {} }, + 'integration-tracker': { id: 'integration-tracker', type: 'tracker', workspaceId, settings: {} }, + 'integration-foreign': { id: 'integration-foreign', type: 'acme', workspaceId: 'workspace-2', settings: {} }, + }; + + let sink: TestServer; + let tokenEndpoint: TestServer; + let tokenEndpointReply: Reply; + let currentRole: UserRole; + let currentUserDisabledAt: Date | null; + let currentUserEmail: string; + let resolveIdentity: jest.Mock; + let chatOnConnected: jest.Mock; + let acmeOnConnected: jest.Mock; + let chatResolveAccount: jest.Mock; + let trackerResolveAccount: jest.Mock; + let connectionRepo: Record; + let integrationRepo: Record; + let db: { transaction: jest.Mock }; + let auditService: { log: jest.Mock }; + let redis: ReturnType; + let service: OAuthService; + const trx = { name: 'install-trx' }; + + const installState = (type: string) => ({ flow: 'install' as const, integrationId: null, type, userId, workspaceId }); + const connectState = (integrationId: string, type: string) => ({ flow: 'connect' as const, integrationId, type, userId, workspaceId }); + + beforeAll(async () => { + jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + for (const type of ['ACME', 'CHAT', 'LEGACY', 'HIDDEN', 'ENTRA', 'DESIGN', 'TRACKER']) { + process.env[`INTEGRATION_${type}_CLIENT_ID`] = `${type.toLowerCase()}-client-id`; + process.env[`INTEGRATION_${type}_CLIENT_SECRET`] = `${type.toLowerCase()}-client-secret`; + } + sink = await startServer((_req, res) => tokenJson(res, { access_token: 'stolen' })); + tokenEndpoint = await startServer((req, res) => tokenEndpointReply(req, res)); + }); + + afterAll(async () => { + jest.restoreAllMocks(); + for (const type of ['ACME', 'CHAT', 'LEGACY', 'HIDDEN', 'ENTRA', 'DESIGN', 'TRACKER']) { + delete process.env[`INTEGRATION_${type}_CLIENT_ID`]; + delete process.env[`INTEGRATION_${type}_CLIENT_SECRET`]; + } + await Promise.all([stopServer(tokenEndpoint), stopServer(sink)]); + }); + + beforeEach(() => { + sink.requests.length = 0; + tokenEndpoint.requests.length = 0; + tokenEndpointReply = (_req, res) => { + res.writeHead(307, { Location: `${sink.url}/collect` }); + res.end(); + }; + currentRole = UserRole.ADMIN; + currentUserDisabledAt = null; + currentUserEmail = 'user@example.com'; + resolveIdentity = jest.fn(async ({ settings }) => ({ + account: { id: 'U-42' }, + metadata: { tenantId: settings.tenantId }, + })); + chatResolveAccount = jest.fn(async () => ({ id: 'U-INSTALLER', displayName: 'Ada' })); + trackerResolveAccount = jest.fn(async () => ({ + id: '583231', + displayName: 'Ada Lovelace', + username: 'ada', + })); + chatOnConnected = jest.fn(async () => undefined); + acmeOnConnected = jest.fn(async () => undefined); + + const providerOAuth = (scopes: string[]) => ({ + authUrl: `${tokenEndpoint.url}/oauth/authorize`, + tokenUrl: `${tokenEndpoint.url}/oauth/token`, + scopes, + }); + const chatOAuth = { + ...providerOAuth(['bot']), + authParams: { prompt: 'consent' }, + connectionScope: 'workspace' as const, + identity: { + authUrl: `${tokenEndpoint.url}/openid/authorize`, + tokenUrl: `${tokenEndpoint.url}/openid/token`, + scopes: ['openid', 'profile'], + }, + }; + const providers: Record = { + acme: { + definition: { + type: 'acme', + oauth: { + ...providerOAuth(['read']), + authParams: { audience: 'api.acme.test', client_id: 'forged' }, + }, + }, + onConnected: acmeOnConnected, + }, + chat: { + definition: { type: 'chat', oauth: chatOAuth }, + getOAuthConfig: (settings: Record) => ({ + ...chatOAuth, + identity: { ...chatOAuth.identity, authParams: { team: settings.tenantId } }, + }), + resolveIdentity, + resolveAccount: chatResolveAccount, + onConnected: chatOnConnected, + }, + legacy: { + definition: { + type: 'legacy', + oauth: { ...providerOAuth(['bot']), connectionScope: 'workspace' as const }, + }, + }, + hidden: { + definition: { type: 'hidden', hidden: true, oauth: providerOAuth(['read']) }, + }, + entra: { + definition: { + type: 'entra', + oauth: { + ...providerOAuth(['api://resource/items.read', 'offline_access']), + scopeOnRefresh: true, + }, + }, + }, + design: { + definition: { + type: 'design', + oauth: { + ...providerOAuth(['file_metadata:read']), + refreshUrl: `${tokenEndpoint.url}/oauth/refresh`, + clientAuth: 'basic' as const, + }, + }, + }, + tracker: { + definition: { type: 'tracker', oauth: providerOAuth(['read']) }, + resolveAccount: trackerResolveAccount, + }, + }; + + connectionRepo = { + upsert: jest.fn(async (values) => values), + upsertWorkspaceConnection: jest.fn(async (values) => values), + upsertUserLink: jest.fn(async (values) => values), + findUserLink: jest.fn(async () => undefined), + findById: jest.fn(async () => undefined), + updateIfTokensMatch: jest.fn(async () => true), + invalidate: jest.fn(async () => undefined), + }; + integrationRepo = { + findById: jest.fn(async (id: string) => integrations[id] ?? null), + findByWorkspaceAndType: jest.fn(async () => null), + insertOrRestore: jest.fn(async ({ type }: { type: string }) => ({ + id: `integration-${type}`, + type, + workspaceId, + settings: {}, + })), + }; + + auditService = { log: jest.fn() }; + redis = fakeRedis(); + db = { + transaction: jest.fn(() => ({ + execute: (callback: (t: typeof trx) => Promise) => callback(trx), + })), + }; + + service = new OAuthService( + db as any, + { + getAppUrl: () => 'http://localhost:3000', + getAppSecret: () => 'test-secret', + } as any, + { getWorkspaceUrl: () => 'http://localhost:3000' } as any, + { getProvider: (type: string) => providers[type] } as any, + integrationRepo as any, + connectionRepo as any, + { + findById: async () => ({ id: workspaceId, hostname: null, customDomain: null }), + } as any, + { + encrypt: (value: string) => value, + decrypt: (value: string) => value, + } as any, + { + findById: async () => ({ + id: userId, + role: currentRole, + deactivatedAt: currentUserDisabledAt, + email: currentUserEmail, + }), + } as any, + new WorkspaceAbilityFactory(), + auditService as any, + { getOrThrow: () => redis } as any, + ); + }); + + describe('install flow', () => { + it('stores the issued tokens and audits the installation', async () => { + tokenEndpointReply = (_req, res) => + tokenJson(res, { + access_token: 'issued-access-token', + refresh_token: 'issued-refresh-token', + expires_in: 3600, + }); + + const connection = await service.exchangeCodeForTokens('acme', 'auth-code', installState('acme')); + + expect(tokenEndpoint.requests).toEqual([ + { + method: 'POST', + url: '/oauth/token', + body: + 'grant_type=authorization_code&client_id=acme-client-id&client_secret=acme-client-secret&code=auth-code&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fapi%2Fintegrations%2Foauth%2Facme%2Fcallback', + }, + ]); + expect(integrationRepo.insertOrRestore).toHaveBeenCalledWith( + { type: 'acme', workspaceId, installedById: userId }, + trx, + ); + expect(connection).toMatchObject({ + accessToken: 'issued-access-token', + refreshToken: 'issued-refresh-token', + }); + expect(auditService.log).toHaveBeenCalledWith({ + event: 'integration.installed', + resourceType: 'integration', + resourceId: 'integration-acme', + changes: { after: { provider: 'acme' } }, + }); + expect(acmeOnConnected).toHaveBeenCalledWith( + expect.objectContaining({ + integrationId: 'integration-acme', + userId, + accessToken: 'issued-access-token', + }), + ); + }); + + it('writes the shared connection for a workspace-scoped provider', async () => { + tokenEndpointReply = (_req, res) => tokenJson(res, { access_token: 'bot-token' }); + + await service.exchangeCodeForTokens('chat', 'auth-code', installState('chat')); + + expect(connectionRepo.upsertWorkspaceConnection).toHaveBeenCalledWith( + expect.objectContaining({ integrationId: 'integration-chat', userId, accessToken: 'bot-token' }), + trx, + ); + expect(chatOnConnected).toHaveBeenCalledWith( + expect.objectContaining({ integrationId: 'integration-chat', userId, accessToken: 'bot-token' }), + ); + }); + + it.each(['lost permission', 'deactivated'])('refuses an installer who is %s', async (reason) => { + if (reason === 'lost permission') currentRole = UserRole.MEMBER; + else currentUserDisabledAt = new Date(); + + await expect( + service.exchangeCodeForTokens('chat', 'auth-code', installState('chat')), + ).rejects.toBeInstanceOf(ForbiddenException); + + expect(tokenEndpoint.requests).toEqual([]); + expect(integrationRepo.insertOrRestore).not.toHaveBeenCalled(); + expect(connectionRepo.upsertWorkspaceConnection).not.toHaveBeenCalled(); + }); + + it('stops at a redirecting token endpoint instead of re-POSTing the client secret', async () => { + const outcome = await service + .exchangeCodeForTokens('acme', 'auth-code', installState('acme')) + .catch((err) => err); + + expect(sink.requests).toEqual([]); + expect(tokenEndpoint.requests).toHaveLength(1); + expect(outcome).toBeInstanceOf(BadRequestException); + expect(auditService.log).not.toHaveBeenCalled(); + }); + + it('refuses an install state naming an integration from another workspace', async () => { + await expect( + service.exchangeCodeForTokens('acme', 'auth-code', { + flow: 'install', + integrationId: 'integration-foreign', + type: 'acme', + userId, + workspaceId, + }), + ).rejects.toBeInstanceOf(NotFoundException); + + expect(tokenEndpoint.requests).toEqual([]); + expect(integrationRepo.insertOrRestore).not.toHaveBeenCalled(); + }); + + it('refuses to complete an install for a hidden provider without writing', async () => { + const outcome = await service + .exchangeCodeForTokens('hidden', 'auth-code', installState('hidden')) + .catch((err) => err); + + expect(outcome).toBeInstanceOf(BadRequestException); + expect(tokenEndpoint.requests).toEqual([]); + expect(db.transaction).not.toHaveBeenCalled(); + }); + }); + + describe('connect flow', () => { + it('connects a member with a per-user token and audits without reinstalling', async () => { + currentRole = UserRole.MEMBER; + tokenEndpointReply = (_req, res) => tokenJson(res, { access_token: 'user-token' }); + + const connection = await service.exchangeCodeForTokens( + 'acme', + 'auth-code', + connectState('integration-acme', 'acme'), + ); + + expect(connectionRepo.upsert).toHaveBeenCalledWith( + expect.objectContaining({ integrationId: 'integration-acme', userId, accessToken: 'user-token' }), + undefined, + ); + expect(integrationRepo.insertOrRestore).not.toHaveBeenCalled(); + expect(connection).toMatchObject({ accessToken: 'user-token' }); + expect(acmeOnConnected).not.toHaveBeenCalled(); + expect(auditService.log).toHaveBeenCalledWith({ + event: 'integration.connected', + resourceType: 'integration', + resourceId: 'integration-acme', + changes: { after: { provider: 'acme' } }, + }); + }); + + it('sends the client credentials as HTTP Basic, not in the body, for a provider that asks for it', async () => { + tokenEndpointReply = (_req, res) => tokenJson(res, { access_token: 'user-token' }); + + await service.exchangeCodeForTokens('design', 'auth-code', connectState('integration-design', 'design')); + + expect(tokenEndpoint.requests).toEqual([ + { + method: 'POST', + url: '/oauth/token', + body: 'grant_type=authorization_code&code=auth-code&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fapi%2Fintegrations%2Foauth%2Fdesign%2Fcallback', + authorization: `Basic ${Buffer.from('design-client-id:design-client-secret').toString('base64')}`, + }, + ]); + }); + + it('links a member with a matching email without replacing the shared connection', async () => { + currentRole = UserRole.MEMBER; + tokenEndpointReply = (_req, res) => tokenJson(res, { access_token: 'identity-token' }); + + resolveIdentity.mockResolvedValue({ + account: { id: 'U-42', displayName: 'Ada' }, + email: 'User@Example.com ', + metadata: { tenantId: 'T-1' }, + }); + const link = await service.exchangeCodeForTokens( + 'chat', + 'auth-code', + connectState('integration-chat', 'chat'), + ); + + expect(tokenEndpoint.requests.map((r) => r.url)).toEqual(['/openid/token']); + expect(resolveIdentity).toHaveBeenCalledWith( + expect.objectContaining({ + integrationId: 'integration-chat', + workspaceId, + userId, + tokenResponse: { access_token: 'identity-token' }, + settings: { tenantId: 'T-1' }, + }), + ); + expect(connectionRepo.upsertUserLink).toHaveBeenCalledWith({ + integrationId: 'integration-chat', + workspaceId, + userId, + providerUserId: 'U-42', + metadata: { tenantId: 'T-1', account: { id: 'U-42', displayName: 'Ada' } }, + }); + expect(connectionRepo.upsertWorkspaceConnection).not.toHaveBeenCalled(); + expect(connectionRepo.upsert).not.toHaveBeenCalled(); + expect(chatOnConnected).not.toHaveBeenCalled(); + expect(link).toMatchObject({ providerUserId: 'U-42' }); + }); + + it('writes nothing when the provider rejects the identity', async () => { + tokenEndpointReply = (_req, res) => tokenJson(res, { access_token: 'identity-token' }); + resolveIdentity.mockRejectedValue(new Error('wrong tenant')); + + await expect( + service.exchangeCodeForTokens('chat', 'auth-code', connectState('integration-chat', 'chat')), + ).rejects.toThrow('wrong tenant'); + + expect(connectionRepo.upsertUserLink).not.toHaveBeenCalled(); + expect(connectionRepo.upsertWorkspaceConnection).not.toHaveBeenCalled(); + }); + + it('refuses a workspace-scoped provider without an identity flow', async () => { + await expect( + service.exchangeCodeForTokens('legacy', 'auth-code', connectState('integration-legacy', 'legacy')), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(tokenEndpoint.requests).toEqual([]); + expect(connectionRepo.upsertWorkspaceConnection).not.toHaveBeenCalled(); + }); + + it('refuses a state signed for a different provider', async () => { + await expect( + service.exchangeCodeForTokens('chat', 'auth-code', connectState('integration-acme', 'acme')), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(tokenEndpoint.requests).toEqual([]); + expect(connectionRepo.upsert).not.toHaveBeenCalled(); + expect(connectionRepo.upsertUserLink).not.toHaveBeenCalled(); + }); + + it('refuses when the provider account email differs from the Docmost user', async () => { + tokenEndpointReply = (_req, res) => tokenJson(res, { access_token: 'identity-token' }); + resolveIdentity.mockResolvedValue({ account: { id: 'U-42' }, email: 'someone-else@example.com' }); + + await expect( + service.exchangeCodeForTokens('chat', 'auth-code', connectState('integration-chat', 'chat')), + ).rejects.toBeInstanceOf(IdentityEmailMismatchError); + + expect(connectionRepo.upsertUserLink).not.toHaveBeenCalled(); + expect(connectionRepo.findUserLink).not.toHaveBeenCalled(); + }); + + it('still connects a member to an existing installation of a hidden provider', async () => { + currentRole = UserRole.MEMBER; + tokenEndpointReply = (_req, res) => tokenJson(res, { access_token: 'user-token' }); + const { authorizationUrl } = await service.getAuthorizationUrl('integration-hidden', workspaceId, userId); + expect(decodeState(authorizationUrl)).toMatchObject({ flow: 'connect', integrationId: 'integration-hidden' }); + + await service.exchangeCodeForTokens('hidden', 'auth-code', connectState('integration-hidden', 'hidden')); + + expect(connectionRepo.upsert).toHaveBeenCalledWith( + expect.objectContaining({ integrationId: 'integration-hidden', userId, accessToken: 'user-token' }), + undefined, + ); + expect(integrationRepo.insertOrRestore).not.toHaveBeenCalled(); + }); + }); + + describe('connected account', () => { + const account = { id: '583231', displayName: 'Ada Lovelace', username: 'ada' }; + + beforeEach(() => { + tokenEndpointReply = (_req, res) => tokenJson(res, { access_token: 'user-token' }); + }); + + it('stores the account a member connected with and audits it', async () => { + await service.exchangeCodeForTokens('tracker', 'auth-code', connectState('integration-tracker', 'tracker')); + + expect(trackerResolveAccount).toHaveBeenCalledWith({ + accessToken: 'user-token', + tokenResponse: { access_token: 'user-token' }, + }); + expect(connectionRepo.upsert).toHaveBeenCalledWith( + expect.objectContaining({ userId, providerUserId: '583231', metadata: { account } }), + undefined, + ); + expect(auditService.log).toHaveBeenCalledWith({ + event: 'integration.connected', + resourceType: 'integration', + resourceId: 'integration-tracker', + changes: { after: { provider: 'tracker', account } }, + }); + }); + + it('still connects when the profile read fails, without an account', async () => { + const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + trackerResolveAccount.mockRejectedValue(new Error('tracker API error: 500')); + + await service.exchangeCodeForTokens('tracker', 'auth-code', connectState('integration-tracker', 'tracker')); + + expect(connectionRepo.upsert).toHaveBeenCalledWith( + expect.objectContaining({ providerUserId: null, metadata: null }), + undefined, + ); + expect(connectionRepo.findUserLink).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith('Could not read the connected tracker account: tracker API error: 500'); + expect(auditService.log).toHaveBeenCalledWith( + expect.objectContaining({ changes: { after: { provider: 'tracker' } } }), + ); + }); + + it('refuses an account already connected to another Docmost user', async () => { + connectionRepo.findUserLink.mockResolvedValue({ userId: 'user-2' }); + + await expect( + service.exchangeCodeForTokens('tracker', 'auth-code', connectState('integration-tracker', 'tracker')), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(connectionRepo.findUserLink).toHaveBeenCalledWith('integration-tracker', '583231', undefined); + expect(connectionRepo.upsert).not.toHaveBeenCalled(); + expect(auditService.log).not.toHaveBeenCalled(); + }); + + it('lets a member reconnect the account they already hold', async () => { + connectionRepo.findUserLink.mockResolvedValue({ userId }); + + await service.exchangeCodeForTokens('tracker', 'auth-code', connectState('integration-tracker', 'tracker')); + + expect(connectionRepo.upsert).toHaveBeenCalledTimes(1); + }); + + it("stores the installing admin's account and audits the install with it", async () => { + await service.exchangeCodeForTokens('tracker', 'auth-code', installState('tracker')); + + expect(connectionRepo.findUserLink).toHaveBeenCalledWith('integration-tracker', '583231', trx); + expect(connectionRepo.upsert).toHaveBeenCalledWith( + expect.objectContaining({ providerUserId: '583231', metadata: { account } }), + trx, + ); + expect(auditService.log).toHaveBeenCalledWith({ + event: 'integration.installed', + resourceType: 'integration', + resourceId: 'integration-tracker', + changes: { after: { provider: 'tracker', account } }, + }); + }); + + it('reads the installer account before the transaction and hands it to onConnected, not the shared row', async () => { + const order: string[] = []; + chatResolveAccount.mockImplementation(async () => { + order.push('resolveAccount'); + return { id: 'U-INSTALLER', displayName: 'Ada' }; + }); + db.transaction.mockImplementation(() => ({ + execute: (callback: (t: typeof trx) => Promise) => { + order.push('transaction'); + return callback(trx); + }, + })); + tokenEndpointReply = (_req, res) => tokenJson(res, { access_token: 'bot-token' }); + + await service.exchangeCodeForTokens('chat', 'auth-code', installState('chat')); + + expect(order).toEqual(['resolveAccount', 'transaction']); + expect(connectionRepo.upsertWorkspaceConnection.mock.calls[0][0]).not.toHaveProperty('providerUserId'); + expect(chatOnConnected).toHaveBeenCalledWith( + expect.objectContaining({ account: { id: 'U-INSTALLER', displayName: 'Ada' } }), + ); + expect(auditService.log).toHaveBeenCalledWith( + expect.objectContaining({ + changes: { after: { provider: 'chat', account: { id: 'U-INSTALLER', displayName: 'Ada' } } }, + }), + ); + }); + + it('audits the account of a linked identity without reading it again', async () => { + tokenEndpointReply = (_req, res) => tokenJson(res, { access_token: 'identity-token' }); + resolveIdentity.mockResolvedValue({ account: { id: 'U-42', displayName: 'Ada' }, metadata: {} }); + + await service.exchangeCodeForTokens('chat', 'auth-code', connectState('integration-chat', 'chat')); + + expect(chatResolveAccount).not.toHaveBeenCalled(); + expect(auditService.log).toHaveBeenCalledWith( + expect.objectContaining({ + changes: { after: { provider: 'chat', account: { id: 'U-42', displayName: 'Ada' } } }, + }), + ); + }); + }); + + describe('tokens from the code exchange', () => { + const malformed: [string, Record][] = [ + ['an access token with a line break', { access_token: 'abc\ndef-SECRET' }], + ['an access token with a space', { access_token: 'abc def-SECRET' }], + ['an access token with a non-ASCII character', { access_token: 'abcédef-SECRET' }], + ['no access token', { error: 'invalid_code' }], + ['an access token that is not a string', { access_token: { value: 'def-SECRET' } }], + ['a refresh token with an escape character', { access_token: 'access-token', refresh_token: 'abc\x1bdef-SECRET' }], + ]; + const errorLog = () => jest.mocked(Logger.prototype.error).mock.calls; + + beforeEach(() => { + jest.mocked(Logger.prototype.error).mockClear(); + }); + + it.each(malformed)('fails an install on %s without writing or logging the token', async (_label, body) => { + tokenEndpointReply = (_req, res) => tokenJson(res, body); + + const outcome = await service + .exchangeCodeForTokens('acme', 'auth-code', installState('acme')) + .catch((err) => err); + + expect(outcome).toBeInstanceOf(BadRequestException); + expect(outcome.message).toBe('OAuth token exchange failed'); + expect(db.transaction).not.toHaveBeenCalled(); + expect(integrationRepo.insertOrRestore).not.toHaveBeenCalled(); + expect(acmeOnConnected).not.toHaveBeenCalled(); + expect(auditService.log).not.toHaveBeenCalled(); + expect(JSON.stringify(errorLog())).not.toContain('SECRET'); + }); + + it('fails an identity link on a malformed access token before the provider resolves the identity', async () => { + tokenEndpointReply = (_req, res) => tokenJson(res, { access_token: 'abc\ndef-SECRET' }); + + await expect( + service.exchangeCodeForTokens('chat', 'auth-code', connectState('integration-chat', 'chat')), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(resolveIdentity).not.toHaveBeenCalled(); + expect(connectionRepo.upsertUserLink).not.toHaveBeenCalled(); + }); + }); + + describe('token endpoint responses during the code exchange', () => { + const errorLog = () => jest.mocked(Logger.prototype.error).mock.calls; + const exchange = (type = 'acme') => + service.exchangeCodeForTokens(type, 'auth-code', installState(type)).catch((err) => err); + const expectNothingWritten = () => { + expect(db.transaction).not.toHaveBeenCalled(); + expect(integrationRepo.insertOrRestore).not.toHaveBeenCalled(); + expect(connectionRepo.upsert).not.toHaveBeenCalled(); + }; + + beforeEach(() => { + jest.mocked(Logger.prototype.error).mockClear(); + }); + + it('logs the status and error code of a refused exchange and nothing else from the body', async () => { + tokenEndpointReply = tokenError( + 401, + JSON.stringify({ + error: 'invalid_client', + error_description: 'PRIVATE tenant and secret details', + }), + ); + + const outcome = await exchange('entra'); + + expect(outcome).toBeInstanceOf(BadRequestException); + expect(outcome.message).toBe('OAuth token exchange failed'); + expect(errorLog()).toEqual([['Token exchange failed for entra: 401 invalid_client']]); + expectNothingWritten(); + }); + + it('rejects truncated JSON during exchange without writing or logging the body', async () => { + tokenEndpointReply = (_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{"access_token":"leaked-0f1e2d3c'); + }; + + const outcome = await exchange(); + + expect(outcome).toBeInstanceOf(BadRequestException); + expect(outcome.message).toBe('OAuth token exchange failed'); + expect(errorLog()).toEqual([['Token exchange for acme returned a response that is not JSON']]); + expectNothingWritten(); + }); + + it('stops reading a response that passes the size cap and writes nothing', async () => { + tokenEndpointReply = (_req, res) => streamUntilClosed(res); + + const outcome = await exchange(); + + expect(outcome).toBeInstanceOf(ProviderApiError); + expect(outcome.message).toBe('acme token endpoint API error: 502 response too large'); + expectNothingWritten(); + expect(acmeOnConnected).not.toHaveBeenCalled(); + }, 5000); + }); + + describe('authorization URLs and signed state', () => { + it('creates an install URL with provider params and a signed browser nonce', async () => { + const { authorizationUrl, nonce } = await service.getInstallAuthorizationUrl('chat', workspaceId, userId); + const url = new URL(authorizationUrl); + + expect(url.origin + url.pathname).toBe(tokenEndpoint.url + '/oauth/authorize'); + expect(url.searchParams.get('prompt')).toBe('consent'); + expect(service.verifySignedState(url.searchParams.get('state'))).toMatchObject({ + flow: 'install', integrationId: null, type: 'chat', userId, workspaceId, nonce, + }); + expect(nonce).toMatch(/^[A-Za-z0-9_-]{43}$/); + }); + + it('creates a per-user URL without letting provider params override core params', async () => { + const first = await service.getAuthorizationUrl('integration-acme', workspaceId, userId); + const second = await service.getAuthorizationUrl('integration-acme', workspaceId, userId); + const url = new URL(first.authorizationUrl); + const state = url.searchParams.get('state'); + + expect(url.origin + url.pathname).toBe(tokenEndpoint.url + '/oauth/authorize'); + expect(url.searchParams.get('scope')).toBe('read'); + expect(url.searchParams.get('audience')).toBe('api.acme.test'); + expect(url.searchParams.getAll('client_id')).toEqual(['acme-client-id']); + expect(service.verifySignedState(state)).toMatchObject({ + flow: 'connect', integrationId: 'integration-acme', type: 'acme', userId, workspaceId, nonce: first.nonce, + }); + expect(second.nonce).not.toBe(first.nonce); + + const now = jest.spyOn(Date, 'now').mockReturnValue(Date.now() + 11 * 60_000); + try { + expect(service.verifySignedState(state)).toBeNull(); + } finally { + now.mockRestore(); + } + }); + + it('uses the workspace identity endpoint, scopes and settings instead of install params', async () => { + const { authorizationUrl } = await service.getAuthorizationUrl('integration-chat', workspaceId, userId); + const url = new URL(authorizationUrl); + + expect(url.origin + url.pathname).toBe(tokenEndpoint.url + '/openid/authorize'); + expect(url.searchParams.get('team')).toBe('T-1'); + expect(url.searchParams.get('scope')).toBe('openid profile'); + expect(url.searchParams.get('client_id')).toBe('chat-client-id'); + expect(url.searchParams.has('prompt')).toBe(false); + expect(decodeState(authorizationUrl)).toMatchObject({ + flow: 'connect', integrationId: 'integration-chat', returnPath: '/settings/account/connections', + }); + }); + + it('refuses connect URLs for a workspace provider without an identity flow', async () => { + await expect(service.getAuthorizationUrl('integration-legacy', workspaceId, userId)) + .rejects.toBeInstanceOf(BadRequestException); + }); + + it('refuses new installs for a hidden provider', async () => { + await expect(service.getInstallAuthorizationUrl('hidden', workspaceId, userId)) + .rejects.toBeInstanceOf(BadRequestException); + expect(integrationRepo.findByWorkspaceAndType).not.toHaveBeenCalled(); + }); + + it.each(['tampered', 'short signature', 'no separator', 'raw secret'])('rejects a state with %s', async (kind) => { + const { authorizationUrl } = await service.getAuthorizationUrl('integration-acme', workspaceId, userId); + const state = new URL(authorizationUrl).searchParams.get('state'); + const [data, signature] = state.split('.'); + const invalid = { + tampered: data + '.' + (signature[0] === 'A' ? 'B' : 'A') + signature.slice(1), + 'short signature': state.slice(0, -1), + 'no separator': data + signature, + 'raw secret': data + '.' + crypto.createHmac('sha256', 'test-secret').update(data).digest('base64url'), + }[kind]; + + expect(service.verifySignedState(invalid)).toBeNull(); + }); + }); + + describe('access token for a request', () => { + const storedConnection = (overrides: Record = {}) => + ({ + id: 'connection-1', + integrationId: 'integration-acme', + userId, + workspaceId, + accessToken: 'stored-access-token', + refreshToken: 'refresh-token', + tokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000), + invalidatedAt: null, + ...overrides, + }) as unknown as IntegrationConnection; + + it.each<[string, Record]>([ + ['that expires in two minutes', { tokenExpiresAt: new Date(Date.now() + 2 * 60 * 1000) }], + ['that expires in a minute and has no refresh token', { tokenExpiresAt: new Date(Date.now() + 60 * 1000), refreshToken: null }], + ['without an expiry', { tokenExpiresAt: null }], + ])('returns the stored token of a connection %s without refreshing', async (_label, overrides) => { + await expect(service.getValidAccessToken(storedConnection(overrides))).resolves.toBe('stored-access-token'); + + expect(tokenEndpoint.requests).toEqual([]); + expect(redis.set).not.toHaveBeenCalled(); + }); + + it('reports an expired token that has no refresh token as invalid', async () => { + const outcome = await service + .getValidAccessToken(storedConnection({ tokenExpiresAt: new Date(Date.now() - 1000), refreshToken: null })) + .catch((err) => err); + + expect(outcome).toBeInstanceOf(TokenInvalidError); + expect(tokenEndpoint.requests).toEqual([]); + }); + + it('reports a retired connection as invalid', async () => { + const outcome = await service + .getValidAccessToken(storedConnection({ invalidatedAt: new Date(), refreshToken: null, tokenExpiresAt: null })) + .catch((err) => err); + + expect(outcome).toBeInstanceOf(TokenInvalidError); + expect(tokenEndpoint.requests).toEqual([]); + }); + }); + + describe('token refresh', () => { + const refreshBody = (refreshToken: string) => + `grant_type=refresh_token&client_id=acme-client-id&client_secret=acme-client-secret&refresh_token=${refreshToken}`; + const expiringConnection = (overrides: Record = {}) => + ({ + id: 'connection-1', + integrationId: 'integration-acme', + userId, + workspaceId, + accessToken: 'expired-access-token', + refreshToken: 'refresh-token', + tokenExpiresAt: new Date(Date.now() - 1000), + invalidatedAt: null, + ...overrides, + }) as unknown as IntegrationConnection; + const rejectGrant = (status: number): Reply => (_req, res) => { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'invalid_grant' })); + }; + // Backs the repo with one row that honours the expected tokens, so a race can be replayed. + const storeRow = (row: Record) => { + const matches = (expected: Record = {}) => + Object.entries(expected).every(([column, value]) => row[column] === value); + connectionRepo.findById.mockImplementation(async () => ({ ...row })); + connectionRepo.updateIfTokensMatch.mockImplementation(async (_id, expected, data) => { + if (!matches(expected)) return false; + Object.assign(row, data); + return true; + }); + connectionRepo.invalidate.mockImplementation(async (_id, expected) => { + if (matches(expected)) Object.assign(row, { invalidatedAt: new Date(), refreshToken: null, tokenExpiresAt: null }); + }); + return row; + }; + const reconnectDuring = (reply: Reply, row: Record): Reply => (req, res) => { + Object.assign(row, { + accessToken: 'reconnected-access-token', + refreshToken: 'reconnected-refresh-token', + tokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000), + }); + reply(req, res); + }; + + beforeEach(() => { + connectionRepo.findById.mockResolvedValue(expiringConnection()); + }); + + it('stops at a redirecting token endpoint instead of re-POSTing the client secret', async () => { + const outcome = await service.refreshAccessToken(expiringConnection()).catch((err) => err); + + expect(sink.requests).toEqual([]); + expect(tokenEndpoint.requests).toHaveLength(1); + expect(outcome).toBeInstanceOf(BadRequestException); + }); + + it('refreshes using the current row and conditionally stores and returns the new tokens', async () => { + const row = storeRow({ ...expiringConnection({ refreshToken: 'current-refresh-token' }) }); + tokenEndpointReply = (_req, res) => + tokenJson(res, { access_token: 'new-access-token', refresh_token: 'new-refresh-token', expires_in: 7200 }); + + await expect(service.refreshAccessToken(expiringConnection({ refreshToken: 'stale-refresh-token' }))) + .resolves.toBe('new-access-token'); + + expect(tokenEndpoint.requests).toEqual([ + { method: 'POST', url: '/oauth/token', body: refreshBody('current-refresh-token') }, + ]); + expect(connectionRepo.updateIfTokensMatch).toHaveBeenCalledWith( + 'connection-1', { refreshToken: 'current-refresh-token' }, expect.objectContaining({ + accessToken: 'new-access-token', refreshToken: 'new-refresh-token', invalidatedAt: null, + }), + ); + expect(row).toMatchObject({ accessToken: 'new-access-token', refreshToken: 'new-refresh-token' }); + expect(new Date(row.tokenExpiresAt as Date).getTime()).toBeGreaterThan(Date.now() + 7100 * 1000); + }); + + it('drops the refreshed tokens of a connection reconnected during the refresh', async () => { + const row = storeRow({ ...expiringConnection() }); + tokenEndpointReply = reconnectDuring( + (_req, res) => tokenJson(res, { access_token: 'new-access-token', refresh_token: 'new-refresh-token', expires_in: 7200 }), + row, + ); + + await expect(service.refreshAccessToken(expiringConnection())).resolves.toBe('reconnected-access-token'); + + expect(tokenEndpoint.requests).toHaveLength(1); + expect(row).toMatchObject({ accessToken: 'reconnected-access-token', refreshToken: 'reconnected-refresh-token' }); + }); + + it('repeats the scopes on refresh for a provider that asks for them', async () => { + connectionRepo.findById.mockResolvedValue(expiringConnection({ integrationId: 'integration-entra' })); + tokenEndpointReply = (_req, res) => tokenJson(res, { access_token: 'new-access-token', expires_in: 3600 }); + + await service.refreshAccessToken(expiringConnection({ integrationId: 'integration-entra' })); + + expect(tokenEndpoint.requests.map((r) => r.body)).toEqual([ + 'grant_type=refresh_token&client_id=entra-client-id&client_secret=entra-client-secret&refresh_token=refresh-token&scope=api%3A%2F%2Fresource%2Fitems.read+offline_access', + ]); + }); + + it("refreshes at the provider's own refresh endpoint with HTTP Basic credentials and keeps a refresh token it does not replace", async () => { + connectionRepo.findById.mockResolvedValue(expiringConnection({ integrationId: 'integration-design' })); + tokenEndpointReply = (_req, res) => tokenJson(res, { access_token: 'new-access-token', expires_in: 7776000 }); + + await service.refreshAccessToken(expiringConnection({ integrationId: 'integration-design' })); + + expect(tokenEndpoint.requests).toEqual([ + { + method: 'POST', + url: '/oauth/refresh', + body: 'grant_type=refresh_token&refresh_token=refresh-token', + authorization: `Basic ${Buffer.from('design-client-id:design-client-secret').toString('base64')}`, + }, + ]); + expect(connectionRepo.updateIfTokensMatch).toHaveBeenCalledWith( + 'connection-1', + { refreshToken: 'refresh-token' }, + expect.objectContaining({ accessToken: 'new-access-token', refreshToken: 'refresh-token' }), + ); + }); + + it.each<[string, Record]>([ + ['refreshed by a non-rotating provider', { accessToken: 'fresh-access-token' }], + ['reconnected without a refresh token', { accessToken: 'fresh-access-token', refreshToken: null }], + ])('skips a connection %s since the caller read it and returns its current token', async (_label, changes) => { + connectionRepo.findById.mockResolvedValue( + expiringConnection({ ...changes, tokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000) }), + ); + tokenEndpointReply = rejectGrant(400); + + await expect(service.refreshAccessToken(expiringConnection())).resolves.toBe('fresh-access-token'); + + expect(tokenEndpoint.requests).toEqual([]); + expect(connectionRepo.updateIfTokensMatch).not.toHaveBeenCalled(); + expect(connectionRepo.invalidate).not.toHaveBeenCalled(); + }); + + it.each<[string, Record | undefined]>([ + ['refreshed to a token that has expired too', { accessToken: 'fresh-access-token', refreshToken: 'rotated-refresh-token' }], + ['retired', { invalidatedAt: new Date(), refreshToken: null, tokenExpiresAt: null }], + ['deleted', undefined], + ])('skips a connection %s since the caller read it and reports it expired', async (_label, changes) => { + connectionRepo.findById.mockResolvedValue(changes && expiringConnection(changes)); + tokenEndpointReply = rejectGrant(400); + + const outcome = await service.refreshAccessToken(expiringConnection()).catch((err) => err); + + expect(outcome).toBeInstanceOf(TokenExpiredError); + expect(tokenEndpoint.requests).toEqual([]); + expect(connectionRepo.updateIfTokensMatch).not.toHaveBeenCalled(); + expect(connectionRepo.invalidate).not.toHaveBeenCalled(); + }); + + it.each(['acme', 'entra'])('preserves a %s connection reconnected while the old token is rejected', async (type) => { + const connection = expiringConnection({ integrationId: 'integration-' + type }); + const row = storeRow({ ...connection }); + tokenEndpointReply = reconnectDuring(rejectGrant(400), row); + + await expect(service.refreshAccessToken(connection)).rejects.toBeInstanceOf(TokenInvalidError); + + expect(row).toMatchObject({ + accessToken: 'reconnected-access-token', refreshToken: 'reconnected-refresh-token', invalidatedAt: null, + }); + }); + + it.each<[string, Reply]>([ + ['a 5xx', (_req, res) => { + res.writeHead(503); + res.end(); + }], + ['a dropped connection', (_req, res) => res.socket?.destroy()], + ])('maps %s from the token endpoint to a failed refresh without invalidating', async (_label, reply) => { + tokenEndpointReply = reply; + + const outcome = await service.refreshAccessToken(expiringConnection()).catch((err) => err); + + expect(outcome).toBeInstanceOf(BadRequestException); + expect(outcome.message).toBe('Failed to refresh token'); + expect(tokenEndpoint.requests).toHaveLength(1); + expect(connectionRepo.invalidate).not.toHaveBeenCalled(); + expect(connectionRepo.updateIfTokensMatch).not.toHaveBeenCalled(); + }); + + it.each<[string, Record | null]>([ + ['an access token with a line break', { access_token: 'abc\ndef-SECRET', expires_in: 3600 }], + ['an access token that is not a string', { access_token: 12345, expires_in: 3600 }], + ['no access token', { expires_in: 3600 }], + ['a JSON null', null], + ['a refresh token with a NUL', { access_token: 'new-access-token', refresh_token: 'abc\x00def-SECRET' }], + ])('keeps the connection as it was when the refresh returns %s, without logging the token', async (_label, body) => { + const logged = jest.mocked(Logger.prototype.error); + logged.mockClear(); + tokenEndpointReply = (_req, res) => tokenJson(res, body); + + const outcome = await service.refreshAccessToken(expiringConnection()).catch((err) => err); + + expect(outcome).toBeInstanceOf(BadRequestException); + expect(outcome.message).toBe('Failed to refresh token'); + expect(connectionRepo.invalidate).not.toHaveBeenCalled(); + expect(connectionRepo.updateIfTokensMatch).not.toHaveBeenCalled(); + expect(JSON.stringify(logged.mock.calls)).not.toContain('SECRET'); + }); + + it('rejects truncated JSON during refresh without changing the connection or logging the body', async () => { + const logged = jest.mocked(Logger.prototype.error); + logged.mockClear(); + tokenEndpointReply = (_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{"access_token":"leaked-0f1e2d3c'); + }; + + const outcome = await service.refreshAccessToken(expiringConnection()).catch((err) => err); + + expect(outcome).toBeInstanceOf(BadRequestException); + expect(outcome.message).toBe('Failed to refresh token'); + expect(connectionRepo.invalidate).not.toHaveBeenCalled(); + expect(connectionRepo.updateIfTokensMatch).not.toHaveBeenCalled(); + expect(logged.mock.calls).toEqual([ + ['Token refresh for acme returned a response that is not JSON'], + ['Token refresh error: Token refresh failed'], + ]); + }); + + it('stops an oversized refresh response without retiring or updating the connection', async () => { + tokenEndpointReply = (_req, res) => streamUntilClosed(res); + + await expect(service.refreshAccessToken(expiringConnection())).rejects.toBeInstanceOf(BadRequestException); + + expect(connectionRepo.invalidate).not.toHaveBeenCalled(); + expect(connectionRepo.updateIfTokensMatch).not.toHaveBeenCalled(); + }, 5000); + + describe('when the token endpoint rejects the refresh', () => { + beforeEach(() => { + jest.mocked(Logger.prototype.error).mockClear(); + }); + + it.each([ + [401, 'invalid_client'], + [400, 'invalid_client'], + [400, 'unauthorized_client'], + [400, 'invalid_scope'], + [200, 'incorrect_client_credentials'], + ])('keeps the connection on %s %s', async (status, error) => { + storeRow({ ...expiringConnection() }); + tokenEndpointReply = tokenError(status, JSON.stringify({ error, error_description: 'PRIVATE' })); + + await expect(service.getValidAccessToken(expiringConnection())).rejects.toBeInstanceOf(TokenExpiredError); + + expect(connectionRepo.invalidate).not.toHaveBeenCalled(); + expect(connectionRepo.updateIfTokensMatch).not.toHaveBeenCalled(); + const logs = JSON.stringify(jest.mocked(Logger.prototype.error).mock.calls); + expect(logs).toContain(status + ' ' + error); + expect(logs).not.toContain('PRIVATE'); + expect(redis.keys.size).toBe(0); + }); + + it.each(['acme', 'entra'])('retires a %s connection on invalid_grant', async (type) => { + const connection = expiringConnection({ integrationId: 'integration-' + type }); + const row = storeRow({ ...connection }); + tokenEndpointReply = tokenError(400, JSON.stringify({ error: 'invalid_grant' })); + + await expect(service.getValidAccessToken(connection)).rejects.toBeInstanceOf(TokenInvalidError); + + expect(connectionRepo.invalidate).toHaveBeenCalledWith('connection-1', { refreshToken: 'refresh-token' }); + expect(row).toMatchObject({ invalidatedAt: expect.any(Date), refreshToken: null }); + expect(jest.mocked(Logger.prototype.error).mock.calls).toEqual([[`Token refresh failed for ${type}: 400 invalid_grant`]]); + }); + + it('retires the connection on interaction_required', async () => { + const row = storeRow({ ...expiringConnection() }); + tokenEndpointReply = tokenError(400, JSON.stringify({ error: 'interaction_required' })); + + await expect(service.getValidAccessToken(expiringConnection())).rejects.toBeInstanceOf(TokenInvalidError); + + expect(connectionRepo.invalidate).toHaveBeenCalledWith('connection-1', { refreshToken: 'refresh-token' }); + expect(row).toMatchObject({ invalidatedAt: expect.any(Date), refreshToken: null }); + expect(redis.keys.size).toBe(0); + }); + + it("retires the connection on GitHub's bad_refresh_token, which comes with a 200", async () => { + const row = storeRow({ ...expiringConnection() }); + tokenEndpointReply = tokenError(200, JSON.stringify({ + error: 'bad_refresh_token', + error_description: 'The refresh token passed is incorrect or expired.', + error_uri: 'https://docs.github.com/apps/creating-github-apps/authenticating-with-a-github-app/refreshing-user-access-tokens', + })); + + await expect(service.getValidAccessToken(expiringConnection())).rejects.toBeInstanceOf(TokenInvalidError); + + expect(connectionRepo.invalidate).toHaveBeenCalledWith('connection-1', { refreshToken: 'refresh-token' }); + expect(row).toMatchObject({ invalidatedAt: expect.any(Date), refreshToken: null }); + expect(jest.mocked(Logger.prototype.error).mock.calls).toEqual([['Token refresh failed for acme: 200 bad_refresh_token']]); + }); + + it.each([ + 'invalid_grant PRIVATE', + JSON.stringify({ error: 'invalid_grant\nPRIVATE' }), + JSON.stringify({ error: 'a'.repeat(41) }), + JSON.stringify({ error: { code: 'invalid_grant', detail: 'PRIVATE' } }), + ])('does not retire on a malformed error body: %s', async (body) => { + connectionRepo.findById.mockResolvedValue(expiringConnection()); + tokenEndpointReply = tokenError(400, body); + + await expect(service.refreshAccessToken(expiringConnection())).rejects.toBeInstanceOf(BadRequestException); + + expect(connectionRepo.invalidate).not.toHaveBeenCalled(); + expect(jest.mocked(Logger.prototype.error).mock.calls).toEqual([ + ['Token refresh failed for acme: 400'], ['Token refresh error: Token refresh failed'], + ]); + }); + }); + + describe('on the request path', () => { + const lockKey = 'integration:refresh:connection-1'; + const issueNewTokens: Reply = (_req, res) => + tokenJson(res, { access_token: 'new-access-token', refresh_token: 'new-refresh-token', expires_in: 7200 }); + const inAnHour = () => new Date(Date.now() + 60 * 60 * 1000); + + it.each<[string, Date]>([ + ['has expired', new Date(Date.now() - 1000)], + ['expires within a minute', new Date(Date.now() + 30 * 1000)], + ])('refreshes a token that %s and returns the new one', async (_label, tokenExpiresAt) => { + const row = storeRow({ ...expiringConnection({ tokenExpiresAt }) }); + tokenEndpointReply = issueNewTokens; + + await expect(service.getValidAccessToken(expiringConnection({ tokenExpiresAt }))).resolves.toBe( + 'new-access-token', + ); + + expect(tokenEndpoint.requests.map((r) => r.body)).toEqual([refreshBody('refresh-token')]); + expect(row).toMatchObject({ accessToken: 'new-access-token', refreshToken: 'new-refresh-token' }); + expect(redis.set).toHaveBeenCalledWith(lockKey, expect.any(String), 'PX', 20_000, 'NX'); + expect(redis.keys.has(lockKey)).toBe(false); + }); + + it('makes one token request for ten concurrent callers', async () => { + storeRow({ ...expiringConnection() }); + tokenEndpointReply = issueNewTokens; + + const tokens = await Promise.all( + Array.from({ length: 10 }, () => service.getValidAccessToken(expiringConnection())), + ); + + expect(tokens).toEqual(Array(10).fill('new-access-token')); + expect(tokenEndpoint.requests).toHaveLength(1); + expect(redis.set).toHaveBeenCalledTimes(1); + }); + + it('lets a later request refresh after a shared refresh failed', async () => { + storeRow({ ...expiringConnection() }); + tokenEndpointReply = tokenError(503, ''); + await expect(service.getValidAccessToken(expiringConnection())).rejects.toBeInstanceOf(TokenExpiredError); + + tokenEndpointReply = issueNewTokens; + await expect(service.getValidAccessToken(expiringConnection())).resolves.toBe('new-access-token'); + + expect(tokenEndpoint.requests).toHaveLength(2); + }); + + it('returns the token another process saved after the caller read the row', async () => { + storeRow({ + ...expiringConnection({ + accessToken: 'saved-access-token', + refreshToken: 'rotated-refresh-token', + tokenExpiresAt: inAnHour(), + }), + }); + + await expect(service.getValidAccessToken(expiringConnection())).resolves.toBe('saved-access-token'); + + expect(tokenEndpoint.requests).toEqual([]); + expect(redis.keys.has(lockKey)).toBe(false); + }); + + it('waits for a refresh another process holds and returns the token it saved', async () => { + const row = storeRow({ ...expiringConnection() }); + redis.keys.set(lockKey, 'other-process'); + redis.exists.mockImplementationOnce(async () => { + Object.assign(row, { + accessToken: 'other-access-token', + refreshToken: 'other-refresh-token', + tokenExpiresAt: inAnHour(), + }); + redis.keys.delete(lockKey); + return 1; + }); + + await expect(service.getValidAccessToken(expiringConnection())).resolves.toBe('other-access-token'); + + expect(redis.exists).toHaveBeenCalledTimes(2); + expect(tokenEndpoint.requests).toEqual([]); + expect(connectionRepo.updateIfTokensMatch).not.toHaveBeenCalled(); + }); + + it('gives up after five seconds while another process still holds the lock, without retiring the connection', async () => { + jest.useFakeTimers(); + try { + const row = storeRow({ ...expiringConnection() }); + redis.keys.set(lockKey, 'other-process'); + + const outcome = service.getValidAccessToken(expiringConnection()).catch((err) => err); + await jest.advanceTimersByTimeAsync(4_800); + expect(connectionRepo.findById).not.toHaveBeenCalled(); + await jest.advanceTimersByTimeAsync(400); + + expect(await outcome).toBeInstanceOf(TokenExpiredError); + expect(connectionRepo.findById).toHaveBeenCalledTimes(1); + expect(tokenEndpoint.requests).toEqual([]); + expect(connectionRepo.invalidate).not.toHaveBeenCalled(); + expect(row).toMatchObject({ refreshToken: 'refresh-token', invalidatedAt: null }); + expect(redis.keys.get(lockKey)).toBe('other-process'); + } finally { + jest.useRealTimers(); + } + }); + + it('reports the token expired without refreshing when Redis fails', async () => { + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + storeRow({ ...expiringConnection() }); + redis.set.mockRejectedValue(new Error('connection lost')); + redis.exists.mockRejectedValue(new Error('connection lost')); + tokenEndpointReply = issueNewTokens; + + const outcome = await service.getValidAccessToken(expiringConnection()).catch((err) => err); + + expect(outcome).toBeInstanceOf(TokenExpiredError); + expect(tokenEndpoint.requests).toEqual([]); + expect(connectionRepo.updateIfTokensMatch).not.toHaveBeenCalled(); + expect(connectionRepo.invalidate).not.toHaveBeenCalled(); + }); + }); + }); +}); 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 00000000..c6f6d4ae --- /dev/null +++ b/apps/server/src/core/integration/oauth/oauth.service.ts @@ -0,0 +1,972 @@ +import { + BadRequestException, + ForbiddenException, + Inject, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; +import { executeTx } from '@docmost/db/utils'; +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 { UserRepo } from '@docmost/db/repos/user/user.repo'; +import { + EncryptionPurpose, + EncryptionService, +} from '../../../integrations/encryption/encryption.service'; +import { IntegrationConnection } from '@docmost/db/types/entity.types'; +import { + IdentityEmailMismatchError, + IntegrationProvider, + OAuthConfig, + ProviderAccount, + TokenExpiredError, + TokenInvalidError, +} from '../registry/integration-provider.interface'; +import { proxyFetch } from '../../../common/proxy-fetch'; +import { readCappedResponse } from '../utils/provider-fetch'; +import { isUserDisabled } from '../../../common/helpers/utils'; +import { credentialEnvKey } from './credential-env'; +import { supportsOAuthConnect } from './oauth-connect'; +import WorkspaceAbilityFactory from '../../casl/abilities/workspace-ability.factory'; +import { + WorkspaceCaslAction, + WorkspaceCaslSubject, +} from '../../casl/interfaces/workspace-ability.type'; +import * as crypto from 'crypto'; +import { + AUDIT_SERVICE, + IAuditService, +} from '../../../integrations/audit/audit.service'; +import { + AuditEvent, + AuditResource, +} from '../../../common/events/audit-events'; +import { RedisService } from '@nestjs-labs/nestjs-ioredis'; +import { RefreshLock } from '../utils/refresh-lock'; + +const OAUTH_HTTP_TIMEOUT_MS = 10_000; +// Room for the provider call that follows, and far inside the refresh job's 15-minute window. +const REQUEST_REFRESH_WINDOW_MS = 60_000; +const REFRESH_WAIT_TIMEOUT_MS = 5_000; +const REFRESH_WAIT_POLL_MS = 200; + +const CONNECT_UNSUPPORTED_MESSAGE = + 'This integration does not support linking your account from Docmost'; + +export type OAuthFlow = 'install' | 'connect'; + +// RFC 6749 token charset; a control character would make undici echo the token in a header error. +const isWellFormedToken = (token: unknown): token is string => + typeof token === 'string' && /^[\x21-\x7e]+$/.test(token); + +// Only the error code is safe to log; the rest of the body can name the tenant, app and user. +function tokenErrorCode(body: { error?: unknown } | null): string { + return typeof body?.error === 'string' && /^[a-z_]{1,40}$/.test(body.error) + ? body.error + : ''; +} + +type OAuthTokenResponse = { + access_token: string; + refresh_token?: string; + expires_in?: number; + token_type?: string; + scope?: string; + error?: string; +}; + +type CompletedConnection = { + connection: IntegrationConnection; + account?: ProviderAccount; +}; + +export type OAuthStatePayload = { + // Only admin-gated install states may write the shared workspace connection. + flow: OAuthFlow; + // null for installs, since completion creates the row. + integrationId: string | null; + type: string; + userId: string; + workspaceId: string; + // Workspace URL the central callback hands the code back to. + returnUrl: string; + // Path on returnUrl to land on after the callback. + returnPath: string; + // Bound to a cookie on the browser that started the flow. + nonce: string; + exp: number; +}; + +@Injectable() +export class OAuthService { + private readonly logger = new Logger(OAuthService.name); + private readonly refreshLock: RefreshLock; + // Concurrent requests for one connection share a single refresh in this process. + private readonly pendingRefreshes = new Map>(); + + constructor( + @InjectKysely() private readonly db: KyselyDB, + 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, + private readonly userRepo: UserRepo, + private readonly workspaceAbility: WorkspaceAbilityFactory, + @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, + private readonly redisService: RedisService, + ) { + this.refreshLock = new RefreshLock(this.redisService.getOrThrow()); + } + + async getAuthorizationUrl( + integrationId: string, + workspaceId: string, + userId: string, + returnPathOverride?: string, + ): Promise<{ authorizationUrl: string; type: string; nonce: 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; + + if (!supportsOAuthConnect(oauthConfig)) { + throw new BadRequestException(CONNECT_UNSUPPORTED_MESSAGE); + } + + // Workspace-scoped providers link a member's identity instead of re-running the install. + const identity = + (oauthConfig.connectionScope ?? 'user') === 'workspace' + ? oauthConfig.identity + : undefined; + + const callbackUrl = this.buildCallbackUrl(integration.type); + + const workspace = await this.workspaceRepo.findById(workspaceId); + const returnUrl = this.domainService.getWorkspaceUrl( + workspace ?? { hostname: null, customDomain: null }, + ); + + const returnPath = returnPathOverride ?? '/settings/account/connections'; + const nonce = crypto.randomBytes(32).toString('base64url'); + + const state = this.createSignedState({ + flow: 'connect', + integrationId, + type: integration.type, + userId, + workspaceId, + returnUrl, + returnPath, + nonce, + exp: Date.now() + 10 * 60 * 1000, + }); + + const params = new URLSearchParams({ + ...((identity ? identity.authParams : oauthConfig.authParams) ?? {}), + client_id: this.getClientId(integration.type, oauthConfig), + redirect_uri: callbackUrl, + response_type: 'code', + state, + }); + + const scope = (identity?.scopes ?? oauthConfig.scopes) + .map((s) => encodeURIComponent(s)) + .join('%20'); + + return { + authorizationUrl: `${identity?.authUrl ?? oauthConfig.authUrl}?${params.toString()}&scope=${scope}`, + type: integration.type, + nonce, + }; + } + + async getInstallAuthorizationUrl( + type: string, + workspaceId: string, + userId: string, + ): Promise<{ authorizationUrl: string; nonce: string }> { + const provider = this.registry.getProvider(type); + if (!provider || provider.definition.hidden) { + throw new BadRequestException(`Unknown integration type: ${type}`); + } + if (!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 nonce = crypto.randomBytes(32).toString('base64url'); + + const state = this.createSignedState({ + flow: 'install', + integrationId: null, + type, + userId, + workspaceId, + returnUrl, + returnPath: '/settings/integrations', + nonce, + exp: Date.now() + 10 * 60 * 1000, + }); + + const params = new URLSearchParams({ + ...(oauthConfig.authParams ?? {}), + client_id: this.getClientId(type, oauthConfig), + 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}`, + nonce, + }; + } + + 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.stateKey(); + const expected = crypto + .createHmac('sha256', secret) + .update(data) + .digest('base64url'); + + const sigBuf = Buffer.from(signature); + const expBuf = Buffer.from(expected); + if ( + sigBuf.length !== expBuf.length || + !crypto.timingSafeEqual(sigBuf, expBuf) + ) { + 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, + state: Pick< + OAuthStatePayload, + 'flow' | 'integrationId' | 'type' | 'userId' | 'workspaceId' + >, + ): Promise { + if (state.type !== type) { + throw new BadRequestException('OAuth state does not match this provider'); + } + + const provider = this.registry.getProvider(type); + if (!provider || !provider.definition.oauth) { + throw new BadRequestException('Integration does not support OAuth'); + } + + if (state.flow === 'install') { + if (provider.definition.hidden) { + throw new BadRequestException(`Unknown integration type: ${type}`); + } + await this.assertCanManageWorkspace(state.userId, state.workspaceId); + const { connection, account } = await this.completeInstall( + provider, + type, + code, + state, + ); + this.auditService.log({ + event: AuditEvent.INTEGRATION_INSTALLED, + resourceType: AuditResource.INTEGRATION, + resourceId: connection.integrationId, + changes: { after: { provider: type, ...(account && { account }) } }, + }); + return connection; + } + const { connection, account } = await this.completeConnect( + provider, + type, + code, + state, + ); + this.auditService.log({ + event: AuditEvent.INTEGRATION_CONNECTED, + resourceType: AuditResource.INTEGRATION, + resourceId: state.integrationId, + changes: { after: { provider: type, ...(account && { account }) } }, + }); + return connection; + } + + + // Catches an admin role lost between authorize and completion. + private async assertCanManageWorkspace( + userId: string, + workspaceId: string, + ): Promise { + const [user, workspace] = await Promise.all([ + this.userRepo.findById(userId, workspaceId), + this.workspaceRepo.findById(workspaceId), + ]); + if (!user || isUserDisabled(user) || !workspace) { + throw new ForbiddenException(); + } + const ability = this.workspaceAbility.createForUser(user, workspace); + if ( + ability.cannot(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Settings) + ) { + throw new ForbiddenException(); + } + } + + // The integration row is only created after a successful token exchange. + private async completeInstall( + provider: IntegrationProvider, + type: string, + code: string, + state: Pick, + ): Promise { + const { userId, workspaceId } = state; + const integration = state.integrationId + ? await this.integrationRepo.findById(state.integrationId) + : null; + if (integration && integration.workspaceId !== workspaceId) { + throw new NotFoundException('Integration not found'); + } + + const settings = (integration?.settings as Record) ?? {}; + const oauthConfig = provider.getOAuthConfig + ? provider.getOAuthConfig(settings) + : provider.definition.oauth; + + const tokenResponse = await this.requestTokens(oauthConfig, type, code); + // Read before the transaction so no provider call runs inside it. + const account = await this.resolveAccount(provider, type, tokenResponse); + + // A failing onConnected must not leave the row and bot token behind. + const connection = await executeTx(this.db, async (trx) => { + const installed = + integration ?? + (await this.integrationRepo.insertOrRestore( + { type, workspaceId, installedById: userId }, + trx, + )); + + const stored = await this.storeConnection( + installed.id, + userId, + workspaceId, + tokenResponse, + provider.definition.oauth?.connectionScope ?? 'user', + account, + trx, + ); + + // Only the install flow fires onConnected. + if (provider.onConnected) { + await provider.onConnected({ + integrationId: installed.id, + workspaceId, + accessToken: tokenResponse.access_token, + refreshToken: tokenResponse.refresh_token, + userId, + metadata: tokenResponse, + account, + trx, + }); + } + + return stored; + }); + + return { connection, account }; + } + + // Writes only the caller's own row, never the shared workspace connection. + private async completeConnect( + provider: IntegrationProvider, + type: string, + code: string, + state: Pick, + ): Promise { + const { userId, workspaceId } = state; + const integration = state.integrationId + ? await this.integrationRepo.findById(state.integrationId) + : null; + if (!integration || integration.workspaceId !== workspaceId) { + throw new NotFoundException('Integration not found'); + } + + const settings = (integration.settings as Record) ?? {}; + const oauthConfig = provider.getOAuthConfig + ? provider.getOAuthConfig(settings) + : provider.definition.oauth; + if (!supportsOAuthConnect(oauthConfig)) { + throw new BadRequestException(CONNECT_UNSUPPORTED_MESSAGE); + } + + if ((oauthConfig.connectionScope ?? 'user') === 'user') { + const tokenResponse = await this.requestTokens(oauthConfig, type, code); + const account = await this.resolveAccount(provider, type, tokenResponse); + const connection = await this.storeConnection( + integration.id, + userId, + workspaceId, + tokenResponse, + 'user', + account, + ); + return { connection, account }; + } + + const identity = oauthConfig.identity; + if (!identity || !provider.resolveIdentity) { + throw new BadRequestException(CONNECT_UNSUPPORTED_MESSAGE); + } + const tokenResponse = await this.requestTokens( + oauthConfig, + type, + code, + identity.tokenUrl, + ); + const resolved = await provider.resolveIdentity({ + integrationId: integration.id, + workspaceId, + userId, + tokenResponse, + settings, + }); + // Stops a phished completion from binding someone else's provider account. + if (resolved.email) { + const user = await this.userRepo.findById(userId, workspaceId); + const userEmail = user?.email?.trim().toLowerCase(); + if (!userEmail || userEmail !== resolved.email.trim().toLowerCase()) { + throw new IdentityEmailMismatchError(); + } + } + await this.assertAccountNotLinkedElsewhere( + integration.id, + resolved.account.id, + userId, + ); + + const connection = await this.connectionRepo.upsertUserLink({ + integrationId: integration.id, + workspaceId, + userId, + providerUserId: resolved.account.id, + metadata: { ...resolved.metadata, account: resolved.account }, + }); + return { connection, account: resolved.account }; + } + + private async storeConnection( + integrationId: string, + userId: string, + workspaceId: string, + tokenResponse: OAuthTokenResponse, + connectionScope: 'workspace' | 'user', + account: ProviderAccount | undefined, + trx?: KyselyTransaction, + ): Promise { + const encryptedAccessToken = this.encryptionService.encrypt( + tokenResponse.access_token, + EncryptionPurpose.INTEGRATION_TOKENS, + ); + const encryptedRefreshToken = tokenResponse.refresh_token + ? this.encryptionService.encrypt( + tokenResponse.refresh_token, + EncryptionPurpose.INTEGRATION_TOKENS, + ) + : null; + + const tokenExpiresAt = tokenResponse.expires_in + ? new Date(Date.now() + tokenResponse.expires_in * 1000) + : null; + + const values = { + integrationId, + userId, + workspaceId, + accessToken: encryptedAccessToken, + refreshToken: encryptedRefreshToken, + tokenExpiresAt, + scopes: tokenResponse.scope ?? null, + }; + + // The installer's account never goes on the shared bot row. + if (connectionScope === 'workspace') { + return this.connectionRepo.upsertWorkspaceConnection(values, trx); + } + if (account) { + await this.assertAccountNotLinkedElsewhere( + integrationId, + account.id, + userId, + trx, + ); + } + // A missing account clears the old one, so a reconnect never keeps a stale identity. + return this.connectionRepo.upsert( + { + ...values, + providerUserId: account?.id ?? null, + metadata: account ? { account } : null, + }, + trx, + ); + } + + private async resolveAccount( + provider: IntegrationProvider, + type: string, + tokenResponse: OAuthTokenResponse, + ): Promise { + if (!provider.resolveAccount) return undefined; + try { + return await provider.resolveAccount({ + accessToken: tokenResponse.access_token, + tokenResponse, + }); + } catch (err) { + this.logger.warn( + `Could not read the connected ${type} account: ${(err as Error).message}`, + ); + return undefined; + } + } + + // Checked up front so a rebind gets a clear error, not a unique-index violation. + private async assertAccountNotLinkedElsewhere( + integrationId: string, + accountId: string, + userId: string, + trx?: KyselyTransaction, + ): Promise { + const existingLink = await this.connectionRepo.findUserLink( + integrationId, + accountId, + trx, + ); + if (existingLink && existingLink.userId !== userId) { + throw new BadRequestException( + 'This account is already connected to a different Docmost user. Disconnect it there first.', + ); + } + } + + async getValidAccessToken( + connection: IntegrationConnection, + ): Promise { + if (connection.invalidatedAt) { + throw new TokenInvalidError(); + } + + const expiresAt = connection.tokenExpiresAt + ? new Date(connection.tokenExpiresAt).getTime() + : null; + if ( + expiresAt === null || + expiresAt - Date.now() > REQUEST_REFRESH_WINDOW_MS + ) { + return this.encryptionService.decrypt( + connection.accessToken, + EncryptionPurpose.INTEGRATION_TOKENS, + ); + } + + if (!connection.refreshToken) { + // Not every provider answers an expired token with a 401. + if (expiresAt < Date.now()) { + throw new TokenInvalidError( + 'Access token expired and cannot be refreshed', + ); + } + return this.encryptionService.decrypt( + connection.accessToken, + EncryptionPurpose.INTEGRATION_TOKENS, + ); + } + + let pending = this.pendingRefreshes.get(connection.id); + if (!pending) { + pending = this.refreshForRequest(connection).finally(() => + this.pendingRefreshes.delete(connection.id), + ); + this.pendingRefreshes.set(connection.id, pending); + } + return pending; + } + + private async refreshForRequest( + connection: IntegrationConnection, + ): Promise { + const lockToken = await this.refreshLock.acquire(connection.id); + if (!lockToken) { + await this.refreshLock.waitForRelease(connection.id, { + timeoutMs: REFRESH_WAIT_TIMEOUT_MS, + pollMs: REFRESH_WAIT_POLL_MS, + }); + const accessToken = this.usableAccessToken( + await this.connectionRepo.findById(connection.id), + ); + if (!accessToken) { + throw new TokenExpiredError(); + } + return accessToken; + } + + try { + return await this.refreshAccessToken(connection); + } catch (err) { + if (err instanceof TokenInvalidError || err instanceof TokenExpiredError) { + throw err; + } + // A transient failure keeps the connection; a later request tries again. + throw new TokenExpiredError(); + } finally { + await this.refreshLock.release(connection.id, lockToken); + } + } + + private usableAccessToken( + row: IntegrationConnection | undefined, + ): string | null { + if (!row?.accessToken || row.invalidatedAt) { + return null; + } + if ( + row.tokenExpiresAt && + new Date(row.tokenExpiresAt).getTime() <= Date.now() + ) { + return null; + } + return this.encryptionService.decrypt( + row.accessToken, + EncryptionPurpose.INTEGRATION_TOKENS, + ); + } + + // Callers must hold the connection's refresh lock. + async refreshAccessToken(connection: IntegrationConnection): Promise { + 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 clientId = this.getClientId(integration.type, oauthConfig); + const clientSecret = this.getClientSecret(integration.type, oauthConfig); + + try { + const current = await this.connectionRepo.findById(connection.id); + // Gone, retired, refreshed or reconnected since the caller read it. + if ( + !current?.refreshToken || + current.invalidatedAt || + current.accessToken !== connection.accessToken + ) { + const accessToken = this.usableAccessToken(current); + if (!accessToken) { + throw new TokenExpiredError(); + } + return accessToken; + } + // A reconnect or another refresh meanwhile turns the writes below into no-ops. + const expected = { refreshToken: current.refreshToken }; + + const basicAuth = oauthConfig.clientAuth === 'basic'; + const params = new URLSearchParams({ + grant_type: 'refresh_token', + ...(basicAuth ? {} : { client_id: clientId, client_secret: clientSecret }), + refresh_token: this.encryptionService.decrypt( + current.refreshToken, + EncryptionPurpose.INTEGRATION_TOKENS, + ), + }); + if (oauthConfig.scopeOnRefresh) { + params.set('scope', oauthConfig.scopes.join(' ')); + } + + const response = await this.postTokenRequest( + integration.type, + oauthConfig.refreshUrl ?? oauthConfig.tokenUrl, + params, + basicAuth ? { clientId, clientSecret } : undefined, + ); + + // A parse error would quote the body. + const data: OAuthTokenResponse | null = await response + .json() + .catch(() => null); + + // GitHub reports token errors with a 200 and an error body. + if (!response.ok || data?.error !== undefined) { + const errorCode = tokenErrorCode(data); + this.logger.error( + `Token refresh failed for ${integration.type}: ${response.status} ${errorCode}`.trimEnd(), + ); + // invalid_client etc. are config errors, not a dead grant + if ( + errorCode === 'invalid_grant' || + errorCode === 'interaction_required' || + errorCode === 'bad_refresh_token' + ) { + await this.connectionRepo.invalidate(current.id, expected); + throw new TokenInvalidError( + `Refresh token rejected for ${integration.type}`, + ); + } + throw new BadRequestException('Token refresh failed'); + } + + if (!data) { + this.logger.error( + `Token refresh for ${integration.type} returned a response that is not JSON`, + ); + throw new BadRequestException('Token refresh failed'); + } + if ( + !isWellFormedToken(data.access_token) || + (data.refresh_token && !isWellFormedToken(data.refresh_token)) + ) { + this.logger.error( + `Token refresh for ${integration.type} returned a malformed token`, + ); + throw new BadRequestException('Token refresh failed'); + } + const encryptedAccessToken = this.encryptionService.encrypt( + data.access_token, + EncryptionPurpose.INTEGRATION_TOKENS, + ); + const encryptedRefreshToken = data.refresh_token + ? this.encryptionService.encrypt( + data.refresh_token, + EncryptionPurpose.INTEGRATION_TOKENS, + ) + : current.refreshToken; + const tokenExpiresAt = data.expires_in + ? new Date(Date.now() + data.expires_in * 1000) + : null; + + const saved = await this.connectionRepo.updateIfTokensMatch( + current.id, + expected, + { + accessToken: encryptedAccessToken, + refreshToken: encryptedRefreshToken, + tokenExpiresAt, + invalidatedAt: null, + }, + ); + if (saved) { + return data.access_token; + } + // Reconnected meanwhile; the response belongs to the old grant. + const accessToken = this.usableAccessToken( + await this.connectionRepo.findById(current.id), + ); + if (!accessToken) { + throw new TokenExpiredError(); + } + return accessToken; + } catch (err) { + if (err instanceof TokenInvalidError || err instanceof TokenExpiredError) { + 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, + tokenUrl = oauthConfig.tokenUrl, + ): Promise { + const clientId = this.getClientId(type, oauthConfig); + const clientSecret = this.getClientSecret(type, oauthConfig); + const basicAuth = oauthConfig.clientAuth === 'basic'; + const params = new URLSearchParams({ + grant_type: 'authorization_code', + ...(basicAuth ? {} : { client_id: clientId, client_secret: clientSecret }), + code, + redirect_uri: this.buildCallbackUrl(type), + }); + + const response = await this.postTokenRequest( + type, + tokenUrl, + params, + basicAuth ? { clientId, clientSecret } : undefined, + ); + + if (!response.ok) { + const errorCode = tokenErrorCode(await response.json().catch(() => null)); + this.logger.error( + `Token exchange failed for ${type}: ${response.status} ${errorCode}`.trimEnd(), + ); + throw new BadRequestException('OAuth token exchange failed'); + } + + // A parse error would quote the body. + const data: OAuthTokenResponse | null = await response + .json() + .catch(() => null); + if (!data) { + this.logger.error( + `Token exchange for ${type} returned a response that is not JSON`, + ); + throw new BadRequestException('OAuth token exchange failed'); + } + if (!data.access_token) { + this.logger.error(`Token exchange for ${type} returned no access token`); + throw new BadRequestException('OAuth token exchange failed'); + } + if ( + !isWellFormedToken(data.access_token) || + (data.refresh_token && !isWellFormedToken(data.refresh_token)) + ) { + this.logger.error(`Token exchange for ${type} returned a malformed token`); + throw new BadRequestException('OAuth token exchange failed'); + } + return data; + } + + private async postTokenRequest( + type: string, + tokenUrl: string, + params: URLSearchParams, + basicAuth?: { clientId: string; clientSecret: string }, + ): Promise { + const response = await proxyFetch(tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + ...(basicAuth + ? { + Authorization: `Basic ${Buffer.from(`${basicAuth.clientId}:${basicAuth.clientSecret}`).toString('base64')}`, + } + : {}), + }, + body: params.toString(), + // Never follow: a 3xx would re-POST the client secret and code to the Location target. + redirect: 'manual', + signal: AbortSignal.timeout(OAUTH_HTTP_TIMEOUT_MS), + }); + return readCappedResponse(`${type} token endpoint`, response); + } + + buildCallbackUrl(type: string): string { + const appUrl = this.environmentService.getAppUrl(); + return `${appUrl}/api/integrations/oauth/${type}/callback`; + } + + // State gets its own key so a signature made with APP_SECRET for anything else never verifies here. + private stateKey(): Buffer { + return Buffer.from( + crypto.hkdfSync( + 'sha256', + this.environmentService.getAppSecret(), + '', + 'integration-oauth-state', + 32, + ), + ); + } + + private createSignedState(payload: OAuthStatePayload): string { + const data = Buffer.from(JSON.stringify(payload)).toString('base64url'); + const secret = this.stateKey(); + const signature = crypto + .createHmac('sha256', secret) + .update(data) + .digest('base64url'); + return `${data}.${signature}`; + } + + private getClientId(type: string, oauthConfig: OAuthConfig): string { + return this.requireEnv(credentialEnvKey('CLIENT_ID', type, oauthConfig)); + } + + private getClientSecret(type: string, oauthConfig: OAuthConfig): string { + return this.requireEnv( + credentialEnvKey('CLIENT_SECRET', type, oauthConfig), + ); + } + + private requireEnv(envKey: string): string { + const value = process.env[envKey]; + if (!value) { + throw new BadRequestException( + `Missing environment variable: ${envKey}`, + ); + } + return value; + } +} diff --git a/apps/server/src/core/integration/oauth/oauth.utils.ts b/apps/server/src/core/integration/oauth/oauth.utils.ts new file mode 100644 index 00000000..a708318d --- /dev/null +++ b/apps/server/src/core/integration/oauth/oauth.utils.ts @@ -0,0 +1,6 @@ +export const COMPLETION_FALLBACK_REDIRECT = + '/settings/account/connections?error=oauth_failed'; + +export function nonceCookieName(type: string): string { + return `integration_oauth_${type}`; +} diff --git a/apps/server/src/core/integration/providers/figma/figma-patterns.ts b/apps/server/src/core/integration/providers/figma/figma-patterns.ts new file mode 100644 index 00000000..cadea5dd --- /dev/null +++ b/apps/server/src/core/integration/providers/figma/figma-patterns.ts @@ -0,0 +1,9 @@ +import { UnfurlPattern } from '../../registry/integration-provider.interface'; + +export const figmaPatterns: UnfurlPattern[] = [ + { + regex: + /^https?:\/\/([\w.-]+\.)?figma\.com\/(file|proto|board|design)\/([0-9a-zA-Z]{22,128})/, + type: 'figma-file', + }, +]; diff --git a/apps/server/src/core/integration/providers/figma/figma.module.ts b/apps/server/src/core/integration/providers/figma/figma.module.ts new file mode 100644 index 00000000..68273e07 --- /dev/null +++ b/apps/server/src/core/integration/providers/figma/figma.module.ts @@ -0,0 +1,21 @@ +import { Module, OnModuleInit } from '@nestjs/common'; +import { FigmaProvider } from './figma.provider'; +import { FigmaService } from './figma.service'; +import { IntegrationRegistry } from '../../registry/integration-registry'; +import { IntegrationModule } from '../../integration.module'; + +@Module({ + imports: [IntegrationModule], + providers: [FigmaProvider, FigmaService], + exports: [FigmaProvider], +}) +export class FigmaModule implements OnModuleInit { + constructor( + private readonly registry: IntegrationRegistry, + private readonly figmaProvider: FigmaProvider, + ) {} + + onModuleInit() { + this.registry.register(this.figmaProvider); + } +} diff --git a/apps/server/src/core/integration/providers/figma/figma.provider.ts b/apps/server/src/core/integration/providers/figma/figma.provider.ts new file mode 100644 index 00000000..00f3fb48 --- /dev/null +++ b/apps/server/src/core/integration/providers/figma/figma.provider.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@nestjs/common'; +import { + IntegrationProvider, + IntegrationDefinition, + ProviderAccount, + ResolveAccountOpts, + UnfurlOpts, + UnfurlResult, +} from '../../registry/integration-provider.interface'; +import { FigmaService } from './figma.service'; +import { figmaPatterns } from './figma-patterns'; + +@Injectable() +export class FigmaProvider extends IntegrationProvider { + definition: IntegrationDefinition = { + type: 'figma', + name: 'Figma', + description: 'Link previews for Figma designs, prototypes, and FigJam boards', + icon: 'figma', + capabilities: ['oauth', 'unfurl'], + oauth: { + authUrl: 'https://www.figma.com/oauth', + tokenUrl: 'https://api.figma.com/v1/oauth/token', + refreshUrl: 'https://api.figma.com/v1/oauth/refresh', + scopes: ['file_metadata:read', 'current_user:read'], + clientAuth: 'basic', + }, + unfurlPatterns: figmaPatterns, + }; + + constructor(private readonly figmaService: FigmaService) { + super(); + } + + async resolveAccount(opts: ResolveAccountOpts): Promise { + return this.figmaService.getAccount(opts.accessToken); + } + + async unfurl(opts: UnfurlOpts): Promise { + const { match, accessToken, url } = opts; + const fileType = match[2]; + const fileKey = match[3]; + return this.figmaService.unfurlFile(accessToken, fileKey, fileType, url); + } +} diff --git a/apps/server/src/core/integration/providers/figma/figma.service.spec.ts b/apps/server/src/core/integration/providers/figma/figma.service.spec.ts new file mode 100644 index 00000000..fa1fa264 --- /dev/null +++ b/apps/server/src/core/integration/providers/figma/figma.service.spec.ts @@ -0,0 +1,46 @@ +import { proxyFetch } from '../../../../common/proxy-fetch'; +import { FigmaProvider } from './figma.provider'; +import { FigmaService } from './figma.service'; + +// Mock only proxyFetch so the real providerApiFetch handles statuses. +jest.mock('../../../../common/proxy-fetch', () => ({ + proxyFetch: jest.fn(), +})); + +const fetchMock = proxyFetch as jest.Mock; + +function json(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +} + +describe('Figma connected account', () => { + const provider = new FigmaProvider(new FigmaService()); + + beforeEach(() => fetchMock.mockReset()); + + it('asks for the profile scope beside file metadata', () => { + expect(provider.definition.oauth?.scopes).toEqual([ + 'file_metadata:read', + 'current_user:read', + ]); + }); + + it('reads the connected account from /v1/me and keeps no email', async () => { + fetchMock.mockResolvedValue( + json({ + id: '1234567890', + handle: 'Philip Okugbe', + email: 'philip@docmost.com', + img_url: 'https://s3-alpha.figma.com/profile/1234567890', + }), + ); + + await expect( + provider.resolveAccount({ accessToken: 'token', tokenResponse: {} }), + ).resolves.toEqual({ id: '1234567890', displayName: 'Philip Okugbe' }); + expect(fetchMock.mock.calls[0][0]).toBe('https://api.figma.com/v1/me'); + }); +}); diff --git a/apps/server/src/core/integration/providers/figma/figma.service.ts b/apps/server/src/core/integration/providers/figma/figma.service.ts new file mode 100644 index 00000000..7b6f20d3 --- /dev/null +++ b/apps/server/src/core/integration/providers/figma/figma.service.ts @@ -0,0 +1,63 @@ +import { Injectable } from '@nestjs/common'; +import { + ProviderAccount, + UnfurlResult, +} from '../../registry/integration-provider.interface'; +import { providerApiFetch } from '../../utils/provider-fetch'; +import { toProviderAccount } from '../../utils/integration.utils'; + +const FIGMA_API = 'https://api.figma.com/v1'; + +const FILE_TYPE_LABELS: Record = { + design: 'Design file', + file: 'Design file', + proto: 'Prototype', + board: 'FigJam board', +}; + +@Injectable() +export class FigmaService { + async getAccount(accessToken: string): Promise { + const data = await this.apiGet(accessToken, '/me'); + return toProviderAccount({ id: data.id, displayName: data.handle }); + } + + async unfurlFile( + accessToken: string, + fileKey: string, + fileType: string, + url: string, + ): Promise { + const data = await this.apiGet(accessToken, `/files/${fileKey}/meta`); + const file = data.file; + + const typeLabel = FILE_TYPE_LABELS[fileType] ?? 'Figma file'; + + return { + title: file.name, + description: typeLabel, + url, + provider: 'figma', + providerIcon: 'figma', + author: file.last_touched_by?.handle, + authorAvatarUrl: file.last_touched_by?.img_url, + metadata: { + type: fileType, + fileKey, + thumbnailUrl: file.thumbnail_url, + lastModified: file.last_touched_at, + }, + }; + } + + private async apiGet(accessToken: string, path: string): Promise { + const response = await providerApiFetch('Figma', `${FIGMA_API}${path}`, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }); + + return response.json(); + } +} diff --git a/apps/server/src/core/integration/providers/github/github-patterns.spec.ts b/apps/server/src/core/integration/providers/github/github-patterns.spec.ts new file mode 100644 index 00000000..0897eab3 --- /dev/null +++ b/apps/server/src/core/integration/providers/github/github-patterns.spec.ts @@ -0,0 +1,143 @@ +import { integrationLinkPatterns } from '@docmost/editor-ext'; +import { buildGitHubPatterns } from './github-patterns'; + +function firstMatch(patterns: { type: string; regex: RegExp }[], url: string) { + for (const pattern of patterns) { + const match = url.match(pattern.regex); + if (match) return { type: pattern.type, groups: match.slice(1) }; + } + return null; +} + +// Each of these matched before the guard; the browser resolves them to a different path. +const RESOLVED_ELSEWHERE = [ + 'https://github.com/a/b/pull/1/../../../../evil/x/pull/2', + 'https://github.com/../user?x=/pull/1', + 'https://github.com/a/b/pull/1/%2e%2e/%2E%2E/%2e./.%2e/evil/x/pull/2', + 'https://github.com/a/b/pull/1/commits/abc123/../../../../../evil/x/pull/2', + 'https://github.com/a/b/issues/1/..', + 'https://github.com/a/b/issues/1/..?x=1', + 'https://github.com/a/b/commit/abc123/./x', + 'https://github.com/a/b/blob/main/../../../evil/x/blob/main/secret.txt', + 'https://github.com/a/b/pulls/../../evil/x/pulls', + 'https://github.com/a/b/releases/./tag/v1', + 'https://github.com/a/b/issues/created_by/../../../evil/x/issues', + 'https://github.com/a/b/pull/1\\..\\..\\evil', + 'https://github.com/a\\b/c/pull/1', +]; + +const STILL_MATCHED: [string, string, (string | undefined)[]][] = [ + ['https://github.com/o/.github/pull/1', 'github-pr', ['o', '.github', '1']], + ['https://github.com/o/my..repo/issues/2', 'github-issue', ['o', 'my..repo', '2']], + ['https://github.com/o/.../issues/3', 'github-issue', ['o', '...', '3']], + ['https://github.com/o/r/pull/1#discussion_r1/../..', 'github-pr', ['o', 'r', '1']], + ['https://github.com/o/r/pulls?q=../../x', 'github-pulls-list', ['o', 'r']], + [ + 'https://github.com/o/r/blob/main/.github/workflows/ci.yml#L1-L3', + 'github-file', + ['o', 'r', 'main', '.github/workflows/ci.yml', '1', '3'], + ], + [ + 'https://github.com/o/r/blob/main/src/..hidden/a.ts', + 'github-file', + ['o', 'r', 'main', 'src/..hidden/a.ts', undefined, undefined], + ], + ['https://github.com/o/r/commit/abc123', 'github-commit', ['o', 'r', 'abc123']], + ['https://github.com/o/r', 'github-repo', ['o', 'r']], + ['https://github.com/my-org_1/repo.name-x/pull/1', 'github-pr', ['my-org_1', 'repo.name-x', '1']], +]; + +// The browser opens the repo or owner for these; the rest of the route is query or fragment. +const ROUTE_IN_QUERY_OR_FRAGMENT = [ + 'https://github.com/o/r?x=/pull/1', + 'https://github.com/o/r#x/pull/1', + 'https://github.com/o?x=/r/issues/1', + 'https://github.com/o/r?/commit/abc123', + 'https://github.com/o/r?x=/pull/1/commits/abc123', + 'https://github.com/o/r#/blob/main/a.ts', + 'https://github.com/o/r?x=/pulls', + 'https://github.com/o/r#/releases', + 'https://github.com/o/r?x=/issues', +]; + +describe('buildGitHubPatterns', () => { + const patterns = buildGitHubPatterns('https://github.com'); + const byType = (type: string) => patterns.find((p) => p.type === type)!; + const crafted = 'https://github.com/o/r/pulls/' + '?'.repeat(40000) + '\n'; + + it('keeps the pulls and releases list patterns linear on a crafted input', () => { + for (const type of ['github-pulls-list', 'github-releases-list']) { + const started = process.hrtime.bigint(); + expect(byType(type).regex.test(crafted)).toBe(false); + expect(Number(process.hrtime.bigint() - started) / 1e6).toBeLessThan(100); + } + }); + + it.each([ + ['https://github.com/o/r/pulls', 'github-pulls-list'], + ['https://github.com/o/r/pulls/', 'github-pulls-list'], + ['https://github.com/o/r/pulls?q=is%3Aopen', 'github-pulls-list'], + ['https://github.com/o/r/pulls/extra', 'github-pulls-list'], + ['https://github.com/o/r/releases', 'github-releases-list'], + ['https://github.com/o/r/releases/tag/v1.0.0?x=1', 'github-releases-list'], + ])('still matches %s as %s', (url, type) => { + expect(byType(type).regex.test(url)).toBe(true); + }); + + it('does not match a longer path segment as the list', () => { + expect(byType('github-pulls-list').regex.test('https://github.com/o/r/pullsx')).toBe(false); + }); + + it.each(ROUTE_IN_QUERY_OR_FRAGMENT)('does not match %s', (url) => { + expect(firstMatch(patterns, url)).toBeNull(); + }); + + describe('paths the browser would resolve elsewhere', () => { + it.each(RESOLVED_ELSEWHERE)('does not match %s', (url) => { + expect(firstMatch(patterns, url)).toBeNull(); + }); + + it.each(STILL_MATCHED)('still matches %s as %s', (url, type, groups) => { + expect(firstMatch(patterns, url)).toEqual({ type, groups }); + }); + + it('guards a GitHub Enterprise host the same way', () => { + const enterprise = buildGitHubPatterns('https://github.acme.com'); + expect( + firstMatch(enterprise, 'https://github.acme.com/a/b/pull/1/../../../../evil/x/pull/2'), + ).toBeNull(); + expect(firstMatch(enterprise, 'https://github.acme.com/../user?x=/pull/1')).toBeNull(); + expect(firstMatch(enterprise, 'https://github.acme.com/o/r/pull/1')).toEqual({ + type: 'github-pr', + groups: ['o', 'r', '1'], + }); + }); + + it('stays linear on hostile input', () => { + const crafted = [ + 'https://github.com/' + 'a/'.repeat(30000), + 'https://github.com/o/r/pull/1' + '/.x'.repeat(10000), + 'https://github.com/o/r/blob/main/' + '%2e'.repeat(10000) + '\n', + ]; + for (const input of crafted) { + const started = process.hrtime.bigint(); + for (const pattern of patterns) pattern.regex.test(input); + expect(Number(process.hrtime.bigint() - started) / 1e6).toBeLessThan(100); + } + }); + + it.each([ + ...RESOLVED_ELSEWHERE, + ...ROUTE_IN_QUERY_OR_FRAGMENT, + ...STILL_MATCHED.map(([url]) => url), + ])( + 'agrees with the editor patterns on %s', + (url) => { + const editorPatterns = integrationLinkPatterns.filter( + (pattern) => pattern.provider === 'github', + ); + expect(firstMatch(editorPatterns, url)).toEqual(firstMatch(patterns, url)); + }, + ); + }); +}); 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 00000000..e409a56c --- /dev/null +++ b/apps/server/src/core/integration/providers/github/github-patterns.ts @@ -0,0 +1,70 @@ +import { UnfurlPattern } from '../../registry/integration-provider.interface'; +import { CANONICAL_PATH } from '../../utils/canonical-path'; +import { escapeForRegex } from '../../utils/integration.utils'; + +export function buildGitHubPatterns(baseUrl: string): UnfurlPattern[] { + const prefix = `^${escapeForRegex(baseUrl)}${CANONICAL_PATH}`; + return [ + // Commit within a PR: /:owner/:repo/pull/:num/commits/:sha + { + regex: new RegExp( + `${prefix}\\/([a-zA-Z0-9\\-_.]+)\\/([a-zA-Z0-9\\-_.]+)\\/pull\\/(\\d+)\\/commits\\/([a-f0-9]+)`, + ), + type: 'github-pr-commit', + }, + // PR sub-pages: /:owner/:repo/pull/:num(/checks|/commits|/files)? + { + regex: new RegExp(`${prefix}\\/([a-zA-Z0-9\\-_.]+)\\/([a-zA-Z0-9\\-_.]+)\\/pull\\/(\\d+)`), + type: 'github-pr', + }, + // Single issue: /:owner/:repo/issues/:num + { + regex: new RegExp( + `${prefix}\\/([a-zA-Z0-9\\-_.]+)\\/([a-zA-Z0-9\\-_.]+)\\/issues\\/(\\d+)`, + ), + type: 'github-issue', + }, + // Commit: /:owner/:repo/commit(s)/:sha + { + regex: new RegExp( + `${prefix}\\/([a-zA-Z0-9\\-_.]+)\\/([a-zA-Z0-9\\-_.]+)\\/commits?\\/([a-f0-9]+)`, + ), + type: 'github-commit', + }, + // File/blob: /:owner/:repo/blob/:ref/:path(#L:start(-L:end))? + { + regex: new RegExp( + `${prefix}\\/([a-zA-Z0-9\\-_.]+)\\/([a-zA-Z0-9\\-_.]+)\\/blob\\/([^\\/]+)\\/(.+?)(?:#L(\\d+)(?:-L(\\d+))?)?$`, + ), + type: 'github-file', + }, + // Pulls list: /:owner/:repo/pulls + { + regex: new RegExp( + `${prefix}\\/([a-zA-Z0-9\\-_.]+)\\/([a-zA-Z0-9\\-_.]+)\\/pulls(?:[\\/?].*)?$`, + ), + type: 'github-pulls-list', + }, + // Issues list: /:owner/:repo/issues(/created_by/...|/assigned/...)? + { + regex: new RegExp( + `${prefix}\\/([a-zA-Z0-9\\-_.]+)\\/([a-zA-Z0-9\\-_.]+)\\/issues(?:\\/(?:created_by|assigned)\\/[\\w.\\/-]+)?\\/?(?:\\?.*)?$`, + ), + type: 'github-issues-list', + }, + // Releases: /:owner/:repo/releases + { + regex: new RegExp( + `${prefix}\\/([a-zA-Z0-9\\-_.]+)\\/([a-zA-Z0-9\\-_.]+)\\/releases(?:[\\/?].*)?$`, + ), + type: 'github-releases-list', + }, + // Repo: /:owner/:repo + { + regex: new RegExp( + `${prefix}\\/([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 00000000..ed4c52be --- /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.spec.ts b/apps/server/src/core/integration/providers/github/github.provider.spec.ts new file mode 100644 index 00000000..4afb54f0 --- /dev/null +++ b/apps/server/src/core/integration/providers/github/github.provider.spec.ts @@ -0,0 +1,167 @@ +import { Logger } from '@nestjs/common'; +import { GitHubProvider } from './github.provider'; +import { IntegrationRegistry } from '../../registry/integration-registry'; +import { UnfurlPattern } from '../../registry/integration-provider.interface'; + +const GHE_BASE = 'https://github.acme.com'; +const PUBLIC_PR = 'https://github.com/o/r/pull/1'; +const GHE_PR = `${GHE_BASE}/o/r/pull/1`; + +function buildProvider() { + const unfurlPullRequest = jest.fn().mockResolvedValue({}); + const provider = new GitHubProvider({ unfurlPullRequest } as any); + return { provider, unfurlPullRequest }; +} + +function firstMatch(patterns: UnfurlPattern[], url: string) { + for (const pattern of patterns) { + const match = url.match(pattern.regex); + if (match) return { match, patternType: pattern.type }; + } + return null; +} + +function matches(provider: GitHubProvider, url: string): boolean { + return firstMatch(provider.getUnfurlPatterns({}), url) !== null; +} + +async function unfurlWith(provider: GitHubProvider, url: string) { + const found = firstMatch(provider.getUnfurlPatterns({}), url)!; + await provider.unfurl({ ...found, url, accessToken: 'token' } as any); +} + +describe('GitHubProvider host matching', () => { + const original = process.env.INTEGRATION_GITHUB_URL; + + beforeEach(() => delete process.env.INTEGRATION_GITHUB_URL); + + afterEach(() => { + if (original === undefined) delete process.env.INTEGRATION_GITHUB_URL; + else process.env.INTEGRATION_GITHUB_URL = original; + }); + + it('declares no static unfurl patterns', () => { + const { provider } = buildProvider(); + expect(provider.definition.unfurlPatterns).toBeUndefined(); + + const registry = new IntegrationRegistry(); + registry.register(provider); + expect(registry.findUnfurlProvider(PUBLIC_PR)).toBeNull(); + }); + + describe('github.com (no INTEGRATION_GITHUB_URL)', () => { + it('matches github.com links', () => { + const { provider } = buildProvider(); + expect(matches(provider, PUBLIC_PR)).toBe(true); + expect(matches(provider, 'https://github.com/o/r')).toBe(true); + expect(matches(provider, GHE_PR)).toBe(false); + }); + + it('calls api.github.com', async () => { + const { provider, unfurlPullRequest } = buildProvider(); + await unfurlWith(provider, PUBLIC_PR); + expect(unfurlPullRequest).toHaveBeenCalledWith( + 'token', 'https://api.github.com', 'o', 'r', 1, PUBLIC_PR, + ); + }); + }); + + describe('GitHub Enterprise (INTEGRATION_GITHUB_URL set)', () => { + beforeEach(() => { + process.env.INTEGRATION_GITHUB_URL = GHE_BASE; + }); + + it('does not match github.com links', () => { + const { provider } = buildProvider(); + expect(matches(provider, PUBLIC_PR)).toBe(false); + expect(matches(provider, 'https://github.com/o/r')).toBe(false); + }); + + it('matches links on the configured host', () => { + const { provider } = buildProvider(); + expect(matches(provider, GHE_PR)).toBe(true); + expect(matches(provider, `${GHE_BASE}/o/r`)).toBe(true); + }); + + it('sends the token to the configured instance api', async () => { + const { provider, unfurlPullRequest } = buildProvider(); + await unfurlWith(provider, GHE_PR); + expect(unfurlPullRequest).toHaveBeenCalledWith( + 'token', `${GHE_BASE}/api/v3`, 'o', 'r', 1, GHE_PR, + ); + }); + + it.each(['https://GitHub.Acme.com', 'https://github.acme.com/', 'HTTPS://GITHUB.ACME.COM//'])( + 'normalizes %s to match lowercase pasted links', + async (configured) => { + process.env.INTEGRATION_GITHUB_URL = configured; + const { provider, unfurlPullRequest } = buildProvider(); + expect(matches(provider, GHE_PR)).toBe(true); + await unfurlWith(provider, GHE_PR); + expect(unfurlPullRequest.mock.calls[0][1]).toBe(`${GHE_BASE}/api/v3`); + }, + ); + }); + + describe('INTEGRATION_GITHUB_URL validation', () => { + let warn: jest.SpyInstance; + + beforeEach(() => { + warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => warn.mockRestore()); + + it.each(['github.acme.com', 'github.acme.com:8443', 'ftp://github.acme.com'])( + 'warns once and falls back to github.com for %s', + async (configured) => { + process.env.INTEGRATION_GITHUB_URL = configured; + const { provider, unfurlPullRequest } = buildProvider(); + + expect(matches(provider, PUBLIC_PR)).toBe(true); + expect(matches(provider, GHE_PR)).toBe(false); + expect(provider.getOAuthConfig({})).toMatchObject({ + authUrl: 'https://github.com/login/oauth/authorize', + tokenUrl: 'https://github.com/login/oauth/access_token', + }); + await unfurlWith(provider, PUBLIC_PR); + expect(unfurlPullRequest.mock.calls[0][1]).toBe('https://api.github.com'); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + 'INTEGRATION_GITHUB_URL is not a valid http(s) URL; falling back to https://github.com', + ); + }, + ); + + it('does not warn for a valid url', async () => { + process.env.INTEGRATION_GITHUB_URL = GHE_BASE; + const { provider } = buildProvider(); + provider.getOAuthConfig({}); + await unfurlWith(provider, GHE_PR); + expect(warn).not.toHaveBeenCalled(); + }); + }); + + describe('getUnfurlHosts', () => { + it('reports github.com when INTEGRATION_GITHUB_URL is unset', () => { + const { provider } = buildProvider(); + expect(provider.getUnfurlHosts({})).toEqual(['github.com']); + }); + + it('reports the configured host', () => { + process.env.INTEGRATION_GITHUB_URL = GHE_BASE; + const { provider } = buildProvider(); + expect(provider.getUnfurlHosts({})).toEqual(['github.acme.com']); + }); + + it.each(['https://GitHub.Acme.com', 'https://github.acme.com/'])( + 'reports %s as a lowercase hostname', + (configured) => { + process.env.INTEGRATION_GITHUB_URL = configured; + const { provider } = buildProvider(); + expect(provider.getUnfurlHosts({})).toEqual(['github.acme.com']); + }, + ); + }); +}); 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 00000000..a179e029 --- /dev/null +++ b/apps/server/src/core/integration/providers/github/github.provider.ts @@ -0,0 +1,173 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { + IntegrationProvider, + IntegrationDefinition, + LinkDescription, + OAuthConfig, + ProviderAccount, + ResolveAccountOpts, + UnfurlPattern, + UnfurlOpts, + UnfurlResult, +} from '../../registry/integration-provider.interface'; +import { GitHubService } from './github.service'; +import { buildGitHubPatterns } from './github-patterns'; +import { normalizeBaseUrl } from '../../utils/integration.utils'; + +const DEFAULT_BASE_URL = 'https://github.com'; + +@Injectable() +export class GitHubProvider extends IntegrationProvider { + private readonly logger = new Logger(GitHubProvider.name); + + 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'], + }, + }; + + constructor(private readonly githubService: GitHubService) { + super(); + const configured = process.env.INTEGRATION_GITHUB_URL; + if (configured && !normalizeBaseUrl(configured)) { + this.logger.warn( + `INTEGRATION_GITHUB_URL is not a valid http(s) URL; falling back to ${DEFAULT_BASE_URL}`, + ); + } + } + + getOAuthConfig(settings: Record): OAuthConfig { + const baseUrl = this.resolveBaseUrl(); + return { + authUrl: `${baseUrl}/login/oauth/authorize`, + tokenUrl: `${baseUrl}/login/oauth/access_token`, + scopes: ['repo', 'read:user'], + }; + } + + // Configured host only, so a GitHub Enterprise token never reaches github.com. + getUnfurlPatterns(settings: Record): UnfurlPattern[] { + return buildGitHubPatterns(this.resolveBaseUrl()); + } + + getUnfurlHosts(settings: Record): string[] { + return [new URL(this.resolveBaseUrl()).hostname]; + } + + async resolveAccount(opts: ResolveAccountOpts): Promise { + return this.githubService.getAccount( + opts.accessToken, + this.resolveApiBaseUrl(), + ); + } + + async unfurl(opts: UnfurlOpts): Promise { + const { match, patternType, accessToken, url } = opts; + const apiBaseUrl = this.resolveApiBaseUrl(); + 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_URL; + return (baseUrl && normalizeBaseUrl(baseUrl)) || DEFAULT_BASE_URL; + } + + private resolveApiBaseUrl(): string { + const baseUrl = this.resolveBaseUrl(); + return baseUrl === DEFAULT_BASE_URL + ? 'https://api.github.com' + : `${baseUrl}/api/v3`; + } +} diff --git a/apps/server/src/core/integration/providers/github/github.service.spec.ts b/apps/server/src/core/integration/providers/github/github.service.spec.ts new file mode 100644 index 00000000..63703b7b --- /dev/null +++ b/apps/server/src/core/integration/providers/github/github.service.spec.ts @@ -0,0 +1,146 @@ +import { proxyFetch } from '../../../../common/proxy-fetch'; +import { UnfurlForbiddenError } from '../../registry/integration-provider.interface'; +import { GitHubProvider } from './github.provider'; +import { GitHubService } from './github.service'; + +// Mock only proxyFetch so the real providerApiFetch handles statuses. +jest.mock('../../../../common/proxy-fetch', () => ({ + proxyFetch: jest.fn(), +})); + +const fetchMock = proxyFetch as jest.Mock; + +const API = 'https://api.github.com'; +const ENTERPRISE_API = 'https://github.acme.com/api/v3'; +const PAGE = 'https://github.com/o/r/pull/1'; +const WHEN = '2026-10-01T10:00:00.000Z'; + +const BODY = { + number: 1, + title: 'Add dark mode', + created_at: WHEN, + updated_at: WHEN, + full_name: 'o/r', + sha: 'abc1234', + commit: { message: 'Fix header', author: { date: WHEN } }, +}; + +const service = new GitHubService(); + +const REQUESTS: [string, (api: string, owner: string, repo: string) => Promise, string][] = [ + ['pull request', (api, owner, repo) => service.unfurlPullRequest('token', api, owner, repo, 1, PAGE), '/pulls/1'], + ['issue', (api, owner, repo) => service.unfurlIssue('token', api, owner, repo, 2, PAGE), '/issues/2'], + ['repo', (api, owner, repo) => service.unfurlRepo('token', api, owner, repo, PAGE), ''], + ['commit', (api, owner, repo) => service.unfurlCommit('token', api, owner, repo, 'abc1234', PAGE), '/commits/abc1234'], + ['collection page', (api, owner, repo) => service.unfurlCollectionPage('token', api, owner, repo, 'pulls-list', PAGE), ''], +]; + +function requestedUrls(): string[] { + return fetchMock.mock.calls.map(([url]) => url); +} + +describe('GitHubService API paths', () => { + beforeEach(() => { + fetchMock.mockReset(); + fetchMock.mockImplementation( + async () => + new Response(JSON.stringify(BODY), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + }); + + it.each(REQUESTS)('requests a %s under the plain repo path', async (_, request, suffix) => { + await request(API, 'o', 'r'); + + expect(requestedUrls()).toEqual([`${API}/repos/o/r${suffix}`]); + }); + + it.each(REQUESTS)( + 'percent-encodes owner and repo for a %s, so neither can add a query or path', + async (_, request, suffix) => { + await request(API, 'a b', 'r?x=/#'); + + const requested = new URL(requestedUrls()[0]); + expect(requested.pathname).toBe(`/repos/a%20b/r%3Fx%3D%2F%23${suffix}`); + expect(requested.search).toBe(''); + }, + ); + + it.each(REQUESTS)( + 'keeps an encoded dot segment for a %s inside the Enterprise api root', + async (_, request, suffix) => { + await request(ENTERPRISE_API, '%2e%2e', '..x'); + + expect(new URL(requestedUrls()[0]).pathname).toBe( + `/api/v3/repos/%252e%252e/..x${suffix}`, + ); + }, + ); + + describe.each([ + ['.', 'r'], + ['..', 'r'], + ['o', '.'], + ['o', '..'], + ])('with owner %j and repo %j', (owner, repo) => { + it.each(REQUESTS)('refuses a %s without calling GitHub', async (_, request) => { + await expect(request(ENTERPRISE_API, owner, repo)).rejects.toBeInstanceOf( + UnfurlForbiddenError, + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); +}); + +describe('GitHub connected account', () => { + const original = process.env.INTEGRATION_GITHUB_URL; + + beforeEach(() => { + fetchMock.mockReset(); + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + id: 583231, + login: 'Philipinho', + name: 'Philip Okugbe', + email: 'philip@docmost.com', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + }); + + afterEach(() => { + if (original === undefined) delete process.env.INTEGRATION_GITHUB_URL; + else process.env.INTEGRATION_GITHUB_URL = original; + }); + + it('reads the account from api.github.com and keeps no email', async () => { + delete process.env.INTEGRATION_GITHUB_URL; + const provider = new GitHubProvider(new GitHubService()); + + await expect( + provider.resolveAccount({ accessToken: 'token', tokenResponse: {} }), + ).resolves.toEqual({ + id: '583231', + displayName: 'Philip Okugbe', + username: 'Philipinho', + }); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://api.github.com/user'); + expect(init.headers.Authorization).toBe('Bearer token'); + }); + + it('reads the account from the GitHub Enterprise API', async () => { + process.env.INTEGRATION_GITHUB_URL = 'https://github.acme.com'; + const provider = new GitHubProvider(new GitHubService()); + + await provider.resolveAccount({ accessToken: 'token', tokenResponse: {} }); + + expect(fetchMock.mock.calls[0][0]).toBe( + 'https://github.acme.com/api/v3/user', + ); + }); +}); 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 00000000..e5f02241 --- /dev/null +++ b/apps/server/src/core/integration/providers/github/github.service.ts @@ -0,0 +1,282 @@ +import { Injectable } from '@nestjs/common'; +import { + ProviderAccount, + UnfurlResult, +} from '../../registry/integration-provider.interface'; +import { relativeTime } from '../../utils/relative-time'; +import { providerApiFetch } from '../../utils/provider-fetch'; +import { toProviderAccount } from '../../utils/integration.utils'; +import { repoApiPath } from './github.utils'; + +@Injectable() +export class GitHubService { + async getAccount( + accessToken: string, + apiBaseUrl: string, + ): Promise { + const data = await this.apiGet(accessToken, apiBaseUrl, '/user'); + return toProviderAccount({ + id: data.id, + displayName: data.name, + username: data.login, + }); + } + + async unfurlPullRequest( + accessToken: string, + apiBaseUrl: string, + owner: string, + repo: string, + number: number, + url: string, + ): Promise { + const data = await this.apiGet( + accessToken, + apiBaseUrl, + `${repoApiPath(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, + `${repoApiPath(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' : 'violet', + 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, + repoApiPath(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, + `${repoApiPath(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, + repoApiPath(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 'violet'; + 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/github/github.utils.ts b/apps/server/src/core/integration/providers/github/github.utils.ts new file mode 100644 index 00000000..5215feeb --- /dev/null +++ b/apps/server/src/core/integration/providers/github/github.utils.ts @@ -0,0 +1,9 @@ +import { UnfurlForbiddenError } from '../../registry/integration-provider.interface'; + +export function repoApiPath(owner: string, repo: string): string { + // encodeURIComponent keeps . and .., which the URL parser would resolve out of the path. + if ([owner, repo].some((name) => name === '.' || name === '..')) { + throw new UnfurlForbiddenError('GitHub repository path has a dot segment'); + } + return `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; +} diff --git a/apps/server/src/core/integration/providers/linear/linear-patterns.spec.ts b/apps/server/src/core/integration/providers/linear/linear-patterns.spec.ts new file mode 100644 index 00000000..1841bc5c --- /dev/null +++ b/apps/server/src/core/integration/providers/linear/linear-patterns.spec.ts @@ -0,0 +1,55 @@ +import { integrationLinkPatterns } from '@docmost/editor-ext'; +import { linearPatterns } from './linear-patterns'; + +function firstMatch(url: string) { + for (const pattern of linearPatterns) { + const match = url.match(pattern.regex); + if (match) return { type: pattern.type, groups: match.slice(1) }; + } + return null; +} + +const MATCHES: [string, string, (string | undefined)[]][] = [ + ['https://linear.app/acme/issue/ABC-1', 'linear-issue', ['acme', 'ABC-1', undefined]], + ['https://linear.app/acme/issue/ABC-1/fix-the-login', 'linear-issue', ['acme', 'ABC-1', 'fix-the-login']], + ['https://linear.app/acme/issue/ABC-1#comment-1/../..', 'linear-issue', ['acme', 'ABC-1', undefined]], + ['https://linear.app/acme/issue/ABC-1?q=../..', 'linear-issue', ['acme', 'ABC-1', undefined]], + ['https://linear.app/acme/project/mobile-app-1b9607f47174/overview', 'linear-project', ['acme', 'mobile-app-1b9607f47174']], + ['https://linear.app/acme/initiative/q4-goals-8a7b6c5d4e3f', 'linear-initiative', ['acme', 'q4-goals-8a7b6c5d4e3f']], + ['https://linear.app/acme/view/..x', 'linear-view', ['acme', '..x']], +]; + +// Each of these matched before the guard; the browser resolves them to a different path. +const NON_MATCHES = [ + 'https://linear.app/acme/issue/ABC-1/../../../evil/issue/XYZ-2', + 'https://linear.app/acme/issue/ABC-1/%2e%2e/%2E%2e/%2e./evil/issue/XYZ-2', + 'https://linear.app/acme/issue/ABC-1/slug/..', + 'https://linear.app/acme/project/p/../../../evil/project/q', + 'https://linear.app/acme/initiative/i\\..\\..\\evil', + 'https://linear.app/./view/v1', + 'https://linear.app/acme/view/v1/.', + 'https://linear.app/acme/view/v1/..?x=1', +]; + +describe('linearPatterns', () => { + it.each(MATCHES)('matches %s as %s', (url, type, groups) => { + expect(firstMatch(url)).toEqual({ type, groups }); + }); + + it.each(NON_MATCHES)('does not match %s', (url) => { + expect(firstMatch(url)).toBeNull(); + }); + + it('are the four patterns the editor matches pasted links with, in the same order', () => { + const shape = ({ type, regex }: { type: string; regex: RegExp }) => ({ + type, + source: regex.source, + flags: regex.flags, + }); + + expect(linearPatterns).toHaveLength(4); + expect( + integrationLinkPatterns.filter((pattern) => pattern.provider === 'linear').map(shape), + ).toEqual(linearPatterns.map(shape)); + }); +}); diff --git a/apps/server/src/core/integration/providers/linear/linear-patterns.ts b/apps/server/src/core/integration/providers/linear/linear-patterns.ts new file mode 100644 index 00000000..230a1186 --- /dev/null +++ b/apps/server/src/core/integration/providers/linear/linear-patterns.ts @@ -0,0 +1,29 @@ +import { UnfurlPattern } from '../../registry/integration-provider.interface'; +import { CANONICAL_PATH } from '../../utils/canonical-path'; + +const LINEAR_APP = String.raw`^https?:\/\/${CANONICAL_PATH}linear\.app`; + +export const linearPatterns: UnfurlPattern[] = [ + // Issue: /:team/issue/:KEY-123(/:title-slug)?(#comment)? + { + regex: new RegExp( + String.raw`${LINEAR_APP}\/([^\/]+)\/issue\/([A-Z]+-\d+)(?:\/([^\/?#]+))?`, + ), + type: 'linear-issue', + }, + // Project: /:team/project/:slug(/:tab)? + { + regex: new RegExp(String.raw`${LINEAR_APP}\/([^\/]+)\/project\/([^\/]+)`), + type: 'linear-project', + }, + // Initiative: /:team/initiative/:slug(/:tab)? + { + regex: new RegExp(String.raw`${LINEAR_APP}\/([^\/]+)\/initiative\/([^\/]+)`), + type: 'linear-initiative', + }, + // View: /:team/view/:id(/:tab)? + { + regex: new RegExp(String.raw`${LINEAR_APP}\/([^\/]+)\/view\/([^\/]+)`), + type: 'linear-view', + }, +]; diff --git a/apps/server/src/core/integration/providers/linear/linear.module.ts b/apps/server/src/core/integration/providers/linear/linear.module.ts new file mode 100644 index 00000000..b93d9901 --- /dev/null +++ b/apps/server/src/core/integration/providers/linear/linear.module.ts @@ -0,0 +1,21 @@ +import { Module, OnModuleInit } from '@nestjs/common'; +import { LinearProvider } from './linear.provider'; +import { LinearService } from './linear.service'; +import { IntegrationRegistry } from '../../registry/integration-registry'; +import { IntegrationModule } from '../../integration.module'; + +@Module({ + imports: [IntegrationModule], + providers: [LinearProvider, LinearService], + exports: [LinearProvider], +}) +export class LinearModule implements OnModuleInit { + constructor( + private readonly registry: IntegrationRegistry, + private readonly linearProvider: LinearProvider, + ) {} + + onModuleInit() { + this.registry.register(this.linearProvider); + } +} diff --git a/apps/server/src/core/integration/providers/linear/linear.provider.ts b/apps/server/src/core/integration/providers/linear/linear.provider.ts new file mode 100644 index 00000000..fb7fa6b4 --- /dev/null +++ b/apps/server/src/core/integration/providers/linear/linear.provider.ts @@ -0,0 +1,107 @@ +import { Injectable } from '@nestjs/common'; +import { + IntegrationProvider, + IntegrationDefinition, + LinkDescription, + ProviderAccount, + ResolveAccountOpts, + UnfurlOpts, + UnfurlResult, +} from '../../registry/integration-provider.interface'; +import { LinearService } from './linear.service'; +import { linearPatterns } from './linear-patterns'; + +@Injectable() +export class LinearProvider extends IntegrationProvider { + definition: IntegrationDefinition = { + type: 'linear', + name: 'Linear', + description: 'Link previews for issues, projects, initiatives, and views', + icon: 'linear', + capabilities: ['oauth', 'unfurl'], + oauth: { + authUrl: 'https://linear.app/oauth/authorize', + tokenUrl: 'https://api.linear.app/oauth/token', + scopes: ['read'], + }, + unfurlPatterns: linearPatterns, + }; + + constructor(private readonly linearService: LinearService) { + super(); + } + + async resolveAccount(opts: ResolveAccountOpts): Promise { + return this.linearService.getAccount(opts.accessToken); + } + + async unfurl(opts: UnfurlOpts): Promise { + const { match, patternType, accessToken, url } = opts; + const teamSlug = match[1]; + + switch (patternType) { + case 'linear-issue': { + const issueId = match[2]; + return this.linearService.unfurlIssue(accessToken, issueId, url); + } + + case 'linear-project': { + const slugId = match[2]; + return this.linearService.unfurlProject(accessToken, slugId, teamSlug, url); + } + + case 'linear-initiative': { + const slugId = match[2]; + return this.linearService.unfurlInitiative(accessToken, slugId, teamSlug, url); + } + + case 'linear-view': { + const viewId = match[2]; + return this.linearService.unfurlView(teamSlug, viewId, url); + } + + default: + throw new Error(`Unknown Linear pattern type: ${patternType}`); + } + } + + describeLink( + patternType: string, + match: RegExpMatchArray, + ): LinkDescription | null { + const workspace = match[1]; + switch (patternType) { + case 'linear-issue': { + const titleSlug = match[3] ? this.humanizeSlug(match[3]) : null; + return { + title: `Issue ${match[2]}`, + description: titleSlug ?? workspace, + }; + } + case 'linear-project': + return { + title: this.humanizeSlug(match[2]) ?? 'Project', + description: 'Project', + }; + case 'linear-initiative': + return { + title: this.humanizeSlug(match[2]) ?? 'Initiative', + description: 'Initiative', + }; + case 'linear-view': + return { title: 'View', description: workspace }; + default: + return null; + } + } + + // "mobile-app-1b9607f47174" -> "Mobile app": slugs end in a hex id segment. + private 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); + } +} diff --git a/apps/server/src/core/integration/providers/linear/linear.service.spec.ts b/apps/server/src/core/integration/providers/linear/linear.service.spec.ts new file mode 100644 index 00000000..f2958bd2 --- /dev/null +++ b/apps/server/src/core/integration/providers/linear/linear.service.spec.ts @@ -0,0 +1,58 @@ +import { proxyFetch } from '../../../../common/proxy-fetch'; +import { LinearProvider } from './linear.provider'; +import { LinearService } from './linear.service'; + +// Mock only proxyFetch so the real providerApiFetch handles statuses. +jest.mock('../../../../common/proxy-fetch', () => ({ + proxyFetch: jest.fn(), +})); + +const fetchMock = proxyFetch as jest.Mock; + +function json(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +} + +describe('Linear connected account', () => { + const provider = new LinearProvider(new LinearService()); + + beforeEach(() => fetchMock.mockReset()); + + it('reads the viewer as the connected account', async () => { + fetchMock.mockResolvedValue( + json({ + data: { + viewer: { + id: '8f3e1b2a-4c5d-4e6f-9a0b-1c2d3e4f5a6b', + name: 'Philip Okugbe', + displayName: 'philip', + }, + }, + }), + ); + + await expect( + provider.resolveAccount({ accessToken: 'token', tokenResponse: {} }), + ).resolves.toEqual({ + id: '8f3e1b2a-4c5d-4e6f-9a0b-1c2d3e4f5a6b', + displayName: 'Philip Okugbe', + username: 'philip', + }); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://api.linear.app/graphql'); + expect(JSON.parse(init.body).query).toContain( + 'viewer { id name displayName }', + ); + }); + + it('fails when Linear returns no viewer', async () => { + fetchMock.mockResolvedValue(json({ errors: [{ message: 'not authorized' }] })); + + await expect( + provider.resolveAccount({ accessToken: 'token', tokenResponse: {} }), + ).rejects.toThrow('Provider profile has no account id'); + }); +}); diff --git a/apps/server/src/core/integration/providers/linear/linear.service.ts b/apps/server/src/core/integration/providers/linear/linear.service.ts new file mode 100644 index 00000000..7f25948e --- /dev/null +++ b/apps/server/src/core/integration/providers/linear/linear.service.ts @@ -0,0 +1,275 @@ +import { Injectable } from '@nestjs/common'; +import { format } from 'date-fns'; +import { + ProviderAccount, + UnfurlResult, +} from '../../registry/integration-provider.interface'; +import { providerApiFetch } from '../../utils/provider-fetch'; +import { toProviderAccount } from '../../utils/integration.utils'; + +const LINEAR_API = 'https://api.linear.app/graphql'; + +@Injectable() +export class LinearService { + async getAccount(accessToken: string): Promise { + const data = await this.graphqlRequest( + accessToken, + 'query Viewer { viewer { id name displayName } }', + {}, + ); + const viewer = data?.viewer; + return toProviderAccount({ + id: viewer?.id, + displayName: viewer?.name, + username: viewer?.displayName, + }); + } + + async unfurlIssue( + accessToken: string, + issueIdentifier: string, + url: string, + ): Promise { + const query = ` + query Issue($id: String!) { + issue(id: $id) { + title + identifier + priority + priorityLabel + createdAt + state { + name + color + } + assignee { + name + avatarUrl + } + team { + name + } + labels { + nodes { + name + color + } + } + } + } + `; + + const data = await this.graphqlRequest(accessToken, query, { + id: issueIdentifier, + }); + + const issue = data?.issue; + if (!issue) { + return { + title: issueIdentifier, + url, + provider: 'linear', + providerIcon: 'linear', + }; + } + + const descriptionParts = [ + issue.identifier, + issue.team?.name, + issue.assignee?.name ?? 'Unassigned', + this.formatDate(issue.createdAt), + ].filter(Boolean); + + return { + title: issue.title, + description: descriptionParts.join(' · '), + url, + provider: 'linear', + providerIcon: 'linear', + status: issue.state?.name, + statusColor: issue.state?.color, + author: issue.assignee?.name, + authorAvatarUrl: issue.assignee?.avatarUrl, + metadata: { + type: 'issue', + identifier: issue.identifier, + priority: issue.priorityLabel, + labels: + issue.labels?.nodes?.map((l: any) => ({ + name: l.name, + color: l.color, + })) ?? [], + }, + }; + } + + async unfurlProject( + accessToken: string, + slugId: string, + teamSlug: string, + url: string, + ): Promise { + const query = ` + query ProjectBySlug($slugId: String!) { + projects(filter: { slugId: { eq: $slugId } }, first: 1) { + nodes { + name + state + createdAt + lead { + name + avatarUrl + } + teams { + nodes { + name + } + } + } + } + } + `; + + const data = await this.graphqlRequest(accessToken, query, { slugId }); + const project = data?.projects?.nodes?.[0]; + + if (!project) { + return { + title: this.formatSlug(slugId), + description: teamSlug, + url, + provider: 'linear', + providerIcon: 'linear', + metadata: { type: 'project' }, + }; + } + + const teamName = project.teams?.nodes?.[0]?.name; + const descriptionParts = [ + 'Project', + this.formatDate(project.createdAt), + project.lead?.name, + teamName, + ].filter(Boolean); + + return { + title: project.name, + description: descriptionParts.join(' · '), + url, + provider: 'linear', + providerIcon: 'linear', + status: this.formatProjectState(project.state), + statusColor: this.getProjectStatusColor(project.state), + author: project.lead?.name, + authorAvatarUrl: project.lead?.avatarUrl, + metadata: { type: 'project' }, + }; + } + + async unfurlInitiative( + accessToken: string, + slugId: string, + teamSlug: string, + url: string, + ): Promise { + const query = ` + query InitiativeBySlug($slugId: String!) { + initiatives(filter: { slugId: { eq: $slugId } }, first: 1) { + nodes { + name + description + status + } + } + } + `; + + const data = await this.graphqlRequest(accessToken, query, { slugId }); + const initiative = data?.initiatives?.nodes?.[0]; + + if (!initiative) { + return { + title: this.formatSlug(slugId), + description: teamSlug, + url, + provider: 'linear', + providerIcon: 'linear', + metadata: { type: 'initiative' }, + }; + } + + return { + title: initiative.name, + description: initiative.description?.slice(0, 200) ?? undefined, + url, + provider: 'linear', + providerIcon: 'linear', + status: initiative.status, + metadata: { type: 'initiative' }, + }; + } + + unfurlView( + teamSlug: string, + viewId: string, + url: string, + ): UnfurlResult { + return { + title: `View · ${this.formatSlug(viewId)}`, + description: teamSlug, + url, + provider: 'linear', + providerIcon: 'linear', + metadata: { type: 'view' }, + }; + } + + private formatProjectState(state: string): string { + return state.charAt(0).toUpperCase() + state.slice(1); + } + + private formatDate(iso: string): string { + return format(new Date(iso), 'MMM d'); + } + + private formatSlug(slug: string): string { + return slug + .replace(/-[a-f0-9]+$/, '') + .replace(/-/g, ' ') + .replace(/\b\w/g, (c) => c.toUpperCase()); + } + + private getProjectStatusColor(state: string): string { + switch (state) { + case 'completed': + return 'green'; + case 'started': + return 'yellow'; + case 'planned': + return 'blue'; + case 'paused': + case 'canceled': + return 'gray'; + default: + return 'gray'; + } + } + + private async graphqlRequest( + accessToken: string, + query: string, + variables: Record, + ): Promise { + const response = await providerApiFetch('Linear', LINEAR_API, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ query, variables }), + }); + + const json = await response.json(); + return json.data; + } +} 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 00000000..43d1aab4 --- /dev/null +++ b/apps/server/src/core/integration/registry/integration-provider.interface.ts @@ -0,0 +1,230 @@ +import { FeatureKey } from '../../../common/features'; +import { KyselyTransaction } from '@docmost/db/types/kysely.types'; + +export type IntegrationCapability = 'oauth' | 'unfurl' | 'actions'; + +// Proves which provider account a member holds, without granting API scopes. +export type IdentityOAuthConfig = { + authUrl: string; + tokenUrl: string; + scopes: string[]; + // Extra authorize-URL query params, e.g. Slack's team pin. + authParams?: Record; +}; + +export type OAuthConfig = { + authUrl: string; + tokenUrl: string; + scopes: string[]; + // 'workspace': one shared bot connection (Slack). 'user' (default): a token per Docmost user. + connectionScope?: 'workspace' | 'user'; + // Env credential prefix shared by products on one authorization server (atlassian, google). + credentialsKey?: string; + // Repeat the scopes on refresh, for token endpoints that pick the resource from them (Microsoft Entra). + scopeOnRefresh?: boolean; + // Extra authorize-URL query params the provider's docs require, e.g. Atlassian's audience. + authParams?: Record; + // Token refresh endpoint when the provider uses a separate one (Figma). + refreshUrl?: string; + // Send the client id and secret as HTTP Basic instead of in the body (Figma). + clientAuth?: 'basic'; + // Without it, workspace-scoped members can only link from inside the provider. + identity?: IdentityOAuthConfig; +}; + +// The provider account a connection authenticated as; id is also stored in provider_user_id. +export type ProviderAccount = { + id: string; + displayName?: string; + username?: string; +}; + +export type IdentityLinkEvent = { + integrationId: string; + workspaceId: string; + // The Docmost user linking their account. + userId: string; + tokenResponse: Record; + settings: Record; +}; + +export type ResolvedIdentity = { + account: ProviderAccount; + // Verified email; must match the Docmost user's for the link to succeed. + email?: string; + metadata?: Record; +}; + +export type ResolveAccountOpts = { + accessToken: string; + tokenResponse: Record; +}; + +export class IdentityEmailMismatchError extends Error { + constructor(message = 'Provider account email does not match your account') { + super(message); + this.name = 'IdentityEmailMismatchError'; + } +} + +// Thrown by resolveIdentity for an account outside the installed tenant. +export class IdentityTenantMismatchError extends Error { + constructor(message = 'Provider account belongs to a different tenant') { + super(message); + this.name = 'IdentityTenantMismatchError'; + } +} + +// Thrown by onConnected; inbound events must route to a single Docmost workspace. +export class IntegrationTenantInUseError extends Error { + constructor(message = 'Provider tenant is already connected to another workspace') { + super(message); + this.name = 'IntegrationTenantInUseError'; + } +} + +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[]; + // Not installable, but existing installations keep unfurling. + hidden?: boolean; + // License feature the install is gated on; unset = free. + requiresFeature?: FeatureKey; +}; + +export type ConnectedEvent = { + integrationId: string; + workspaceId: string; + accessToken: string; + refreshToken?: string; + // The Docmost user who completed the install flow. + userId: string; + metadata: Record; + // The installer's provider account, when it could be read. + account?: ProviderAccount; + // Provider writes must use it so a throw rolls back the install. + trx?: KyselyTransaction; +}; + +export type UnfurlOpts = { + url: string; + accessToken: string; + match: RegExpMatchArray; + patternType: string; + settings?: Record; + // Shared-connection providers must check this user can see the resource. + userId: string; + integrationId: string; +}; + +// UnfurlService shows no card for it instead of logging an error. +export class UnfurlForbiddenError extends Error { + constructor(message = 'Not authorized to unfurl this link') { + super(message); + this.name = 'UnfurlForbiddenError'; + } +} + +// The user has no linked provider identity yet (e.g. no Slack account link). +export class UnfurlNeedsConnectionError extends Error { + constructor(message = 'User has not connected this integration') { + super(message); + this.name = 'UnfurlNeedsConnectionError'; + } +} + +// The provider rejected the stored credential (API 401 or invalid_grant). +export class TokenInvalidError extends Error { + constructor(message = 'Integration credential is no longer valid') { + super(message); + this.name = 'TokenInvalidError'; + } +} + +// The access token expired and the refresh job has not renewed it yet. +export class TokenExpiredError extends Error { + constructor(message = 'Integration credential expired and awaits refresh') { + super(message); + this.name = 'TokenExpiredError'; + } +} + +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; +}; + +export type UnfurlNeedsConnection = { + needsConnection: true; + integrationId: string; + integrationType: string; + integrationName: string; + title: string; + description?: string; +}; + +export abstract class IntegrationProvider { + abstract definition: IntegrationDefinition; + + getOAuthConfig?( + workspaceSettings: Record, + ): OAuthConfig; + + getUnfurlPatterns?( + workspaceSettings: Record, + ): UnfurlPattern[]; + + // Lowercase hostnames the client offers cards on; "*.example.com" matches any subdomain. + getUnfurlHosts?(workspaceSettings: Record): string[]; + + onConnected?(opts: ConnectedEvent): Promise; + + // Maps an identity-flow token response to a provider account in the installed tenant. + resolveIdentity?(opts: IdentityLinkEvent): Promise; + + // Reads which account a fresh token belongs to; a throw only skips storing it. + resolveAccount?(opts: ResolveAccountOpts): Promise; + + unfurl?(opts: UnfurlOpts): Promise; + + // Tokenless summary for the connect prompt, e.g. "Pull Request #13337". + describeLink?( + patternType: string, + match: RegExpMatchArray, + url: string, + ): LinkDescription | null; +} diff --git a/apps/server/src/core/integration/registry/integration-registry.spec.ts b/apps/server/src/core/integration/registry/integration-registry.spec.ts new file mode 100644 index 00000000..48ad4f53 --- /dev/null +++ b/apps/server/src/core/integration/registry/integration-registry.spec.ts @@ -0,0 +1,25 @@ +import { IntegrationRegistry } from './integration-registry'; +import { IntegrationProvider } from './integration-provider.interface'; + +const provider = (type: string) => + ({ definition: { type, name: type } }) as unknown as IntegrationProvider; + +describe('IntegrationRegistry', () => { + it('registers providers by type', () => { + const registry = new IntegrationRegistry(); + const github = provider('github'); + + registry.register(github); + + expect(registry.getProvider('github')).toBe(github); + }); + + it('refuses a second provider with the same type', () => { + const registry = new IntegrationRegistry(); + registry.register(provider('linear')); + + expect(() => registry.register(provider('linear'))).toThrow( + 'Integration provider "linear" is already registered', + ); + }); +}); 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 00000000..02edfd4f --- /dev/null +++ b/apps/server/src/core/integration/registry/integration-registry.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@nestjs/common'; +import { + IntegrationDefinition, + IntegrationProvider, +} from './integration-provider.interface'; + +@Injectable() +export class IntegrationRegistry { + private providers = new Map(); + + register(provider: IntegrationProvider): void { + const { type } = provider.definition; + if (this.providers.has(type)) { + throw new Error(`Integration provider "${type}" is already registered`); + } + this.providers.set(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.spec.ts b/apps/server/src/core/integration/repos/integration-connection.repo.spec.ts new file mode 100644 index 00000000..b66911dd --- /dev/null +++ b/apps/server/src/core/integration/repos/integration-connection.repo.spec.ts @@ -0,0 +1,236 @@ +import { + CamelCasePlugin, + CompiledQuery, + DummyDriver, + Kysely, + PostgresAdapter, + PostgresIntrospector, + PostgresQueryCompiler, +} from 'kysely'; +import { IntegrationConnectionRepo } from './integration-connection.repo'; + +function buildRepo() { + const queries: CompiledQuery[] = []; + const db = new Kysely({ + dialect: { + createAdapter: () => new PostgresAdapter(), + createDriver: () => new DummyDriver(), + createIntrospector: (kysely) => new PostgresIntrospector(kysely), + createQueryCompiler: () => new PostgresQueryCompiler(), + }, + plugins: [new CamelCasePlugin()], + log: (event) => { + if (event.level === 'query') queries.push(event.query); + }, + }); + return { repo: new IntegrationConnectionRepo(db as any), queries }; +} + +describe('IntegrationConnectionRepo personal connections', () => { + it('states the partial index predicate when finding a personal connection', async () => { + const { repo, queries } = buildRepo(); + + await repo.findByIntegrationAndUser('integration-1', 'user-1'); + + expect(queries[0].sql).toBe( + 'select * from "integration_connections" where "integration_id" = $1 and "user_id" = $2 and "kind" = $3', + ); + expect(queries[0].parameters).toEqual(['integration-1', 'user-1', 'user']); + }); + + it('disconnects only the personal connection, never the workspace bot row', async () => { + const { repo, queries } = buildRepo(); + + await expect( + repo.deleteByIntegrationAndUser('integration-1', 'user-1'), + ).resolves.toBe(false); + + expect(queries[0].sql).toBe( + 'delete from "integration_connections" where "integration_id" = $1 and "user_id" = $2 and "kind" = $3', + ); + expect(queries[0].parameters).toEqual(['integration-1', 'user-1', 'user']); + }); +}); + +describe('IntegrationConnectionRepo offboarding', () => { + it('deletes only the personal connections of the user', async () => { + const { repo, queries } = buildRepo(); + + await repo.deleteUserConnections('user-1'); + + expect(queries).toHaveLength(1); + expect(queries[0].sql).toBe( + 'delete from "integration_connections" where "user_id" = $1 and "kind" = $2', + ); + expect(queries[0].parameters).toEqual(['user-1', 'user']); + }); + + it('skips tokens of deactivated or deleted users but keeps workspace bot rows', async () => { + const { repo, queries } = buildRepo(); + + await repo.findExpiringTokens(60_000, 500); + + const { sql, parameters } = queries[0]; + expect(sql).toContain( + 'inner join "users" on "users"."id" = "integration_connections"."user_id"', + ); + const kindParam = parameters.indexOf('workspace') + 1; + expect(kindParam).toBeGreaterThan(0); + expect(sql).toContain( + `("integration_connections"."kind" = $${kindParam} or ("users"."deactivated_at" is null and "users"."deleted_at" is null))`, + ); + }); +}); + +describe('IntegrationConnectionRepo expiring tokens', () => { + it('loads a capped batch of the soonest-expiring refreshable tokens', async () => { + const { repo, queries } = buildRepo(); + const before = Date.now(); + + await repo.findExpiringTokens(60_000, 500); + + const { sql, parameters } = queries[0]; + const n = parameters.length; + expect( + sql.slice(sql.indexOf('"integration_connections"."invalidated_at"')), + ).toBe( + `"integration_connections"."invalidated_at" is null and "integration_connections"."refresh_token" is not null and "integration_connections"."token_expires_at" is not null and "integration_connections"."token_expires_at" < $${n - 1} order by "integration_connections"."token_expires_at" asc limit $${n}`, + ); + expect(parameters[n - 1]).toBe(500); + const threshold = (parameters[n - 2] as Date).getTime(); + expect(threshold).toBeGreaterThanOrEqual(before + 60_000); + expect(threshold).toBeLessThanOrEqual(Date.now() + 60_000); + }); +}); + +describe('IntegrationConnectionRepo token writes', () => { + it('saves refreshed tokens only while the row still holds the refresh token that was sent', async () => { + const { repo, queries } = buildRepo(); + const tokenExpiresAt = new Date(); + + await repo.updateIfTokensMatch( + 'connection-1', + { refreshToken: 'sent-refresh-token' }, + { + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + tokenExpiresAt, + invalidatedAt: null, + }, + ); + + expect(queries).toHaveLength(1); + expect(queries[0].sql).toBe( + 'update "integration_connections" set "access_token" = $1, "refresh_token" = $2, "token_expires_at" = $3, "invalidated_at" = $4, "updated_at" = $5 where "id" = $6 and "refresh_token" = $7', + ); + expect(queries[0].parameters).toEqual([ + 'new-access-token', + 'new-refresh-token', + tokenExpiresAt, + null, + expect.any(Date), + 'connection-1', + 'sent-refresh-token', + ]); + }); + + it('retires a connection only while it still holds the expected access token', async () => { + const { repo, queries } = buildRepo(); + + await repo.invalidate('connection-1', { accessToken: 'used-access-token' }); + + expect(queries[0].sql).toBe( + 'update "integration_connections" set "invalidated_at" = $1, "refresh_token" = $2, "token_expires_at" = $3, "updated_at" = $4 where "id" = $5 and "access_token" = $6', + ); + expect(queries[0].parameters.slice(1)).toEqual([ + null, + null, + expect.any(Date), + 'connection-1', + 'used-access-token', + ]); + }); + + it('retires a connection unconditionally when no token is expected', async () => { + const { repo, queries } = buildRepo(); + + await repo.invalidate('connection-1'); + + expect(queries[0].sql).toBe( + 'update "integration_connections" set "invalidated_at" = $1, "refresh_token" = $2, "token_expires_at" = $3, "updated_at" = $4 where "id" = $5', + ); + }); +}); + +describe('IntegrationConnectionRepo metadata on reconnect', () => { + const MERGE = + `"metadata" = (coalesce("integration_connections"."metadata", '{}'::jsonb) - 'account') || coalesce("excluded"."metadata", '{}'::jsonb)`; + + function conflictParam(sql: string, parameters: readonly unknown[], column: string) { + const setClause = sql.slice(sql.indexOf('do update set')); + const index = Number(setClause.match(new RegExp(`"${column}" = \\$(\\d+)`))![1]); + return parameters[index - 1]; + } + + it('swaps the account and keeps other keys when a token connection reconnects', async () => { + const { repo, queries } = buildRepo(); + + await repo + .upsert({ + integrationId: 'integration-1', + userId: 'user-1', + workspaceId: 'workspace-1', + accessToken: 'token', + refreshToken: null, + tokenExpiresAt: null, + scopes: null, + providerUserId: '583231', + metadata: { account: { id: '583231' } }, + }) + .catch(() => undefined); + + expect(queries[0].sql).toContain(MERGE); + expect( + conflictParam(queries[0].sql, queries[0].parameters, 'provider_user_id'), + ).toBe('583231'); + }); + + it('clears the stored account id when a reconnect could not read the account', async () => { + const { repo, queries } = buildRepo(); + + await repo + .upsert({ + integrationId: 'integration-1', + userId: 'user-1', + workspaceId: 'workspace-1', + accessToken: 'token', + refreshToken: null, + tokenExpiresAt: null, + scopes: null, + providerUserId: null, + metadata: null, + }) + .catch(() => undefined); + + expect(queries[0].sql).toContain(MERGE); + expect( + conflictParam(queries[0].sql, queries[0].parameters, 'provider_user_id'), + ).toBeNull(); + }); + + it('merges an identity link the same way, so a relink keeps notifyEnabled', async () => { + const { repo, queries } = buildRepo(); + + await repo + .upsertUserLink({ + integrationId: 'integration-1', + workspaceId: 'workspace-1', + userId: 'user-1', + providerUserId: 'U-1', + metadata: { slackTeamId: 'T-1', account: { id: 'U-1' } }, + }) + .catch(() => undefined); + + expect(queries[0].sql).toContain(MERGE); + }); +}); 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 00000000..5b326a56 --- /dev/null +++ b/apps/server/src/core/integration/repos/integration-connection.repo.ts @@ -0,0 +1,371 @@ +import { Injectable } from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; +import { sql } from 'kysely'; +import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; +import { Json } from '@docmost/db/types/db'; +import { + IntegrationConnection, + InsertableIntegrationConnection, + UpdatableIntegrationConnection, +} from '@docmost/db/types/entity.types'; +import { dbOrTx } from '@docmost/db/utils'; + +type ExpectedTokens = Partial< + Pick +>; + +@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) + .where('kind', '=', 'user') + .executeTakeFirst(); + } + + async upsert( + connection: InsertableIntegrationConnection, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + // The unique index is partial on kind='user', so ON CONFLICT must repeat the predicate. + 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 ?? null, + metadata: this.mergedMetadata(), + 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, + ); + } + + 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); + // The shared workspace row carries the installer's userId; a personal disconnect must not delete it. + const result = await db + .deleteFrom('integrationConnections') + .where('integrationId', '=', integrationId) + .where('userId', '=', userId) + .where('kind', '=', 'user') + .executeTakeFirst(); + return Number(result.numDeletedRows) > 0; + } + + 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) + // Skip the workspace bot row, which carries the installer's userId. + .where('integrationConnections.kind', '=', 'user') + .where('integrations.workspaceId', '=', workspaceId) + .where('integrations.deletedAt', 'is', null) + .execute(); + } + + async findExpiringTokens( + expiresBeforeMs: number, + limit: number, + ): Promise { + const threshold = new Date(Date.now() + expiresBeforeMs); + return this.db + .selectFrom('integrationConnections') + .innerJoin( + 'integrations', + 'integrations.id', + 'integrationConnections.integrationId', + ) + .innerJoin('users', 'users.id', 'integrationConnections.userId') + .selectAll('integrationConnections') + .where('integrations.deletedAt', 'is', null) + // The workspace bot row carries its installer's userId and must outlive them. + .where((eb) => + eb.or([ + eb('integrationConnections.kind', '=', 'workspace'), + eb.and([ + eb('users.deactivatedAt', 'is', null), + eb('users.deletedAt', 'is', null), + ]), + ]), + ) + .where('integrationConnections.invalidatedAt', 'is', null) + .where('integrationConnections.refreshToken', 'is not', null) + .where('integrationConnections.tokenExpiresAt', 'is not', null) + .where('integrationConnections.tokenExpiresAt', '<', threshold) + .orderBy('integrationConnections.tokenExpiresAt', 'asc') + .limit(limit) + .execute(); + } + + async updateIfTokensMatch( + connectionId: string, + expected: ExpectedTokens, + data: UpdatableIntegrationConnection, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + let query = db + .updateTable('integrationConnections') + .set({ ...data, updatedAt: new Date() }) + .where('id', '=', connectionId); + if (expected.accessToken !== undefined) { + query = query.where('accessToken', '=', expected.accessToken); + } + if (expected.refreshToken !== undefined) { + query = query.where('refreshToken', '=', expected.refreshToken); + } + const result = await query.executeTakeFirst(); + return Number(result.numUpdatedRows) > 0; + } + + async invalidate( + connectionId: string, + expected: ExpectedTokens = {}, + trx?: KyselyTransaction, + ): Promise { + await this.updateIfTokensMatch( + connectionId, + expected, + { invalidatedAt: new Date(), refreshToken: null, tokenExpiresAt: null }, + trx, + ); + } + + async deleteByIntegration( + integrationId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + await db + .deleteFrom('integrationConnections') + .where('integrationId', '=', integrationId) + .execute(); + } + + // Leaves the workspace bot row a departing user installed. + async deleteUserConnections( + userId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + await db + .deleteFrom('integrationConnections') + .where('userId', '=', userId) + .where('kind', '=', 'user') + .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); + // The unique index is partial on kind='user', so ON CONFLICT must repeat the predicate. + 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: this.mergedMetadata(), + updatedAt: new Date(), + }), + ) + .returningAll() + .executeTakeFirstOrThrow(); + } + + // Swaps the account and keeps keys this write leaves out, such as Slack's notifyEnabled. + private mergedMetadata() { + return sql`(coalesce(${sql.ref('integrationConnections.metadata')}, '{}'::jsonb) - 'account') || coalesce(${sql.ref('excluded.metadata')}, '{}'::jsonb)`; + } +} 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 00000000..b1ba687d --- /dev/null +++ b/apps/server/src/core/integration/repos/integration.repo.ts @@ -0,0 +1,119 @@ +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 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, + // Stale settings could re-claim a tenant (slackTeamId) another workspace now holds. + settings: null, + 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, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + return 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.spec.ts b/apps/server/src/core/integration/unfurl/unfurl.controller.spec.ts new file mode 100644 index 00000000..8d7f3d4e --- /dev/null +++ b/apps/server/src/core/integration/unfurl/unfurl.controller.spec.ts @@ -0,0 +1,59 @@ +import { ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ThrottlerException, ThrottlerStorageService } from '@nestjs/throttler'; +import { UnfurlController } from './unfurl.controller'; +import { UserThrottlerGuard } from '../../../integrations/throttle/user-throttler.guard'; +import { + AUTH_THROTTLER, + UNFURL_THROTTLER, +} from '../../../integrations/throttle/throttler-names'; + +describe('UnfurlController throttling', () => { + let storage: ThrottlerStorageService; + let guard: UserThrottlerGuard; + + beforeEach(async () => { + storage = new ThrottlerStorageService(); + guard = new UserThrottlerGuard( + { + throttlers: [ + { name: AUTH_THROTTLER, ttl: 60_000, limit: 10 }, + { name: UNFURL_THROTTLER, ttl: 60_000, limit: 120 }, + ], + }, + storage, + new Reflector(), + ); + await guard.onModuleInit(); + }); + + afterEach(() => storage.onApplicationShutdown()); + + function contextFor(userId: string): ExecutionContext { + const req = { ip: '10.0.0.1', headers: {}, user: { user: { id: userId } } }; + const res = { header: jest.fn() }; + return { + getHandler: () => UnfurlController.prototype.unfurl, + getClass: () => UnfurlController, + switchToHttp: () => ({ getRequest: () => req, getResponse: () => res }), + } as unknown as ExecutionContext; + } + + async function hit(userId: string, times: number) { + for (let i = 0; i < times; i++) { + await guard.canActivate(contextFor(userId)); + } + } + + it('allows 120 requests per user per minute and rejects the next one', async () => { + await hit('user-a', 120); + await expect(guard.canActivate(contextFor('user-a'))).rejects.toThrow( + ThrottlerException, + ); + }); + + it('tracks users separately when they share an IP', async () => { + await hit('user-a', 120); + await expect(guard.canActivate(contextFor('user-b'))).resolves.toBe(true); + }); +}); 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 00000000..7d458988 --- /dev/null +++ b/apps/server/src/core/integration/unfurl/unfurl.controller.ts @@ -0,0 +1,43 @@ +import { + Body, + Controller, + HttpCode, + HttpStatus, + Post, + UseGuards, +} from '@nestjs/common'; +import { SkipThrottle, Throttle } from '@nestjs/throttler'; +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'; +import { UserThrottlerGuard } from '../../../integrations/throttle/user-throttler.guard'; +import { + ALL_NAMED_THROTTLERS_SKIPPED, + UNFURL_THROTTLER, +} from '../../../integrations/throttle/throttler-names'; + +@Controller('integrations') +export class UnfurlController { + constructor(private readonly unfurlService: UnfurlService) {} + + @UseGuards(JwtAuthGuard, UserThrottlerGuard) + @SkipThrottle({ ...ALL_NAMED_THROTTLERS_SKIPPED, [UNFURL_THROTTLER]: false }) + @Throttle({ [UNFURL_THROTTLER]: { limit: 120, ttl: 60_000 } }) + @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.spec.ts b/apps/server/src/core/integration/unfurl/unfurl.service.spec.ts new file mode 100644 index 00000000..5621389d --- /dev/null +++ b/apps/server/src/core/integration/unfurl/unfurl.service.spec.ts @@ -0,0 +1,324 @@ +import * as crypto from 'crypto'; +import { UnfurlService } from './unfurl.service'; +import { IntegrationRegistry } from '../registry/integration-registry'; +import { GitHubProvider } from '../providers/github/github.provider'; +import { + IntegrationDefinition, + IntegrationProvider, + TokenExpiredError, + TokenInvalidError, +} from '../registry/integration-provider.interface'; + +const GHE_BASE = 'https://github.acme.com'; +const CONNECTED_AT = new Date('2026-10-01T09:00:00Z'); + +class WorkspaceScopedProvider extends IntegrationProvider { + definition: IntegrationDefinition = { + type: 'chat', + name: 'Chat', + description: '', + icon: '', + capabilities: ['unfurl'], + oauth: { authUrl: '', tokenUrl: '', scopes: [], connectionScope: 'workspace' }, + unfurlPatterns: [{ regex: /^https:\/\/chat\.example\/m\/(\w+)$/, type: 'message' }], + }; + unfurl = jest.fn().mockResolvedValue({ title: 'Message' }); +} + +function build() { + const unfurlPullRequest = jest.fn().mockResolvedValue({ title: 'PR' }); + const registry = new IntegrationRegistry(); + registry.register(new GitHubProvider({ unfurlPullRequest } as any)); + + const integration = { id: 'i1', type: 'github', settings: {} }; + const integrationRepo = { + findByWorkspaceAndType: jest.fn().mockResolvedValue(integration), + findAllByWorkspace: jest.fn().mockResolvedValue([integration]), + }; + const connectionRepo = { + findByIntegrationAndUser: jest + .fn() + .mockResolvedValue({ id: 'c1', invalidatedAt: null, updatedAt: CONNECTED_AT }), + findWorkspaceConnection: jest.fn().mockResolvedValue(undefined), + invalidate: jest.fn().mockResolvedValue(undefined), + }; + const oauthService = { + getValidAccessToken: jest.fn().mockResolvedValue('token'), + }; + const redis = { + get: jest.fn().mockResolvedValue(null), + set: jest.fn().mockResolvedValue('OK'), + }; + + const service = new UnfurlService( + registry, + integrationRepo as any, + connectionRepo as any, + oauthService as any, + { getOrThrow: () => redis } as any, + ); + return { + service, + registry, + unfurlPullRequest, + integrationRepo, + connectionRepo, + oauthService, + redis, + }; +} + +describe('UnfurlService provider resolution', () => { + const original = process.env.INTEGRATION_GITHUB_URL; + + afterEach(() => { + if (original === undefined) delete process.env.INTEGRATION_GITHUB_URL; + else process.env.INTEGRATION_GITHUB_URL = original; + }); + + it('returns null for github.com links when GitHub Enterprise is configured', async () => { + process.env.INTEGRATION_GITHUB_URL = GHE_BASE; + const { service, unfurlPullRequest, connectionRepo } = build(); + + await expect( + service.unfurl('https://github.com/o/r/pull/1', 'u1', 'w1'), + ).resolves.toBeNull(); + expect(connectionRepo.findByIntegrationAndUser).not.toHaveBeenCalled(); + expect(connectionRepo.invalidate).not.toHaveBeenCalled(); + expect(unfurlPullRequest).not.toHaveBeenCalled(); + }); + + it.each([ + [undefined, 'https://github.com/o/r/pull/1', 'https://api.github.com'], + [GHE_BASE, `${GHE_BASE}/o/r/pull/1`, `${GHE_BASE}/api/v3`], + ])( + 'resolves links on the configured host (%s) through the installed integration', + async (configured, url, apiBaseUrl) => { + if (configured === undefined) delete process.env.INTEGRATION_GITHUB_URL; + else process.env.INTEGRATION_GITHUB_URL = configured; + const { service, unfurlPullRequest } = build(); + + await expect(service.unfurl(url, 'u1', 'w1')).resolves.toEqual({ + title: 'PR', + }); + expect(unfurlPullRequest).toHaveBeenCalledWith( + 'token', apiBaseUrl, 'o', 'r', 1, url, + ); + }, + ); +}); + +describe('UnfurlService credential failures', () => { + const original = process.env.INTEGRATION_GITHUB_URL; + const url = 'https://github.com/o/r/pull/1'; + + beforeEach(() => { + delete process.env.INTEGRATION_GITHUB_URL; + }); + + afterEach(() => { + if (original === undefined) delete process.env.INTEGRATION_GITHUB_URL; + else process.env.INTEGRATION_GITHUB_URL = original; + }); + + it('shows no card for a token awaiting refresh, without caching or retiring', async () => { + const { service, unfurlPullRequest, connectionRepo, oauthService, redis } = + build(); + oauthService.getValidAccessToken.mockRejectedValue(new TokenExpiredError()); + + await expect(service.unfurl(url, 'u1', 'w1')).resolves.toBeNull(); + + expect(unfurlPullRequest).not.toHaveBeenCalled(); + expect(redis.set).not.toHaveBeenCalled(); + expect(connectionRepo.invalidate).not.toHaveBeenCalled(); + }); + + describe('when the provider rejects the token', () => { + function buildWithStoredRow() { + const built = build(); + const row: Record = { + id: 'c1', + invalidatedAt: null, + updatedAt: CONNECTED_AT, + accessToken: 'used-access-token', + }; + built.connectionRepo.findByIntegrationAndUser.mockImplementation( + async () => ({ ...row }), + ); + built.connectionRepo.invalidate.mockImplementation( + async (_id: string, expected: Record = {}) => { + const matches = Object.entries(expected).every( + ([column, value]) => row[column] === value, + ); + if (matches) row.invalidatedAt = new Date(); + }, + ); + return { ...built, row }; + } + + it('retires a connection that still holds the rejected token', async () => { + const { service, unfurlPullRequest, connectionRepo, row } = + buildWithStoredRow(); + unfurlPullRequest.mockRejectedValue(new TokenInvalidError()); + + await expect(service.unfurl(url, 'u1', 'w1')).resolves.toMatchObject({ + needsConnection: true, + }); + + expect(connectionRepo.invalidate).toHaveBeenCalledWith('c1', { + accessToken: 'used-access-token', + }); + expect(row.invalidatedAt).toBeInstanceOf(Date); + }); + + it('keeps a connection whose token the refresh job replaced after it was read', async () => { + const { service, unfurlPullRequest, row } = buildWithStoredRow(); + unfurlPullRequest.mockImplementation(async () => { + row.accessToken = 'refreshed-access-token'; + throw new TokenInvalidError(); + }); + + await service.unfurl(url, 'u1', 'w1'); + + expect(unfurlPullRequest).toHaveBeenCalledTimes(1); + expect(row.invalidatedAt).toBeNull(); + }); + }); +}); + +describe('UnfurlService cache', () => { + const original = process.env.INTEGRATION_GITHUB_URL; + const url = 'https://github.com/o/r/pull/1'; + const urlDigest = crypto.createHash('sha256').update(url).digest('hex'); + const cachedCard = JSON.stringify({ title: 'Cached PR' }); + + beforeEach(() => { + delete process.env.INTEGRATION_GITHUB_URL; + }); + + afterEach(() => { + if (original === undefined) delete process.env.INTEGRATION_GITHUB_URL; + else process.env.INTEGRATION_GITHUB_URL = original; + }); + + it('serves a cached card only after finding the connection', async () => { + const { service, unfurlPullRequest, connectionRepo, redis } = build(); + redis.get.mockResolvedValue(cachedCard); + + await expect(service.unfurl(url, 'u1', 'w1')).resolves.toEqual({ + title: 'Cached PR', + }); + + expect(unfurlPullRequest).not.toHaveBeenCalled(); + expect( + connectionRepo.findByIntegrationAndUser.mock.invocationCallOrder[0], + ).toBeLessThan(redis.get.mock.invocationCallOrder[0]); + }); + + it('asks a disconnected member to connect instead of serving their cached card', async () => { + const { service, connectionRepo, redis } = build(); + redis.get.mockResolvedValue(cachedCard); + connectionRepo.findByIntegrationAndUser.mockResolvedValue(undefined); + + await expect(service.unfurl(url, 'u1', 'w1')).resolves.toMatchObject({ + needsConnection: true, + }); + + expect(redis.get).not.toHaveBeenCalled(); + expect(redis.set).not.toHaveBeenCalled(); + }); + + it('serves no cached card once the integration is uninstalled', async () => { + const { service, integrationRepo, connectionRepo, redis } = build(); + redis.get.mockResolvedValue(cachedCard); + integrationRepo.findByWorkspaceAndType.mockResolvedValue(undefined); + integrationRepo.findAllByWorkspace.mockResolvedValue([]); + + await expect(service.unfurl(url, 'u1', 'w1')).resolves.toBeNull(); + + expect(connectionRepo.findByIntegrationAndUser).not.toHaveBeenCalled(); + expect(redis.get).not.toHaveBeenCalled(); + }); + + it('keys the card by connection, its last update and the full URL digest', async () => { + const { service, redis } = build(); + + await service.unfurl(url, 'u1', 'w1'); + + expect(urlDigest).toHaveLength(64); + expect(redis.get).toHaveBeenCalledWith( + `unfurl:w1:u1:c1:${CONNECTED_AT.getTime()}:${urlDigest}`, + ); + expect(redis.set).toHaveBeenCalledWith( + `unfurl:w1:u1:c1:${CONNECTED_AT.getTime()}:${urlDigest}`, + JSON.stringify({ title: 'PR' }), + 'EX', + 300, + ); + }); + + it('refetches once the connection is updated by a reconnect or refresh', async () => { + const { service, unfurlPullRequest, connectionRepo, redis } = build(); + const store = new Map(); + redis.get.mockImplementation(async (key: string) => store.get(key) ?? null); + redis.set.mockImplementation(async (key: string, value: string) => { + store.set(key, value); + return 'OK'; + }); + + await service.unfurl(url, 'u1', 'w1'); + await service.unfurl(url, 'u1', 'w1'); + expect(unfurlPullRequest).toHaveBeenCalledTimes(1); + + connectionRepo.findByIntegrationAndUser.mockResolvedValue({ + id: 'c1', + invalidatedAt: null, + updatedAt: new Date(CONNECTED_AT.getTime() + 1000), + }); + await service.unfurl(url, 'u1', 'w1'); + + expect(unfurlPullRequest).toHaveBeenCalledTimes(2); + }); + + describe('for a workspace-scoped provider', () => { + const messageUrl = 'https://chat.example/m/abc'; + + function buildWorkspaceScoped() { + const built = build(); + const provider = new WorkspaceScopedProvider(); + built.registry.register(provider); + const integration = { id: 'i2', type: 'chat', settings: {} }; + built.integrationRepo.findByWorkspaceAndType.mockResolvedValue(integration); + built.integrationRepo.findAllByWorkspace.mockResolvedValue([integration]); + return { ...built, provider }; + } + + it('serves no cached card once the workspace connection is gone', async () => { + const { service, provider, connectionRepo, redis } = + buildWorkspaceScoped(); + redis.get.mockResolvedValue(cachedCard); + + await expect(service.unfurl(messageUrl, 'u1', 'w1')).resolves.toBeNull(); + + expect(connectionRepo.findWorkspaceConnection).toHaveBeenCalledWith('i2'); + expect(redis.get).not.toHaveBeenCalled(); + expect(provider.unfurl).not.toHaveBeenCalled(); + }); + + it('keeps the viewer in the key of the shared connection', async () => { + const { service, connectionRepo, redis } = buildWorkspaceScoped(); + connectionRepo.findWorkspaceConnection.mockResolvedValue({ + id: 'wc1', + invalidatedAt: null, + updatedAt: CONNECTED_AT, + }); + + await service.unfurl(messageUrl, 'u1', 'w1'); + + const digest = crypto.createHash('sha256').update(messageUrl).digest('hex'); + expect(redis.get).toHaveBeenCalledWith( + `unfurl:w1:u1:wc1:${CONNECTED_AT.getTime()}:${digest}`, + ); + }); + }); +}); 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 00000000..98071c35 --- /dev/null +++ b/apps/server/src/core/integration/unfurl/unfurl.service.ts @@ -0,0 +1,251 @@ +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, + TokenExpiredError, + TokenInvalidError, + ProviderApiError, + IntegrationProvider, +} from '../registry/integration-provider.interface'; +import { IntegrationConnection } from '@docmost/db/types/entity.types'; +import { RedisService } from '@nestjs-labs/nestjs-ioredis'; +import type { Redis } from 'ioredis'; +import * as crypto from 'crypto'; + +const UNFURL_CACHE_TTL = 300; // 5 minutes +// Short negative cache for transient failures; a 404 uses the normal TTL. +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 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) unfurl through one shared bot connection. + 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, so the card loads once the user connects. + return this.buildNeedsConnection( + provider, + integration.id, + patternType, + match, + url, + ); + } + + const cacheKey = this.buildCacheKey(workspaceId, userId, connection, url); + const cached = await this.redis.get(cacheKey); + if (cached) { + return JSON.parse(cached); + } + + 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) { + // Not cached, so the card loads once the user links their account. + if (err instanceof UnfurlNeedsConnectionError) { + return this.buildNeedsConnection( + provider, + integration.id, + patternType, + match, + url, + ); + } + 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; + } + // Not cached, so the card loads once the refresh job renews the token. + if (err instanceof TokenExpiredError) { + return null; + } + if (err instanceof TokenInvalidError) { + this.logger.warn( + `Retiring connection ${connection.id}: ${(err as Error).message}`, + ); + // Spares a connection whose token the refresh job replaced after it was read. + await this.connectionRepo + .invalidate(connection.id, { accessToken: connection.accessToken }) + .catch(() => undefined); + if (connectionScope === 'workspace') { + return null; + } + // Not cached, so the card loads once 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); + } + + 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, + 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; + } + + // updatedAt moves on reconnect and token refresh, so another account's cards never match. + private buildCacheKey( + workspaceId: string, + userId: string, + connection: Pick, + url: string, + ): string { + const hash = crypto.createHash('sha256').update(url).digest('hex'); + return `${UNFURL_CACHE_PREFIX}${workspaceId}:${userId}:${connection.id}:${connection.updatedAt.getTime()}:${hash}`; + } +} diff --git a/apps/server/src/core/integration/utils/canonical-path.ts b/apps/server/src/core/integration/utils/canonical-path.ts new file mode 100644 index 00000000..9329362d --- /dev/null +++ b/apps/server/src/core/integration/utils/canonical-path.ts @@ -0,0 +1,2 @@ +// Browsers resolve "." and ".." segments (also written %2e) and read "\" as "/", so such a link opens a different item. +export const CANONICAL_PATH = String.raw`(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))`; diff --git a/apps/server/src/core/integration/utils/integration.utils.spec.ts b/apps/server/src/core/integration/utils/integration.utils.spec.ts new file mode 100644 index 00000000..f048d759 --- /dev/null +++ b/apps/server/src/core/integration/utils/integration.utils.spec.ts @@ -0,0 +1,32 @@ +import { toProviderAccount } from './integration.utils'; + +describe('toProviderAccount', () => { + it('stringifies a numeric id and keeps the names', () => { + expect( + toProviderAccount({ + id: 583231, + displayName: 'Philip Okugbe', + username: 'Philipinho', + }), + ).toEqual({ + id: '583231', + displayName: 'Philip Okugbe', + username: 'Philipinho', + }); + }); + + it('leaves out names the provider did not return or left blank', () => { + expect( + toProviderAccount({ id: 'U-1', displayName: ' ', username: null }), + ).toEqual({ id: 'U-1' }); + }); + + it.each([undefined, null, '', {}])( + 'refuses a profile without an id (%p)', + (id) => { + expect(() => toProviderAccount({ id })).toThrow( + 'Provider profile has no account id', + ); + }, + ); +}); diff --git a/apps/server/src/core/integration/utils/integration.utils.ts b/apps/server/src/core/integration/utils/integration.utils.ts new file mode 100644 index 00000000..733de9a7 --- /dev/null +++ b/apps/server/src/core/integration/utils/integration.utils.ts @@ -0,0 +1,40 @@ +import { ProviderAccount } from '../registry/integration-provider.interface'; + +export function normalizeBaseUrl(value: string): string | null { + try { + const parsed = new URL(value); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null; + return `${parsed.origin}${parsed.pathname.replace(/\/+$/, '')}`; + } catch { + return null; + } +} + +export function escapeForRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export function stringOrUndefined(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +export function toProviderAccount(fields: { + id: unknown; + displayName?: unknown; + username?: unknown; +}): ProviderAccount { + const id = + typeof fields.id === 'number' + ? String(fields.id) + : stringOrUndefined(fields.id); + if (!id) { + throw new Error('Provider profile has no account id'); + } + const displayName = stringOrUndefined(fields.displayName)?.trim(); + const username = stringOrUndefined(fields.username)?.trim(); + return { + id, + ...(displayName ? { displayName } : {}), + ...(username ? { username } : {}), + }; +} diff --git a/apps/server/src/core/integration/utils/provider-fetch.spec.ts b/apps/server/src/core/integration/utils/provider-fetch.spec.ts new file mode 100644 index 00000000..4f4db920 --- /dev/null +++ b/apps/server/src/core/integration/utils/provider-fetch.spec.ts @@ -0,0 +1,181 @@ +import * as http from 'http'; +import { AddressInfo } from 'net'; +import { + ProviderApiError, + TokenInvalidError, +} from '../registry/integration-provider.interface'; +import { MAX_PROVIDER_RESPONSE_BYTES, providerApiFetch } from './provider-fetch'; + +type TestServer = { server: http.Server; url: string; hits: number }; + +// Never ends the body, so the request settles only if the client stops reading. +function streamUntilClosed(res: http.ServerResponse, status = 200): void { + res.writeHead(status, { 'Content-Type': 'application/json' }); + const chunk = Buffer.from(`{"secret":"${'s'.repeat(64 * 1024)}"}`); + const pump = () => { + while (!res.destroyed && res.write(chunk)) {} + if (!res.destroyed) res.once('drain', pump); + }; + pump(); +} + +async function startServer( + respond: (res: http.ServerResponse) => void, +): Promise { + const testServer = { hits: 0 } as TestServer; + testServer.server = http.createServer((req, res) => { + testServer.hits += 1; + req.resume(); + req.on('end', () => respond(res)); + }); + await new Promise((resolve) => + testServer.server.listen(0, '127.0.0.1', resolve), + ); + const { port } = testServer.server.address() as AddressInfo; + testServer.url = `http://127.0.0.1:${port}`; + return testServer; +} + +function stopServer({ server }: TestServer): Promise { + server.closeAllConnections(); + return new Promise((resolve) => server.close(() => resolve())); +} + +describe('providerApiFetch', () => { + let sink: TestServer; + let provider: TestServer; + + beforeAll(async () => { + sink = await startServer((res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{}'); + }); + provider = await startServer((res) => { + res.writeHead(307, { Location: `${sink.url}/internal` }); + res.end(); + }); + }); + + afterAll(async () => { + await Promise.all([stopServer(provider), stopServer(sink)]); + }); + + it('reports a redirecting provider endpoint as an unexpected redirect instead of following it', async () => { + const outcome = await providerApiFetch('acme', `${provider.url}/api/item`, { + headers: { Authorization: 'Bearer token' }, + }).catch((err) => err); + + expect(sink.hits).toBe(0); + expect(outcome).toBeInstanceOf(ProviderApiError); + expect(outcome.status).toBe(502); + expect(outcome.message).toBe('acme API error: 502 unexpected redirect'); + }); + + it('keeps the reason a 401 gives in its body and headers', async () => { + const refusing = await startServer((res) => { + res.writeHead(401, { + 'Content-Type': 'application/json', + 'WWW-Authenticate': + 'Bearer authorization_uri=https://login.microsoftonline.com/tenant-1', + 'X-TFS-ServiceError': 'TF400813%3a+The+user+is+not+authorized', + }); + res.end('{"message":"Bad credentials"}'); + }); + + try { + const outcome = await providerApiFetch( + 'acme', + `${refusing.url}/api/item`, + ).catch((err) => err); + + expect(outcome).toBeInstanceOf(TokenInvalidError); + expect(outcome.message).toBe( + 'acme API error: 401 Unauthorized {"message":"Bad credentials"} Bearer authorization_uri=https://login.microsoftonline.com/tenant-1 TF400813%3a+The+user+is+not+authorized', + ); + } finally { + await stopServer(refusing); + } + }); + + it('reports a bare 401 with the status alone', async () => { + const refusing = await startServer((res) => { + res.writeHead(401); + res.end(); + }); + + try { + const outcome = await providerApiFetch( + 'acme', + `${refusing.url}/api/item`, + ).catch((err) => err); + + expect(outcome).toBeInstanceOf(TokenInvalidError); + expect(outcome.message).toBe('acme API error: 401 Unauthorized'); + } finally { + await stopServer(refusing); + } + }); + + describe('response size cap', () => { + async function fetchFrom(respond: (res: http.ServerResponse) => void) { + const testServer = await startServer(respond); + try { + return await providerApiFetch('acme', `${testServer.url}/api/item`).catch( + (err) => err, + ); + } finally { + await stopServer(testServer); + } + } + + it('hands back the status, headers and body of a response within the cap', async () => { + const body = `"${'a'.repeat(MAX_PROVIDER_RESPONSE_BYTES - 2)}"`; + + const response = await fetchFrom((res) => { + res.writeHead(200, { + 'Content-Type': 'application/json; charset=utf-8', + 'X-Request-Id': 'req-1', + }); + res.end(body); + }); + + expect(response).toBeInstanceOf(Response); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe( + 'application/json; charset=utf-8', + ); + expect(response.headers.get('x-request-id')).toBe('req-1'); + expect(await response.json()).toHaveLength(MAX_PROVIDER_RESPONSE_BYTES - 2); + }); + + it('refuses a declared length over the cap without waiting for the body', async () => { + const outcome = await fetchFrom((res) => { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Content-Length': String(MAX_PROVIDER_RESPONSE_BYTES + 1), + }); + res.write('{"secret":"'); + }); + + expect(outcome).toBeInstanceOf(ProviderApiError); + expect(outcome.status).toBe(502); + expect(outcome.message).toBe('acme API error: 502 response too large'); + }, 2000); + + it('stops reading a body that never ends once it passes the cap', async () => { + const outcome = await fetchFrom((res) => streamUntilClosed(res)); + + expect(outcome).toBeInstanceOf(ProviderApiError); + expect(outcome.status).toBe(502); + expect(outcome.message).toBe('acme API error: 502 response too large'); + }, 5000); + + it('keeps the status of an error whose body is too large to quote', async () => { + const outcome = await fetchFrom((res) => streamUntilClosed(res, 500)); + + expect(outcome).toBeInstanceOf(ProviderApiError); + expect(outcome.status).toBe(500); + expect(outcome.message).toBe('acme API error: 500 Internal Server Error'); + }, 5000); + }); +}); 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 00000000..de1be413 --- /dev/null +++ b/apps/server/src/core/integration/utils/provider-fetch.ts @@ -0,0 +1,118 @@ +import { + ProviderApiError, + TokenInvalidError, + UnfurlForbiddenError, +} from '../registry/integration-provider.interface'; +import { proxyFetch } from '../../../common/proxy-fetch'; + +export const INTEGRATION_HTTP_TIMEOUT_MS = 10_000; + +// Far above any unfurl or token response we read. +export const MAX_PROVIDER_RESPONSE_BYTES = 2 * 1024 * 1024; + +// Enough of the body to log why a provider refused ("insufficient scope", ...). +const MAX_ERROR_BODY_CHARS = 300; + +// Buffers the body into a new Response, giving up as soon as it passes the cap. +export async function readCappedResponse( + providerName: string, + response: Response, +): Promise { + const tooLarge = () => + new ProviderApiError(providerName, 502, 'response too large'); + + if ( + Number(response.headers.get('content-length')) > MAX_PROVIDER_RESPONSE_BYTES + ) { + void response.body?.cancel().catch(() => undefined); + throw tooLarge(); + } + + const chunks: Uint8Array[] = []; + let received = 0; + if (response.body) { + const reader = response.body.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + received += value.byteLength; + if (received > MAX_PROVIDER_RESPONSE_BYTES) { + void reader.cancel().catch(() => undefined); + throw tooLarge(); + } + chunks.push(value); + } + } + + return new Response(received > 0 ? Buffer.concat(chunks) : null, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); +} + +async function readErrorBody( + providerName: string, + response: Response, +): Promise { + try { + const capped = await readCappedResponse(providerName, response); + const text = await capped.text(); + return text.replace(/\s+/g, ' ').trim().slice(0, MAX_ERROR_BODY_CHARS); + } catch { + return ''; + } +} + +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.status >= 300 && response.status < 400) { + throw new ProviderApiError(providerName, 502, 'unexpected redirect'); + } + + if (response.status === 401) { + // Azure DevOps puts its TF error code only in x-tfs-serviceerror. + const reasonHeaders = ['www-authenticate', 'x-tfs-serviceerror'] + .map((name) => response.headers.get(name)?.slice(0, MAX_ERROR_BODY_CHARS)) + .filter(Boolean); + throw new TokenInvalidError( + [ + `${providerName} API error: 401 Unauthorized`, + await readErrorBody(providerName, response), + ...reasonHeaders, + ] + .filter(Boolean) + .join(' '), + ); + } + if (!response.ok) { + const body = await readErrorBody(providerName, response); + + // GitHub also uses 403 for secondary rate limits, which stay real errors. + 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 readCappedResponse(providerName, response); +} diff --git a/apps/server/src/core/integration/utils/refresh-lock.spec.ts b/apps/server/src/core/integration/utils/refresh-lock.spec.ts new file mode 100644 index 00000000..5cce06da --- /dev/null +++ b/apps/server/src/core/integration/utils/refresh-lock.spec.ts @@ -0,0 +1,129 @@ +import { Logger } from '@nestjs/common'; +import { RefreshLock } from './refresh-lock'; + +const KEY = 'integration:refresh:connection-1'; + +// Emulates SET NX, the compare-and-delete release script, DEL and EXISTS over a Map. +function fakeRedis() { + const keys = new Map(); + return { + keys, + set: jest.fn(async (key: string, value: string) => { + if (keys.has(key)) return null; + keys.set(key, value); + return 'OK'; + }), + eval: jest.fn(async (_script: string, _numKeys: number, key: string, token: string) => { + if (keys.get(key) !== token) return 0; + keys.delete(key); + return 1; + }), + del: jest.fn(async (key: string) => Number(keys.delete(key))), + exists: jest.fn(async (key: string) => (keys.has(key) ? 1 : 0)), + }; +} + +describe('RefreshLock', () => { + let redis: ReturnType; + let lock: RefreshLock; + + beforeAll(() => { + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + beforeEach(() => { + redis = fakeRedis(); + lock = new RefreshLock(redis as any); + }); + + it('takes the lock for 20 seconds only if nobody holds it', async () => { + const token = await lock.acquire('connection-1'); + + expect(token).toEqual(expect.any(String)); + expect(redis.set).toHaveBeenCalledWith(KEY, token, 'PX', 20_000, 'NX'); + expect(redis.keys.get(KEY)).toBe(token); + }); + + it('gives each holder its own token', async () => { + const first = await lock.acquire('connection-1'); + await lock.release('connection-1', first); + + const second = await lock.acquire('connection-1'); + + expect(second).not.toBe(first); + }); + + it('refuses the lock while someone else holds it', async () => { + redis.keys.set(KEY, 'other-holder'); + + await expect(lock.acquire('connection-1')).resolves.toBeNull(); + + expect(redis.keys.get(KEY)).toBe('other-holder'); + }); + + it('counts a Redis error as not acquired', async () => { + redis.set.mockRejectedValue(new Error('connection lost')); + + await expect(lock.acquire('connection-1')).resolves.toBeNull(); + }); + + it('releases a lock it holds', async () => { + const token = await lock.acquire('connection-1'); + + await lock.release('connection-1', token); + + expect(redis.keys.has(KEY)).toBe(false); + }); + + it('never deletes a lock another holder took after its own expired', async () => { + const stale = await lock.acquire('connection-1'); + redis.keys.delete(KEY); + const current = await lock.acquire('connection-1'); + + await lock.release('connection-1', stale); + + expect(redis.keys.get(KEY)).toBe(current); + expect(redis.del).not.toHaveBeenCalled(); + }); + + it('leaves the lock to expire when the release fails', async () => { + redis.eval.mockRejectedValue(new Error('connection lost')); + + await expect(lock.release('connection-1', 'token')).resolves.toBeUndefined(); + }); + + it('waits until the holder releases the lock', async () => { + redis.keys.set(KEY, 'other-holder'); + redis.exists.mockImplementationOnce(async () => { + redis.keys.delete(KEY); + return 1; + }); + + await lock.waitForRelease('connection-1', { timeoutMs: 1000, pollMs: 10 }); + + expect(redis.exists).toHaveBeenCalledTimes(2); + }); + + it('stops waiting at the timeout while the lock is still held', async () => { + redis.keys.set(KEY, 'other-holder'); + const started = Date.now(); + + await lock.waitForRelease('connection-1', { timeoutMs: 50, pollMs: 10 }); + + expect(Date.now() - started).toBeGreaterThanOrEqual(50); + expect(redis.exists.mock.calls.length).toBeGreaterThan(1); + expect(redis.keys.get(KEY)).toBe('other-holder'); + }); + + it('stops waiting when Redis fails', async () => { + redis.exists.mockRejectedValue(new Error('connection lost')); + + await lock.waitForRelease('connection-1', { timeoutMs: 1000, pollMs: 10 }); + + expect(redis.exists).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/server/src/core/integration/utils/refresh-lock.ts b/apps/server/src/core/integration/utils/refresh-lock.ts new file mode 100644 index 00000000..4563333c --- /dev/null +++ b/apps/server/src/core/integration/utils/refresh-lock.ts @@ -0,0 +1,72 @@ +import { Logger } from '@nestjs/common'; +import type { Redis } from 'ioredis'; +import * as crypto from 'crypto'; + +const REFRESH_LOCK_PREFIX = 'integration:refresh:'; +// Twice the token endpoint timeout, so the lock outlives its holder's provider call. +const REFRESH_LOCK_TTL_MS = 20_000; + +const RELEASE_SCRIPT = ` + if redis.call("GET", KEYS[1]) == ARGV[1] then + return redis.call("DEL", KEYS[1]) + else + return 0 + end +`; + +// Rotating providers revoke a refresh token on use, so one connection must never refresh twice at once. +export class RefreshLock { + private readonly logger = new Logger(RefreshLock.name); + + constructor(private readonly redis: Redis) {} + + // A Redis error counts as not acquired, so nobody refreshes without the lock. + async acquire(connectionId: string): Promise { + const token = crypto.randomUUID(); + try { + const ok = await this.redis.set( + REFRESH_LOCK_PREFIX + connectionId, + token, + 'PX', + REFRESH_LOCK_TTL_MS, + 'NX', + ); + return ok === 'OK' ? token : null; + } catch (err) { + this.logger.warn( + `Refresh lock unavailable for connection ${connectionId}: ${(err as Error).message}`, + ); + return null; + } + } + + async release(connectionId: string, token: string): Promise { + try { + await this.redis.eval( + RELEASE_SCRIPT, + 1, + REFRESH_LOCK_PREFIX + connectionId, + token, + ); + } catch (err) { + this.logger.warn( + `Refresh lock release failed for connection ${connectionId}: ${(err as Error).message}`, + ); + } + } + + // A Redis error ends the wait early; callers re-read the row either way. + async waitForRelease( + connectionId: string, + opts: { timeoutMs: number; pollMs: number }, + ): Promise { + const deadline = Date.now() + opts.timeoutMs; + while (Date.now() < deadline) { + const held = await this.redis + .exists(REFRESH_LOCK_PREFIX + connectionId) + .catch(() => 0); + if (!held) return; + await new Promise((resolve) => setTimeout(resolve, opts.pollMs)); + } + } +} 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 00000000..250ce146 --- /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.processor.ts b/apps/server/src/core/notification/notification.processor.ts index c477c398..6e178bb4 100644 --- a/apps/server/src/core/notification/notification.processor.ts +++ b/apps/server/src/core/notification/notification.processor.ts @@ -64,6 +64,12 @@ export class NotificationProcessor } const workspaceId = await this.resolveWorkspaceId(job); + if (!workspaceId) { + this.logger.debug( + `Skipping ${job.name} job ${job.id}: workspace not found`, + ); + return; + } const appUrl = await this.getWorkspaceUrl(workspaceId); switch (job.name) { @@ -162,7 +168,7 @@ export class NotificationProcessor } } - private async resolveWorkspaceId(job: Job): Promise { + private async resolveWorkspaceId(job: Job): Promise { if ( job.name === QueueJob.PAGE_VERIFICATION_EXPIRING || job.name === QueueJob.PAGE_VERIFICATION_EXPIRED @@ -173,9 +179,9 @@ export class NotificationProcessor .select('workspaceId') .where('id', '=', verificationId) .executeTakeFirst(); - return row?.workspaceId ?? ''; + return row?.workspaceId; } - return (job.data as { workspaceId: string }).workspaceId; + return (job.data as { workspaceId?: string }).workspaceId; } private async runVerificationReconcile(): Promise { diff --git a/apps/server/src/core/notification/notification.service.ts b/apps/server/src/core/notification/notification.service.ts index 1f88bf59..ab1b88e6 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 7978ac38..9c4584c2 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 @@ -112,6 +112,17 @@ export class PageAccessService { return { hasRestriction: hasAnyRestriction }; } + /** + * Validate user can create a root page in the space, throws if not. + * For non-HTTP callers (Slack, integrations) that skip the controller check. + */ + 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/workspace/dto/update-workspace.dto.ts b/apps/server/src/core/workspace/dto/update-workspace.dto.ts index eb5e44d2..9905da20 100644 --- a/apps/server/src/core/workspace/dto/update-workspace.dto.ts +++ b/apps/server/src/core/workspace/dto/update-workspace.dto.ts @@ -13,6 +13,7 @@ import { export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) { @IsOptional() @IsArray() + @IsString({ each: true }) emailDomains: string[]; @IsOptional() diff --git a/apps/server/src/core/workspace/services/workspace.service.spec.ts b/apps/server/src/core/workspace/services/workspace.service.spec.ts new file mode 100644 index 00000000..227489b2 --- /dev/null +++ b/apps/server/src/core/workspace/services/workspace.service.spec.ts @@ -0,0 +1,83 @@ +import { WorkspaceService } from './workspace.service'; +import { UserRole } from '../../../common/helpers/types/permission'; + +const workspaceId = 'workspace-1'; +const userId = 'user-1'; +const authUser = { id: 'admin-1', role: UserRole.ADMIN } as any; + +function buildService() { + const trx = { + deleteFrom: jest.fn(() => ({ + where: jest.fn(() => ({ execute: jest.fn() })), + })), + }; + const db = { + transaction: () => ({ execute: (cb: (t: any) => any) => cb(trx) }), + }; + const workspaceRepo = { + findById: jest.fn().mockResolvedValue({ id: workspaceId }), + }; + const userRepo = { + findById: jest.fn().mockResolvedValue({ + id: userId, + role: UserRole.MEMBER, + deactivatedAt: null, + deletedAt: null, + }), + updateUser: jest.fn(), + }; + const repoDeletingByUser = () => ({ + deleteByUserAndWorkspace: jest.fn(), + }); + const userSessionRepo = { revokeByUserId: jest.fn() }; + const integrationConnectionRepo = { deleteUserConnections: jest.fn() }; + + const service = new WorkspaceService( + workspaceRepo as any, + {} as any, + {} as any, + {} as any, + {} as any, + userRepo as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + repoDeletingByUser() as any, + repoDeletingByUser() as any, + db as any, + { add: jest.fn() } as any, + {} as any, + {} as any, + { log: jest.fn() } as any, + userSessionRepo as any, + integrationConnectionRepo as any, + ); + + return { service, trx, integrationConnectionRepo }; +} + +describe('WorkspaceService user offboarding', () => { + it('deletes personal integration connections on deactivation', async () => { + const { service, trx, integrationConnectionRepo } = buildService(); + + await service.deactivateUser(authUser, userId, workspaceId); + + expect(integrationConnectionRepo.deleteUserConnections).toHaveBeenCalledWith( + userId, + trx, + ); + }); + + it('deletes personal integration connections on deletion', async () => { + const { service, trx, integrationConnectionRepo } = buildService(); + + await service.deleteUser(authUser, userId, workspaceId); + + expect(integrationConnectionRepo.deleteUserConnections).toHaveBeenCalledWith( + userId, + trx, + ); + }); +}); diff --git a/apps/server/src/core/workspace/services/workspace.service.ts b/apps/server/src/core/workspace/services/workspace.service.ts index 62b4a5e4..ea896eb2 100644 --- a/apps/server/src/core/workspace/services/workspace.service.ts +++ b/apps/server/src/core/workspace/services/workspace.service.ts @@ -50,6 +50,7 @@ import { AUDIT_SERVICE, IAuditService, } from '../../../integrations/audit/audit.service'; +import { IntegrationConnectionRepo } from '../../integration/repos/integration-connection.repo'; import { WsService } from '../../../ws/ws.service'; import { CollabAccessService } from '../../../collaboration/services/collab-access.service'; @@ -77,6 +78,7 @@ export class WorkspaceService { @InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue, @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, private userSessionRepo: UserSessionRepo, + private integrationConnectionRepo: IntegrationConnectionRepo, private wsService: WsService, private readonly collabAccessService: CollabAccessService, ) {} @@ -313,11 +315,19 @@ export class WorkspaceService { if (updateWorkspaceDto.emailDomains) { const regex = - /(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]/; - const emailDomains = updateWorkspaceDto.emailDomains || []; - updateWorkspaceDto.emailDomains = emailDomains - .map((domain) => regex.exec(domain)?.[0]) - .filter(Boolean); + /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$/; + const emailDomains = updateWorkspaceDto.emailDomains.map((domain) => + domain.trim().toLowerCase().replace(/^@/, ''), + ); + const invalidDomains = emailDomains.filter( + (domain) => !regex.test(domain), + ); + if (invalidDomains.length > 0) { + throw new BadRequestException( + `Invalid email domains: ${invalidDomains.join(', ')}`, + ); + } + updateWorkspaceDto.emailDomains = [...new Set(emailDomains)]; } if (updateWorkspaceDto.hostname) { @@ -908,6 +918,7 @@ export class WorkspaceService { trx, ); await this.userSessionRepo.revokeByUserId(userId, workspaceId, trx); + await this.integrationConnectionRepo.deleteUserConnections(userId, trx); return user; }); @@ -1035,6 +1046,7 @@ export class WorkspaceService { }); await this.userSessionRepo.revokeByUserId(userId, workspaceId, trx); + await this.integrationConnectionRepo.deleteUserConnections(userId, trx); return user; }); diff --git a/apps/server/src/core/workspace/workspace.module.ts b/apps/server/src/core/workspace/workspace.module.ts index b4c3481a..11cd6b02 100644 --- a/apps/server/src/core/workspace/workspace.module.ts +++ b/apps/server/src/core/workspace/workspace.module.ts @@ -4,9 +4,10 @@ import { WorkspaceController } from './controllers/workspace.controller'; import { SpaceModule } from '../space/space.module'; import { WorkspaceInvitationService } from './services/workspace-invitation.service'; import { TokenModule } from '../auth/token.module'; +import { IntegrationModule } from '../integration/integration.module'; @Module({ - imports: [SpaceModule, TokenModule], + imports: [SpaceModule, TokenModule, IntegrationModule], controllers: [WorkspaceController], providers: [WorkspaceService, WorkspaceInvitationService], exports: [WorkspaceService], diff --git a/apps/server/src/database/migrations/20260905T126412-integrations.ts b/apps/server/src/database/migrations/20260905T126412-integrations.ts new file mode 100644 index 00000000..faa6146d --- /dev/null +++ b/apps/server/src/database/migrations/20260905T126412-integrations.ts @@ -0,0 +1,124 @@ +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: shared bot connection, user: per-user token + .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); + + 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(); + + await db.schema + .createIndex('uq_integration_connections_provider_user_per_integration') + .on('integration_connections') + .columns(['integration_id', 'provider_user_id']) + .where(sql.ref('kind'), '=', 'user') + .where(sql.ref('provider_user_id'), 'is not', null) + .unique() + .execute(); + + await db.schema + .createIndex('idx_integration_connections_user_id') + .on('integration_connections') + .column('user_id') + .execute(); + + await db.schema + .createIndex('idx_integration_connections_integration_id') + .on('integration_connections') + .column('integration_id') + .execute(); + + await db.schema + .createIndex('idx_integration_connections_workspace_id') + .on('integration_connections') + .column('workspace_id') + .execute(); + + await db.schema + .createIndex('idx_integration_connections_token_expires_at') + .on('integration_connections') + .column('token_expires_at') + .where(sql.ref('refresh_token'), 'is not', null) + .where(sql.ref('invalidated_at'), 'is', null) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('integration_connections').ifExists().execute(); + await db.schema.dropTable('integrations').ifExists().execute(); +} diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index 0050e32d..d57e74ef 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -543,6 +543,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; @@ -748,6 +776,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/entity.types.ts b/apps/server/src/database/types/entity.types.ts index 1458eef5..0faf682e 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, @@ -217,6 +219,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 1da2915b..7eb0ef1d 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 1da2915bf81a43f7f5169445cc2e096dd15c3ef2 +Subproject commit 7eb0ef1dfa2f5ed9e6331e8d068cbbe15be25d7d diff --git a/apps/server/src/integrations/encryption/a256cbc-hs512.spec.ts b/apps/server/src/integrations/encryption/a256cbc-hs512.spec.ts new file mode 100644 index 00000000..c0490cf9 --- /dev/null +++ b/apps/server/src/integrations/encryption/a256cbc-hs512.spec.ts @@ -0,0 +1,64 @@ +import * as crypto from 'node:crypto'; +import { decryptA256CbcHs512, encryptA256CbcHs512 } from './a256cbc-hs512'; + +// RFC 7518 Appendix B.3: independent known-answer values +const key = Buffer.from(Array.from({ length: 64 }, (_, i) => i)); +const iv = Buffer.from('1af38c2dc2b96ffdd86694092341bc04', 'hex'); +const plaintext = Buffer.from( + 'A cipher system must not be required to be secret, and it must be able to fall into the hands of the enemy without inconvenience', +); +const aad = Buffer.from('The second principle of Auguste Kerckhoffs'); +const cipherText = Buffer.from( + '4affaaadb78c31c5da4b1b590d10ffbd3dd8d5d302423526912da037ecbcc7bd' + + '822c301dd67c373bccb584ad3e9279c2e6d12a1374b77f077553df829410446b' + + '36ebd97066296ae6427ea75c2e0846a11a09ccf5370dc80bfecbad28c73f09b3' + + 'a3b75e662a2594410ae496b2e2e6609e31e6e02cc837f053d21f37ff4f51950b' + + 'be2638d09dd7a4930930806d0703b1f6', + 'hex', +); +const authTag = Buffer.from( + '4dd3b4c088a7f45c216839645b2012bf2e6269a8c56a816dbc1b267761955bc5', + 'hex', +); + +describe('A256CBC-HS512', () => { + it('matches the RFC 7518 encryption vector', () => { + expect(encryptA256CbcHs512(plaintext, key, iv, aad)).toEqual({ + iv, + cipherText, + authTag, + }); + }); + + it('decrypts the RFC 7518 ciphertext and tag', () => { + expect(decryptA256CbcHs512({ iv, cipherText, authTag }, key, aad)).toEqual( + plaintext, + ); + }); + + it('rejects bad authentication before invoking CBC decryption', () => { + const decrypt = jest.spyOn(crypto, 'createDecipheriv'); + try { + expect(() => + decryptA256CbcHs512( + { iv, cipherText, authTag: Buffer.alloc(32) }, + key, + aad, + ), + ).toThrow('Ciphertext authentication failed'); + expect(decrypt).not.toHaveBeenCalled(); + } finally { + decrypt.mockRestore(); + } + }); + + it('authenticates the additional data', () => { + expect(() => + decryptA256CbcHs512( + { iv, cipherText, authTag }, + key, + Buffer.from('other'), + ), + ).toThrow('Ciphertext authentication failed'); + }); +}); diff --git a/apps/server/src/integrations/encryption/a256cbc-hs512.ts b/apps/server/src/integrations/encryption/a256cbc-hs512.ts new file mode 100644 index 00000000..15b4c2d5 --- /dev/null +++ b/apps/server/src/integrations/encryption/a256cbc-hs512.ts @@ -0,0 +1,82 @@ +import { + createCipheriv, + createDecipheriv, + createHmac, + timingSafeEqual, +} from 'node:crypto'; + +export const V2_ALGORITHM = 'A256CBC-HS512'; +export const V2_KEY_LENGTH = 64; +export const V2_IV_LENGTH = 16; +export const V2_TAG_LENGTH = 32; + +type AuthenticatedCiphertext = { + iv: Buffer; + authTag: Buffer; + cipherText: Buffer; +}; + +function validateKeyAndIv(key: Buffer, iv: Buffer): void { + if (key.length !== V2_KEY_LENGTH || iv.length !== V2_IV_LENGTH) { + throw new Error('Invalid A256CBC-HS512 key or IV length.'); + } +} + +// RFC 7518 §5.2: MAC_KEY precedes ENC_KEY; AL is the AAD length in bits. +function authenticationTag( + key: Buffer, + aad: Buffer, + iv: Buffer, + cipherText: Buffer, +): Buffer { + const aadLength = Buffer.alloc(8); + aadLength.writeBigUInt64BE(BigInt(aad.length) * 8n); + return createHmac('sha512', key.subarray(0, 32)) + .update(aad) + .update(iv) + .update(cipherText) + .update(aadLength) + .digest() + .subarray(0, V2_TAG_LENGTH); +} + +export function encryptA256CbcHs512( + plaintext: Buffer, + key: Buffer, + iv: Buffer, + aad: Buffer, +): AuthenticatedCiphertext { + validateKeyAndIv(key, iv); + const cipher = createCipheriv('aes-256-cbc', key.subarray(32), iv); + const cipherText = Buffer.concat([cipher.update(plaintext), cipher.final()]); + return { + iv, + cipherText, + authTag: authenticationTag(key, aad, iv, cipherText), + }; +} + +export function decryptA256CbcHs512( + payload: AuthenticatedCiphertext, + key: Buffer, + aad: Buffer, +): Buffer { + const { iv, authTag, cipherText } = payload; + validateKeyAndIv(key, iv); + if ( + authTag.length !== V2_TAG_LENGTH || + cipherText.length === 0 || + cipherText.length % V2_IV_LENGTH !== 0 + ) { + throw new Error('Invalid A256CBC-HS512 payload lengths.'); + } + + const expected = authenticationTag(key, aad, iv, cipherText); + if (!timingSafeEqual(authTag, expected)) { + throw new Error('Ciphertext authentication failed.'); + } + + // Authenticate before invoking CBC decryption or inspecting its padding. + const decipher = createDecipheriv('aes-256-cbc', key.subarray(32), iv); + return Buffer.concat([decipher.update(cipherText), decipher.final()]); +} diff --git a/apps/server/src/integrations/encryption/encryption.service.spec.ts b/apps/server/src/integrations/encryption/encryption.service.spec.ts index 64b1a8e0..7c6e2c9b 100644 --- a/apps/server/src/integrations/encryption/encryption.service.spec.ts +++ b/apps/server/src/integrations/encryption/encryption.service.spec.ts @@ -1,9 +1,12 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { EncryptionService } from './encryption.service'; +import { EncryptionPurpose, 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'; +// Produced by the shipped v0.96.0 writer, with a fixed IV and a synthetic secret. +const LEGACY_SIEM_CIPHERTEXT = + 'eyJpdiI6IkFBRUNBd1FGQmdjSUNRb0wiLCJhdXRoVGFnIjoiZGZRbW1TVHdQck5uTzh3THFqMmhhUT09IiwiY2lwaGVyVGV4dCI6IjQxL0c1bVBlS0tVTkQ1ZW5vVk54M2V1eS9CNXluQVhJbWtCYmFLNlAifQ=='; const buildService = (appSecret: string | undefined) => { const env = { getAppSecret: () => appSecret } as EnvironmentService; @@ -12,6 +15,10 @@ const buildService = (appSecret: string | undefined) => { const decodeEnvelope = (encrypted: string) => JSON.parse(Buffer.from(encrypted, 'base64').toString()) as { + version?: number; + algorithm?: string; + keyId?: string; + purpose?: string; iv: string; authTag: string; cipherText: string; @@ -54,48 +61,77 @@ describe('EncryptionService', () => { 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); + const encrypted = service.encrypt( + plaintext, + EncryptionPurpose.SIEM_CREDENTIALS, + ); + expect( + service.decrypt(encrypted, EncryptionPurpose.SIEM_CREDENTIALS), + ).toBe(plaintext); }); it('handles empty string', () => { - const encrypted = service.encrypt(''); - expect(service.decrypt(encrypted)).toBe(''); + const encrypted = service.encrypt('', EncryptionPurpose.SIEM_CREDENTIALS); + expect( + service.decrypt(encrypted, EncryptionPurpose.SIEM_CREDENTIALS), + ).toBe(''); }); it('handles unicode (multi-byte UTF-8)', () => { const plaintext = 'héllo 🔐 世界'; - const encrypted = service.encrypt(plaintext); - expect(service.decrypt(encrypted)).toBe(plaintext); + const encrypted = service.encrypt( + plaintext, + EncryptionPurpose.SIEM_CREDENTIALS, + ); + expect( + service.decrypt(encrypted, EncryptionPurpose.SIEM_CREDENTIALS), + ).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); + const encrypted = service.encrypt( + plaintext, + EncryptionPurpose.SIEM_CREDENTIALS, + ); + expect( + service.decrypt(encrypted, EncryptionPurpose.SIEM_CREDENTIALS), + ).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); + const a = service.encrypt(plaintext, EncryptionPurpose.SIEM_CREDENTIALS); + const b = service.encrypt(plaintext, EncryptionPurpose.SIEM_CREDENTIALS); expect(a).not.toBe(b); - expect(service.decrypt(a)).toBe(plaintext); - expect(service.decrypt(b)).toBe(plaintext); + expect(service.decrypt(a, EncryptionPurpose.SIEM_CREDENTIALS)).toBe( + plaintext, + ); + expect(service.decrypt(b, EncryptionPurpose.SIEM_CREDENTIALS)).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); + const encrypted = service.encrypt( + 'secret', + EncryptionPurpose.SIEM_CREDENTIALS, + ); + expect(() => + other.decrypt(encrypted, EncryptionPurpose.SIEM_CREDENTIALS), + ).toThrow(UnableToDecrypt); }); }); describe('tamper detection', () => { it('rejects modified ciphertext', () => { - const encrypted = service.encrypt('hello'); + const encrypted = service.encrypt( + 'hello', + EncryptionPurpose.SIEM_CREDENTIALS, + ); const env = decodeEnvelope(encrypted); const tamperedCipher = Buffer.from(env.cipherText, 'base64'); tamperedCipher[0] ^= 0x01; @@ -103,11 +139,16 @@ describe('EncryptionService', () => { ...env, cipherText: tamperedCipher.toString('base64'), }); - expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt); + expect(() => + service.decrypt(tampered, EncryptionPurpose.SIEM_CREDENTIALS), + ).toThrow(UnableToDecrypt); }); it('rejects modified auth tag', () => { - const encrypted = service.encrypt('hello'); + const encrypted = service.encrypt( + 'hello', + EncryptionPurpose.SIEM_CREDENTIALS, + ); const env = decodeEnvelope(encrypted); const tamperedTag = Buffer.from(env.authTag, 'base64'); tamperedTag[0] ^= 0x01; @@ -115,11 +156,16 @@ describe('EncryptionService', () => { ...env, authTag: tamperedTag.toString('base64'), }); - expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt); + expect(() => + service.decrypt(tampered, EncryptionPurpose.SIEM_CREDENTIALS), + ).toThrow(UnableToDecrypt); }); it('rejects modified IV', () => { - const encrypted = service.encrypt('hello'); + const encrypted = service.encrypt( + 'hello', + EncryptionPurpose.SIEM_CREDENTIALS, + ); const env = decodeEnvelope(encrypted); const tamperedIV = Buffer.from(env.iv, 'base64'); tamperedIV[0] ^= 0x01; @@ -127,20 +173,27 @@ describe('EncryptionService', () => { ...env, iv: tamperedIV.toString('base64'), }); - expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt); + expect(() => + service.decrypt(tampered, EncryptionPurpose.SIEM_CREDENTIALS), + ).toThrow(UnableToDecrypt); }); }); describe('malformed payloads', () => { it('rejects non-base64 garbage', () => { - expect(() => service.decrypt('!!!not-valid-base64!!!')).toThrow( - UnableToDecrypt, - ); + expect(() => + service.decrypt( + '!!!not-valid-base64!!!', + EncryptionPurpose.SIEM_CREDENTIALS, + ), + ).toThrow(UnableToDecrypt); }); it('rejects base64 of non-JSON', () => { const garbage = Buffer.from('not json at all').toString('base64'); - expect(() => service.decrypt(garbage)).toThrow(UnableToDecrypt); + expect(() => + service.decrypt(garbage, EncryptionPurpose.SIEM_CREDENTIALS), + ).toThrow(UnableToDecrypt); }); it('rejects JSON missing required fields', () => { @@ -148,37 +201,161 @@ describe('EncryptionService', () => { iv: Buffer.alloc(12).toString('base64'), authTag: Buffer.alloc(16).toString('base64'), } as never); - expect(() => service.decrypt(partial)).toThrow(UnableToDecrypt); + expect(() => + service.decrypt(partial, EncryptionPurpose.SIEM_CREDENTIALS), + ).toThrow(UnableToDecrypt); }); it('rejects wrong-length IV', () => { - const encrypted = service.encrypt('hello'); + const encrypted = service.encrypt( + 'hello', + EncryptionPurpose.SIEM_CREDENTIALS, + ); const env = decodeEnvelope(encrypted); const bad = encodeEnvelope({ ...env, iv: Buffer.alloc(8).toString('base64'), }); - expect(() => service.decrypt(bad)).toThrow(UnableToDecrypt); + expect(() => + service.decrypt(bad, EncryptionPurpose.SIEM_CREDENTIALS), + ).toThrow(UnableToDecrypt); }); it('rejects wrong-length auth tag', () => { - const encrypted = service.encrypt('hello'); + const encrypted = service.encrypt( + 'hello', + EncryptionPurpose.SIEM_CREDENTIALS, + ); const env = decodeEnvelope(encrypted); const bad = encodeEnvelope({ ...env, authTag: Buffer.alloc(8).toString('base64'), }); - expect(() => service.decrypt(bad)).toThrow(UnableToDecrypt); + expect(() => + service.decrypt(bad, EncryptionPurpose.SIEM_CREDENTIALS), + ).toThrow(UnableToDecrypt); }); }); describe('envelope format', () => { - it('returns base64 of JSON envelope with iv (12B), authTag (16B), cipherText', () => { - const encrypted = service.encrypt('hello'); + it('writes an authenticated v2 envelope with a 16-byte IV and 32-byte tag', () => { + const encrypted = service.encrypt( + 'hello', + EncryptionPurpose.SIEM_CREDENTIALS, + ); const env = decodeEnvelope(encrypted); - expect(Buffer.from(env.iv, 'base64')).toHaveLength(12); - expect(Buffer.from(env.authTag, 'base64')).toHaveLength(16); + expect(env).toMatchObject({ + version: 2, + algorithm: 'A256CBC-HS512', + keyId: 'app-secret', + purpose: 'siem-credentials', + }); + expect(Buffer.from(env.iv, 'base64')).toHaveLength(16); + expect(Buffer.from(env.authTag, 'base64')).toHaveLength(32); expect(Buffer.from(env.cipherText, 'base64').length).toBeGreaterThan(0); }); }); + + it('still decrypts credentials produced by the shipped SIEM writer', () => { + expect( + service.decrypt( + LEGACY_SIEM_CIPHERTEXT, + EncryptionPurpose.SIEM_CREDENTIALS, + ), + ).toBe('{"token":"shipped-siem-token"}'); + }); + + describe('purpose and version isolation', () => { + it.each(['encrypt', 'decrypt'] as const)( + '%s requires an explicit purpose', + (operation) => { + const input = + operation === 'encrypt' ? 'secret' : LEGACY_SIEM_CIPHERTEXT; + expect(() => + service[operation](input, undefined as unknown as EncryptionPurpose), + ).toThrow('Unsupported encryption purpose.'); + }, + ); + + it.each(Object.values(EncryptionPurpose))( + 'round-trips %s independently', + (purpose) => { + const encrypted = service.encrypt('secret', purpose); + expect(service.decrypt(encrypted, purpose)).toBe('secret'); + for (const other of Object.values(EncryptionPurpose)) { + if (other !== purpose) { + expect(() => service.decrypt(encrypted, other)).toThrow( + UnableToDecrypt, + ); + } + } + }, + ); + + it('does not accept legacy SIEM ciphertext as tokens or a completion ticket', () => { + expect(() => + service.decrypt( + LEGACY_SIEM_CIPHERTEXT, + EncryptionPurpose.INTEGRATION_TOKENS, + ), + ).toThrow(UnableToDecrypt); + expect(() => + service.decrypt( + LEGACY_SIEM_CIPHERTEXT, + EncryptionPurpose.OAUTH_COMPLETION, + ), + ).toThrow(UnableToDecrypt); + }); + + it.each([ + { version: 3 }, + { version: null }, + { algorithm: 'aes-256-gcm' }, + { keyId: 'unknown' }, + { purpose: EncryptionPurpose.INTEGRATION_TOKENS }, + ])('rejects changed authenticated metadata %j', (change) => { + const envelope = decodeEnvelope( + service.encrypt('secret', EncryptionPurpose.SIEM_CREDENTIALS), + ); + const modified = encodeEnvelope({ ...envelope, ...change }); + const purpose = change.purpose ?? EncryptionPurpose.SIEM_CREDENTIALS; + expect(() => service.decrypt(modified, purpose)).toThrow(UnableToDecrypt); + }); + + it('does not fall back to legacy decryption when the v2 header is stripped', () => { + const { iv, authTag, cipherText } = decodeEnvelope( + service.encrypt('secret', EncryptionPurpose.SIEM_CREDENTIALS), + ); + expect(() => + service.decrypt( + encodeEnvelope({ iv, authTag, cipherText }), + EncryptionPurpose.SIEM_CREDENTIALS, + ), + ).toThrow(UnableToDecrypt); + }); + + it.each([null, [], 'string', 1, { iv: 1, authTag: [], cipherText: null }])( + 'rejects malformed JSON envelopes %j', + (value) => { + expect(() => + service.decrypt( + Buffer.from(JSON.stringify(value)).toString('base64'), + EncryptionPurpose.SIEM_CREDENTIALS, + ), + ).toThrow(UnableToDecrypt); + }, + ); + + it('rejects noncanonical base64 fields instead of silently discarding characters', () => { + const envelope = decodeEnvelope( + service.encrypt('secret', EncryptionPurpose.SIEM_CREDENTIALS), + ); + expect(() => + service.decrypt( + encodeEnvelope({ ...envelope, iv: envelope.iv + '!' }), + EncryptionPurpose.SIEM_CREDENTIALS, + ), + ).toThrow(UnableToDecrypt); + }); + }); }); diff --git a/apps/server/src/integrations/encryption/encryption.service.ts b/apps/server/src/integrations/encryption/encryption.service.ts index 63dc4ce9..a0709f42 100644 --- a/apps/server/src/integrations/encryption/encryption.service.ts +++ b/apps/server/src/integrations/encryption/encryption.service.ts @@ -1,108 +1,210 @@ -// https://github.com/nhedger/nestjs-encryption - MIT import { Injectable } from '@nestjs/common'; import { - createCipheriv, createDecipheriv, createHash, + hkdfSync, randomBytes, } from 'node:crypto'; import { UnableToDecrypt, UnableToInitialize } from './encryption.errors'; import { EnvironmentService } from '../environment/environment.service'; +import { + decryptA256CbcHs512, + encryptA256CbcHs512, + V2_ALGORITHM, + V2_IV_LENGTH, + V2_KEY_LENGTH, +} from './a256cbc-hs512'; -const ALGORITHM = 'aes-256-gcm'; -const KEY_DOMAIN = 'docmost:encryption:v1'; -const IV_LENGTH = 12; -const AUTH_TAG_LENGTH = 16; +export enum EncryptionPurpose { + SIEM_CREDENTIALS = 'siem-credentials', + INTEGRATION_TOKENS = 'integration-tokens', + OAUTH_COMPLETION = 'oauth-completion', +} -type AEADPayload = { - iv: TFormat; - authTag: TFormat; - cipherText: TFormat; +const KEY_ID = 'app-secret'; +const V2_KEY_DOMAIN = 'docmost:encryption:v2'; +const LEGACY_KEY_DOMAIN = 'docmost:encryption:v1'; +const PAYLOAD_FIELDS = ['iv', 'authTag', 'cipherText']; +const HEADER_FIELDS = ['version', 'algorithm', 'keyId', 'purpose']; + +type BinaryPayload = { iv: Buffer; authTag: Buffer; cipherText: Buffer }; +type V2Header = { + version: 2; + algorithm: typeof V2_ALGORITHM; + keyId: typeof KEY_ID; + purpose: EncryptionPurpose; }; +type DecodedPayload = + | { version: 1; payload: BinaryPayload } + | { version: 2; payload: BinaryPayload; header: V2Header }; @Injectable() export class EncryptionService { - private readonly key: Buffer; + private readonly appSecret: string; + private readonly keys = new Map(); constructor(environmentService: EnvironmentService) { - const appSecret = environmentService.getAppSecret(); - if (!appSecret) { + this.appSecret = environmentService.getAppSecret(); + if (!this.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'); + // Every consumer must select its purpose; only SIEM can read the shipped + // unversioned format. + public encrypt(plaintext: string, purpose: EncryptionPurpose): string { + const header = this.header(purpose); + const { iv, authTag, cipherText } = encryptA256CbcHs512( + Buffer.from(plaintext, 'utf8'), + this.keyFor(purpose), + randomBytes(V2_IV_LENGTH), + this.aad(header), + ); + return Buffer.from( + JSON.stringify({ + ...header, + iv: iv.toString('base64'), + authTag: authTag.toString('base64'), + cipherText: cipherText.toString('base64'), + }), + ).toString('base64'); } - public decrypt(encrypted: string): string { + public decrypt(encrypted: string, purpose: EncryptionPurpose): 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'); + this.assertPurpose(purpose); + const decoded = this.decode(encrypted); + if (decoded.version === 1) { + if (purpose !== EncryptionPurpose.SIEM_CREDENTIALS) { + throw new Error( + 'Legacy ciphertext is only supported for SIEM credentials.', + ); + } + return this.decryptLegacy(decoded.payload); + } + if (decoded.header.purpose !== purpose) { + throw new Error('Ciphertext purpose does not match.'); + } + return decryptA256CbcHs512( + decoded.payload, + this.keyFor(purpose), + this.aad(decoded.header), + ).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.`, + private keyFor(purpose: EncryptionPurpose): Buffer { + this.assertPurpose(purpose); + let key = this.keys.get(purpose); + if (!key) { + // Derive 32-byte MAC_KEY || 32-byte ENC_KEY via the native provider. + key = Buffer.from( + hkdfSync( + 'sha256', + this.appSecret, + V2_KEY_DOMAIN, + `${V2_ALGORITHM}:${purpose}`, + V2_KEY_LENGTH, + ), ); + this.keys.set(purpose, key); } + return key; + } - 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.`, - ); + private header(purpose: EncryptionPurpose): V2Header { + this.assertPurpose(purpose); + return { version: 2, algorithm: V2_ALGORITHM, keyId: KEY_ID, purpose }; + } + + private aad(header: V2Header): Buffer { + // Fixed field order makes authentication independent of JSON property order. + return Buffer.from(JSON.stringify(header), 'utf8'); + } + + private assertPurpose( + purpose: unknown, + ): asserts purpose is EncryptionPurpose { + if ( + !Object.values(EncryptionPurpose).includes(purpose as EncryptionPurpose) + ) { + throw new Error('Unsupported encryption purpose.'); } + } - const cipherText = Buffer.from( - deserializedPkg.cipherText as string, - 'base64', + private decode(encrypted: string): DecodedPayload { + const value: unknown = JSON.parse( + this.decodeBase64(encrypted).toString('utf8'), ); + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('The ciphertext envelope must be an object.'); + } + const envelope = value as Record; + const hasVersion = Object.prototype.hasOwnProperty.call( + envelope, + 'version', + ); + const fields = hasVersion + ? [...HEADER_FIELDS, ...PAYLOAD_FIELDS] + : PAYLOAD_FIELDS; + if ( + Object.keys(envelope).length !== fields.length || + !fields.every((field) => + Object.prototype.hasOwnProperty.call(envelope, field), + ) + ) { + throw new Error('Invalid ciphertext envelope fields.'); + } + const payload = { + iv: this.decodeBase64(envelope.iv), + authTag: this.decodeBase64(envelope.authTag), + cipherText: this.decodeBase64(envelope.cipherText), + }; + if (!hasVersion) { + if (payload.iv.length !== 12 || payload.authTag.length !== 16) { + throw new Error('Invalid legacy ciphertext IV or tag length.'); + } + return { version: 1, payload }; + } + if ( + envelope.version !== 2 || + envelope.algorithm !== V2_ALGORITHM || + envelope.keyId !== KEY_ID + ) { + throw new Error( + 'Unsupported ciphertext version, algorithm or key identifier.', + ); + } + this.assertPurpose(envelope.purpose); + return { version: 2, payload, header: this.header(envelope.purpose) }; + } - return { iv, authTag, cipherText }; + private decodeBase64(value: unknown): Buffer { + if (typeof value !== 'string') { + throw new Error('Ciphertext fields must be base64 strings.'); + } + const decoded = Buffer.from(value, 'base64'); + if (decoded.toString('base64') !== value) { + throw new Error('Invalid base64 ciphertext encoding.'); + } + return decoded; + } + + private decryptLegacy({ iv, authTag, cipherText }: BinaryPayload): string { + // Preserve the shipped derivation exactly. It is never used for new writes. + const key = createHash('sha256') + .update(LEGACY_KEY_DOMAIN) + .update(this.appSecret) + .digest(); + const decipher = createDecipheriv('aes-256-gcm', key, iv, { + authTagLength: 16, + }); + decipher.setAuthTag(authTag); + return Buffer.concat([ + decipher.update(cipherText), + decipher.final(), + ]).toString('utf8'); } } diff --git a/apps/server/src/integrations/environment/domain.service.ts b/apps/server/src/integrations/environment/domain.service.ts index fa6b1778..e1b7de3e 100644 --- a/apps/server/src/integrations/environment/domain.service.ts +++ b/apps/server/src/integrations/environment/domain.service.ts @@ -18,4 +18,18 @@ export class DomainService { const protocol = this.environmentService.isHttps() ? 'https' : 'http'; return `${protocol}://${hostname}.${domain}`; } + + 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 96d17401..79b1ce60 100644 --- a/apps/server/src/integrations/environment/environment.service.ts +++ b/apps/server/src/integrations/environment/environment.service.ts @@ -393,6 +393,10 @@ export class EnvironmentService { .filter(Boolean); } + getSlackSigningSecret(): string | undefined { + return this.configService.get('INTEGRATION_SLACK_SIGNING_SECRET'); + } + getAllowedPrivateNetworks(): string { return this.configService.get('ALLOWED_PRIVATE_NETWORKS', 'none'); } diff --git a/apps/server/src/integrations/queue/constants/queue.constants.ts b/apps/server/src/integrations/queue/constants/queue.constants.ts index a03c492d..42132f4a 100644 --- a/apps/server/src/integrations/queue/constants/queue.constants.ts +++ b/apps/server/src/integrations/queue/constants/queue.constants.ts @@ -8,7 +8,13 @@ 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}', + // /docmost ask takes seconds, so it stays off the inbound event queue + SLACK_ASK = '{slack-ask}', + // Outbound DMs, kept off the inbound event queue + SLACK_NOTIFY = '{slack-notify}', BASE_QUEUE = '{base-queue}', SIEM_QUEUE = '{siem-queue}', } @@ -90,6 +96,11 @@ export enum QueueJob { PDF_EXPORT_TASK = 'pdf-export-task', PDF_EXPORT_CLEANUP = 'pdf-export-cleanup', + 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 77bdf2b4..3aa1ce8e 100644 --- a/apps/server/src/integrations/queue/queue.module.ts +++ b/apps/server/src/integrations/queue/queue.module.ts @@ -94,6 +94,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.SIEM_QUEUE, defaultJobOptions: { diff --git a/apps/server/src/integrations/throttle/throttle.module.ts b/apps/server/src/integrations/throttle/throttle.module.ts index 9fa3cb6b..e867f170 100644 --- a/apps/server/src/integrations/throttle/throttle.module.ts +++ b/apps/server/src/integrations/throttle/throttle.module.ts @@ -11,6 +11,7 @@ import { OAUTH_TOKEN_THROTTLER, OAUTH_AUTHORIZE_THROTTLER, SIEM_TEST_THROTTLER, + UNFURL_THROTTLER, } from './throttler-names'; import Redis from 'ioredis'; @@ -29,6 +30,7 @@ import Redis from 'ioredis'; { name: OAUTH_TOKEN_THROTTLER, ttl: 60_000, limit: 60 }, { name: OAUTH_AUTHORIZE_THROTTLER, ttl: 60_000, limit: 30 }, { name: SIEM_TEST_THROTTLER, ttl: 60_000, limit: 10 }, + { name: UNFURL_THROTTLER, ttl: 60_000, limit: 120 }, ], errorMessage: 'Too many requests', storage: new ThrottlerStorageRedisService( diff --git a/apps/server/src/integrations/throttle/throttler-names.ts b/apps/server/src/integrations/throttle/throttler-names.ts index e0c3b5af..db913f51 100644 --- a/apps/server/src/integrations/throttle/throttler-names.ts +++ b/apps/server/src/integrations/throttle/throttler-names.ts @@ -4,6 +4,7 @@ export const OAUTH_REGISTER_THROTTLER = 'oauth-register'; export const OAUTH_TOKEN_THROTTLER = 'oauth-token'; export const OAUTH_AUTHORIZE_THROTTLER = 'oauth-authorize'; export const SIEM_TEST_THROTTLER = 'siem-test'; +export const UNFURL_THROTTLER = 'integration-unfurl'; // Every named throttler must appear here; spread it in @SkipThrottle and re-enable per name with false. export const ALL_NAMED_THROTTLERS_SKIPPED: Record = { @@ -13,4 +14,5 @@ export const ALL_NAMED_THROTTLERS_SKIPPED: Record = { [OAUTH_TOKEN_THROTTLER]: true, [OAUTH_AUTHORIZE_THROTTLER]: true, [SIEM_TEST_THROTTLER]: true, + [UNFURL_THROTTLER]: true, }; diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 2696d4e1..8a9fff64 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -18,6 +18,10 @@ import { resolveFrameHeadersForPath, } from './common/helpers'; +// Provider redirects land on the central app host, outside any workspace. +const OAUTH_CALLBACK_PATH = + /^\/api\/integrations\/oauth\/[^/?#]+\/callback(?:[?#]|$)/; + async function bootstrap() { const adapter = new FastifyAdapter({ trustProxy: true, @@ -129,11 +133,15 @@ async function bootstrap() { '/api/workspace/create', '/api/workspace/joined', '/api/workspace/find-by-email', + '/api/integrations/slack/events', + '/api/integrations/slack/commands', + '/api/integrations/slack/interactivity', ]; if ( req.originalUrl.startsWith('/api') && - !excludedPaths.some((path) => req.originalUrl.startsWith(path)) + !excludedPaths.some((path) => req.originalUrl.startsWith(path)) && + !OAUTH_CALLBACK_PATH.test(req.originalUrl) ) { if (!req.raw?.['workspaceId'] && req.originalUrl !== '/api') { throw new NotFoundException('Workspace not found'); diff --git a/packages/editor-ext/src/index.ts b/packages/editor-ext/src/index.ts index cc1631b1..51f742eb 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/tabs"; 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 00000000..9a07f8d5 --- /dev/null +++ b/packages/editor-ext/src/lib/integration-link/index.ts @@ -0,0 +1,16 @@ +export { IntegrationCard } from "./integration-card"; +export type { + IntegrationLinkOptions, + IntegrationLinkAttributes, +} from "./integration-link"; +export { IntegrationMention } from "./integration-mention"; +export { + integrationLinkPatterns, + matchIntegrationLink, + describeIntegrationLink, +} from "./integration-link-patterns"; +export type { + IntegrationLinkPattern, + IntegrationLinkDescription, + IntegrationUnfurlHosts, +} from "./integration-link-patterns"; diff --git a/packages/editor-ext/src/lib/integration-link/integration-card.ts b/packages/editor-ext/src/lib/integration-link/integration-card.ts new file mode 100644 index 00000000..5d346662 --- /dev/null +++ b/packages/editor-ext/src/lib/integration-link/integration-card.ts @@ -0,0 +1,102 @@ +import { Node, mergeAttributes } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +import { + IntegrationLinkAttributes, + IntegrationLinkOptions, + sanitizeIntegrationUrl, +} from "./integration-link"; + +declare module "@tiptap/core" { + interface Commands { + integrationCard: { + setIntegrationCard: ( + attributes: Partial, + ) => ReturnType; + }; + } +} + +export const IntegrationCard = Node.create({ + name: "integrationCard", + inline: false, + group: "block", + isolating: true, + atom: true, + defining: true, + draggable: true, + + addOptions() { + return { + HTMLAttributes: {}, + view: null, + }; + }, + + addAttributes() { + return { + url: { + default: "", + parseHTML: (element: HTMLElement) => + sanitizeIntegrationUrl(element.getAttribute("data-url")), + renderHTML: (attributes: IntegrationLinkAttributes) => ({ + "data-url": sanitizeIntegrationUrl(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 safeUrl = sanitizeIntegrationUrl(HTMLAttributes["data-url"]); + + return [ + "div", + mergeAttributes( + { "data-type": this.name }, + this.options.HTMLAttributes, + HTMLAttributes, + ), + ["a", { href: safeUrl, target: "_blank", rel: "noopener" }, safeUrl], + ]; + }, + + renderText({ node }) { + return node.attrs.url; + }, + + addCommands() { + return { + setIntegrationCard: + (attrs) => + ({ commands }) => { + return commands.insertContent({ + type: this.name, + attrs: { + ...attrs, + url: sanitizeIntegrationUrl(attrs.url), + }, + }); + }, + }; + }, + + addNodeView() { + this.editor.isInitialized = true; + return ReactNodeViewRenderer(this.options.view); + }, +}); 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 00000000..0640ec30 --- /dev/null +++ b/packages/editor-ext/src/lib/integration-link/integration-link-patterns.ts @@ -0,0 +1,518 @@ +export type IntegrationLinkPattern = { + provider: string; + type: string; + regex: RegExp; + // Matches only on a host the provider reports serving. + requiresProviderHost?: boolean; +}; + +export const integrationLinkPatterns: IntegrationLinkPattern[] = [ + // Slack message permalink (must precede the host-agnostic GitHub repo pattern) + { + 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-]+)\/?$/, + }, + // Azure DevOps captures org, project?, repo, id for a pull request and org, project?, id for a work item. + { + provider: "azure_devops", + type: "azure-devops-pr", + regex: + /^https:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))dev\.azure\.com\/([A-Za-z0-9-]+)(?:\/((?!_)[^\/?#]+))?\/_git\/([^\/?#]+)\/pullrequest\/(\d{1,10})(?=[\/?#]|$)/, + }, + { + provider: "azure_devops", + type: "azure-devops-work-item", + regex: + /^https:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))dev\.azure\.com\/([A-Za-z0-9-]+)(?:\/((?!_)[^\/?#]+))?\/_workitems\/edit\/(\d{1,10})(?=[\/?#]|$)/, + }, + // Azure DevOps board, backlog or sprint with an open work item + { + provider: "azure_devops", + type: "azure-devops-work-item", + regex: + /^https:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))dev\.azure\.com\/([A-Za-z0-9-]+)\/((?!_)[^\/?#]+)\/_(?:boards|backlogs|sprints)\/[^?#]*\?(?:[^#]*&)?workitem=(\d{1,10})(?=[&#]|$)/, + }, + // Azure DevOps legacy host + { + provider: "azure_devops", + type: "azure-devops-pr", + regex: + /^https:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))([A-Za-z0-9-]+)\.visualstudio\.com(?:\/DefaultCollection)?(?:\/((?!_)[^\/?#]+))?\/_git\/([^\/?#]+)\/pullrequest\/(\d{1,10})(?=[\/?#]|$)/, + }, + { + provider: "azure_devops", + type: "azure-devops-work-item", + regex: + /^https:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))([A-Za-z0-9-]+)\.visualstudio\.com(?:\/DefaultCollection)?(?:\/((?!_)[^\/?#]+))?\/_workitems\/edit\/(\d{1,10})(?=[\/?#]|$)/, + }, + { + provider: "azure_devops", + type: "azure-devops-work-item", + regex: + /^https:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))([A-Za-z0-9-]+)\.visualstudio\.com(?:\/DefaultCollection)?\/((?!_)[^\/?#]+)\/_(?:boards|backlogs|sprints)\/[^?#]*\?(?:[^#]*&)?workitem=(\d{1,10})(?=[&#]|$)/, + }, + // Jira issue (cloud + self-hosted): /browse/KEY-123 (must precede the GitHub repo pattern) + { + provider: "jira", + type: "jira-issue", + regex: /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[^\/]+\/browse\/([A-Za-z0-9]+-\d+)/, + }, + // Jira legacy board (cloud + self-hosted): RapidBoard.jspa?…selectedIssue=KEY + { + provider: "jira", + type: "jira-issue", + regex: + /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[^\/]+\/secure\/RapidBoard\.jspa\?(?:.*&)?selectedIssue=([A-Za-z0-9]+-\d+)/, + }, + // Jira cloud board/backlog with a selected issue + { + provider: "jira", + type: "jira-issue", + regex: + /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[A-Za-z0-9-]+\.[Aa][Tt][Ll][Aa][Ss][Ss][Ii][Aa][Nn]\.[Nn][Ee][Tt]\/jira\/software(?:\/c)?\/projects\/[\w-]+\/boards\/\d+(?:\/\w+)?\?(?:.*&)?selectedIssue=([A-Za-z0-9]+-\d+)/, + }, + // Jira Data Center under a context path, e.g. /jira/browse/KEY-123 + { + provider: "jira", + type: "jira-issue", + regex: + /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[^\/]+(?:\/[^\/?#]+)+?\/browse\/([A-Za-z0-9]+-\d+)/, + requiresProviderHost: true, + }, + { + provider: "jira", + type: "jira-issue", + regex: + /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[^\/]+(?:\/[^\/?#]+)+?\/secure\/RapidBoard\.jspa\?(?:.*&)?selectedIssue=([A-Za-z0-9]+-\d+)/, + requiresProviderHost: true, + }, + // GitHub PR commit (must be before generic PR pattern) + { + provider: "github", + type: "github-pr-commit", + regex: + /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[^\/]+\/([a-zA-Z0-9\-_.]+)\/([a-zA-Z0-9\-_.]+)\/pull\/(\d+)\/commits\/([a-f0-9]+)/, + }, + // GitHub PR (with optional /checks, /commits, /files sub-pages) + { + provider: "github", + type: "github-pr", + regex: + /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[^\/]+\/([a-zA-Z0-9\-_.]+)\/([a-zA-Z0-9\-_.]+)\/pull\/(\d+)/, + }, + // GitHub issue + { + provider: "github", + type: "github-issue", + regex: + /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[^\/]+\/([a-zA-Z0-9\-_.]+)\/([a-zA-Z0-9\-_.]+)\/issues\/(\d+)/, + }, + // GitHub commit + { + provider: "github", + type: "github-commit", + regex: + /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[^\/]+\/([a-zA-Z0-9\-_.]+)\/([a-zA-Z0-9\-_.]+)\/commits?\/([a-f0-9]+)/, + }, + // GitHub file/blob + { + provider: "github", + type: "github-file", + regex: + /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[^\/]+\/([a-zA-Z0-9\-_.]+)\/([a-zA-Z0-9\-_.]+)\/blob\/([^\/]+)\/(.+?)(?:#L(\d+)(?:-L(\d+))?)?$/, + }, + // GitHub pulls list + { + provider: "github", + type: "github-pulls-list", + regex: + /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[^\/]+\/([a-zA-Z0-9\-_.]+)\/([a-zA-Z0-9\-_.]+)\/pulls(?:[\/?].*)?$/, + }, + // GitHub releases list + { + provider: "github", + type: "github-releases-list", + regex: + /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[^\/]+\/([a-zA-Z0-9\-_.]+)\/([a-zA-Z0-9\-_.]+)\/releases(?:[\/?].*)?$/, + }, + // GitHub issues list + { + provider: "github", + type: "github-issues-list", + regex: + /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[^\/]+\/([a-zA-Z0-9\-_.]+)\/([a-zA-Z0-9\-_.]+)\/issues(?:\/(?:created_by|assigned)\/[\w.\/-]+)?\/?(?:\?.*)?$/, + }, + // GitHub repo + { + provider: "github", + type: "github-repo", + regex: + /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[^\/]+\/([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?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[^\/]+\/(.+)\/-\/merge_requests\/(\d+)\/diffs\?.*commit_id=([a-f0-9]+)/, + }, + // GitLab merge request + { + provider: "gitlab", + type: "gitlab-mr", + regex: + /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[^\/]+\/(.+)\/-\/merge_requests\/(\d+)/, + }, + // GitLab issue + { + provider: "gitlab", + type: "gitlab-issue", + regex: + /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[^\/]+\/(.+)\/-\/issues\/(\d+)/, + }, + // GitLab work item, treated as an issue + { + provider: "gitlab", + type: "gitlab-issue", + regex: + /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[^\/]+\/(.+)\/-\/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?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[^\/]+\/(.+)\/-\/work_items\/?\?(?:.*&)?show=/, + }, + // GitLab commit + { + provider: "gitlab", + type: "gitlab-commit", + regex: + /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[^\/]+\/(.+)\/-\/commits?\/([a-f0-9]+)/, + }, + // GitLab issues list + { + provider: "gitlab", + type: "gitlab-issues-list", + regex: + /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[^\/]+\/(.+)\/-\/issues\/?(?:\?.*)?$/, + }, + // GitLab merge requests list + { + provider: "gitlab", + type: "gitlab-merges-list", + regex: + /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[^\/]+\/(.+)\/-\/merge_requests\/?(?:\?.*)?$/, + }, + // GitLab project + { + provider: "gitlab", + type: "gitlab-project", + regex: + /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))[^\/]+\/([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?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))linear\.app\/([^\/]+)\/issue\/([A-Z]+-\d+)(?:\/([^\/?#]+))?/, + }, + // Linear project: /team/project/:slug(/:tab)? + { + provider: "linear", + type: "linear-project", + regex: /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))linear\.app\/([^\/]+)\/project\/([^\/]+)/, + }, + // Linear initiative: /team/initiative/:slug(/:tab)? + { + provider: "linear", + type: "linear-initiative", + regex: /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))linear\.app\/([^\/]+)\/initiative\/([^\/]+)/, + }, + // Linear view: /team/view/:id(/:tab)? + { + provider: "linear", + type: "linear-view", + regex: /^https?:\/\/(?![^?#]*(?:\/(?:\.|%2[eE]){1,2}(?:[\/?#]|$)|\\))linear\.app\/([^\/]+)\/view\/([^\/]+)/, + }, +]; + +const MAX_INTEGRATION_LINK_LENGTH = 8192; + +// Provider type -> hostnames it unfurls; "*.example.com" means any subdomain. +export type IntegrationUnfurlHosts = Record; + +function isUnfurlHost(hostname: string, hosts: string[]): boolean { + return hosts.some((host) => + host.startsWith("*.") ? hostname.endsWith(host.slice(1)) : hostname === host, + ); +} + +export function matchIntegrationLink( + url: string | null | undefined, + unfurlHosts?: IntegrationUnfurlHosts, +): { provider: string; type: string; match: RegExpMatchArray } | null { + // Real URLs carry no whitespace; the cap bounds regex backtracking on hostile input. + if ( + typeof url !== "string" || + url.length > MAX_INTEGRATION_LINK_LENGTH || + /\s/.test(url) + ) { + return null; + } + + let hostname = ""; + try { + hostname = new URL(url).hostname.toLowerCase(); + } catch { + return null; + } + + const providerHosts = unfurlHosts ?? {}; + const servesHost = (provider: string) => + !providerHosts[provider] || isUnfurlHost(hostname, providerHosts[provider]); + // A host a provider reports serving (e.g. GitHub Enterprise) is that provider's. + const hostProvider = Object.keys(providerHosts).find( + (provider) => providerHosts[provider] && servesHost(provider), + ); + const publicCodeHostProvider = + hostname === "github.com" || hostname === "www.github.com" + ? "github" + : hostname === "gitlab.com" || hostname === "www.gitlab.com" + ? "gitlab" + : null; + const preferredProvider = hostProvider ?? publicCodeHostProvider; + const candidatePatterns = integrationLinkPatterns.filter( + (pattern) => + (!preferredProvider || pattern.provider === preferredProvider) && + servesHost(pattern.provider) && + (!pattern.requiresProviderHost || pattern.provider === hostProvider), + ); + + for (const pattern of candidatePatterns) { + const match = url.match(pattern.regex); + if (!match) continue; + + const isAmbiguousProjectRoot = + pattern.type === "github-repo" || pattern.type === "gitlab-project"; + if (isAmbiguousProjectRoot && !preferredProvider) continue; + + 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 "azure-devops-work-item": + return describe( + `Work Item #${match[3]}`, + [match[1], match[2] && safeDecode(match[2])].filter(Boolean).join("/"), + ); + case "azure-devops-pr": + return describe( + `Pull Request #${match[4]}`, + [match[1], match[2] && safeDecode(match[2]), safeDecode(match[3])] + .filter(Boolean) + .join("/"), + ); + + 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; + } +} + +// Slashes and control, format (bidi, zero-width) or separator characters could disguise a url's display text. +const UNSAFE_DISPLAY_TEXT = /[\/\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu; + +function encodeUnsafe(text: string): string { + return text.replace(UNSAFE_DISPLAY_TEXT, encodeURIComponent); +} + +function safeDecode(value: string): string { + let decoded = value; + try { + decoded = decodeURIComponent(value); + } catch { + return encodeUnsafe(value); + } + return encodeUnsafe(decoded) === decoded ? decoded : encodeUnsafe(value); +} + +// "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); +} + +// A GitLab work item drawer is encoded 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 00000000..e8b3eaa1 --- /dev/null +++ b/packages/editor-ext/src/lib/integration-link/integration-link.ts @@ -0,0 +1,17 @@ +import { isHttpUrl, sanitizeUrl } from "../utils"; + +// Only web urls survive, kept as given because sanitizeUrl decodes percent-escapes. +export function sanitizeIntegrationUrl(url: string | null | undefined): string { + const trimmed = url?.trim() ?? ""; + return isHttpUrl(trimmed) && isHttpUrl(sanitizeUrl(trimmed)) ? trimmed : ""; +} + +export interface IntegrationLinkOptions { + HTMLAttributes: Record; + view: any; +} + +export interface IntegrationLinkAttributes { + url: string; + provider: string; +} 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 00000000..86f08cc9 --- /dev/null +++ b/packages/editor-ext/src/lib/integration-link/integration-mention.ts @@ -0,0 +1,100 @@ +import { Node, mergeAttributes } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +import { + IntegrationLinkAttributes, + IntegrationLinkOptions, + sanitizeIntegrationUrl, +} 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) => + sanitizeIntegrationUrl(element.getAttribute("data-url")), + renderHTML: (attributes: IntegrationLinkAttributes) => ({ + "data-url": sanitizeIntegrationUrl(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 safeUrl = sanitizeIntegrationUrl(HTMLAttributes["data-url"]); + + return [ + "span", + mergeAttributes( + { "data-type": this.name }, + this.options.HTMLAttributes, + HTMLAttributes, + ), + ["a", { href: safeUrl, target: "_blank", rel: "noopener" }, safeUrl], + ]; + }, + + renderText({ node }) { + return node.attrs.url; + }, + + addCommands() { + return { + setIntegrationMention: + (attrs) => + ({ commands }) => { + return commands.insertContent({ + type: this.name, + attrs: { + ...attrs, + url: sanitizeIntegrationUrl(attrs.url), + }, + }); + }, + }; + }, + + addNodeView() { + return ReactNodeViewRenderer(this.options.view); + }, +}); diff --git a/packages/editor-ext/src/lib/link.ts b/packages/editor-ext/src/lib/link.ts index 38ff1da6..5fca354b 100644 --- a/packages/editor-ext/src/lib/link.ts +++ b/packages/editor-ext/src/lib/link.ts @@ -15,6 +15,15 @@ export const LinkExtension = TiptapLink.extend({ renderHTML: (attributes) => attributes.internal ? { 'data-internal': 'true' } : {}, }, + integrationProvider: { + default: null, + parseHTML: (element: HTMLElement) => + element.getAttribute('data-integration-provider'), + renderHTML: (attributes) => + attributes.integrationProvider + ? { 'data-integration-provider': attributes.integrationProvider } + : {}, + }, }; }, diff --git a/packages/editor-ext/src/lib/prosemirror-docx/schema.ts b/packages/editor-ext/src/lib/prosemirror-docx/schema.ts index c7b2d140..abd8da7f 100644 --- a/packages/editor-ext/src/lib/prosemirror-docx/schema.ts +++ b/packages/editor-ext/src/lib/prosemirror-docx/schema.ts @@ -1,5 +1,13 @@ -import { FootnoteReferenceRun, HeadingLevel, Paragraph, ShadingType } from 'docx'; +import { + ExternalHyperlink, + FootnoteReferenceRun, + HeadingLevel, + Paragraph, + ShadingType, + TextRun, +} from 'docx'; import { Node } from 'prosemirror-model'; +import { sanitizeIntegrationUrl } from '../integration-link/integration-link'; import { DocxSerializerAsync, MarkSerializer, @@ -64,6 +72,27 @@ const renderEmbedLine: NodeSerializerAsync[string] = (state, node) => { state.closeBlock(node); }; +const renderIntegrationMention: NodeSerializerAsync[string] = (state, node) => { + const url = sanitizeIntegrationUrl(node.attrs?.url); + if (!url) return; + state.current.push( + new ExternalHyperlink({ + link: url, + children: [new TextRun({ text: url, style: 'Hyperlink' })], + }), + ); +}; + +const renderIntegrationCard: NodeSerializerAsync[string] = async ( + state, + node, + parent, + index, +) => { + await renderIntegrationMention(state, node, parent, index); + state.closeBlock(node); +}; + export const defaultAsyncNodes: NodeSerializerAsync = { text(state, node) { state.text(node.text ?? ''); @@ -137,6 +166,8 @@ export const defaultAsyncNodes: NodeSerializerAsync = { attachment: renderFileLine, embed: renderEmbedLine, youtube: renderEmbedLine, + integrationCard: renderIntegrationCard, + integrationMention: renderIntegrationMention, async callout(state, node) { await state.renderContent(node, { style: 'IntenseQuote' }); }, diff --git a/packages/editor-ext/src/lib/utils.ts b/packages/editor-ext/src/lib/utils.ts index 8d03577c..9aff4b87 100644 --- a/packages/editor-ext/src/lib/utils.ts +++ b/packages/editor-ext/src/lib/utils.ts @@ -391,6 +391,16 @@ export function sanitizeUrl(url: string | undefined): string { return sanitized === "about:blank" ? "" : sanitized; } +export function isHttpUrl(url: string | null | undefined): boolean { + if (!url) return false; + try { + const { protocol } = new URL(url); + return protocol === "http:" || protocol === "https:"; + } catch { + return false; + } +} + export function isInternalFileUrl(url: string | undefined): boolean { if (!url) return false; const normalized = url.trim();