Compare commits

...
Author SHA1 Message Date
Philipinho 0c6f2299b2 Merge branch 'main' into feat/integrations 2026-08-13 01:33:47 +01:00
Philipinho 9bd5ddee69 fix(integration): show disconnect loader only on the affected row 2026-08-10 03:43:45 +01:00
Philipinho 9f279d191c wip 2026-08-10 03:41:54 +01:00
Philipinho 311b511afb fix(integration): send provider and oauth requests through configured proxy 2026-08-10 03:24:19 +01:00
Philipinho bf56b7c2c2 chore(integration): document cache purge intent 2026-08-09 15:58:34 +01:00
Philipinho 15cbcfb78d fix(integration): purge per-user unfurl cache on disconnect 2026-08-09 15:51:53 +01:00
Philipinho 2829491b2d fix(integration): refresh connections list after disconnect 2026-08-09 15:50:03 +01:00
Philipinho 6eb0cf2b31 fix(integration): drop cached unfurls when disconnecting an integration 2026-08-09 15:35:37 +01:00
Philipinho a351ecd77a fix(editor-ext): stop persisting unfurl data on integration link nodes 2026-08-09 15:35:05 +01:00
Philipinho 99bcf3da9d fix(integration): resolve unfurls per viewer at render time 2026-08-09 15:18:43 +01:00
Philipinho 16f7139e6f wip 2026-08-09 15:01:52 +01:00
Philipinho 21665fc0e7 fix(integration): thread requester identity through unfurl for per-user authorization 2026-08-08 23:19:23 +01:00
Philipinho c29249fe13 fix(slack): enforce space create permission for page creation handlers 2026-08-08 23:14:03 +01:00
Philipinho f471335549 feat(page): add validateCanCreate space permission check 2026-08-08 23:13:58 +01:00
Philipinho fed7d78495 wip 2026-08-08 22:25:33 +01:00
Philipinho 92b6513f39 feat(slack): audit comment replies posted from notification DMs 2026-08-08 20:50:24 +01:00
Philipinho 903151877a feat(audit): stop excluding comment creation from audit logs 2026-08-08 20:50:24 +01:00
Philipinho a740fb03f8 feat(slack): quote comment bodies and reply from notification DMs 2026-08-08 20:23:08 +01:00
Philipinho 59ec9ed1e6 fix(slack): use workspace-canonical urls for cloud multi-tenancy 2026-08-08 19:38:52 +01:00
Philipinho c37e428808 feat(slack): unfurl links based on the posting user's page access 2026-08-08 18:44:21 +01:00
Philipinho 618e1ecfc3 feat(slack): trim link unfurls to title and metadata only 2026-08-08 18:10:42 +01:00
Philipinho fe61b2e854 feat(slack): DM verification and approval notifications 2026-08-08 11:56:40 +01:00
Philipinho edf4c52b81 feat(slack): docmost notify DM notifications 2026-08-08 03:40:44 +01:00
Philipinho 6d17224cae feat(integration): add kind-filtered user link lookup by user id 2026-08-08 02:22:05 +01:00
Philipinho 9555537f27 feat(notification): emit notification.created event and add slack notify queue constants 2026-08-08 01:50:10 +01:00
Philipinho 139cc82326 Merge branch 'main' into feat/integrations
# Conflicts:
#	apps/client/src/App.tsx
#	apps/server/src/ee
#	apps/server/src/integrations/queue/constants/queue.constants.ts
#	apps/server/src/integrations/queue/queue.module.ts
#	packages/editor-ext/src/index.ts
2026-08-07 01:13:39 +01:00
Philipinho 3e3e09133b chore(ee): bump submodule for home view re-publish on link 2026-05-23 19:03:41 +01:00
Philipinho 4c266f832a chore(ee): bump submodule for home-search submit handler 2026-05-23 19:00:55 +01:00
Philipinho f4bae91dd0 chore(ee): bump submodule for home-search handler 2026-05-23 18:58:22 +01:00
Philipinho aee6781a5a chore(ee): bump submodule for home-search modal builder 2026-05-23 18:55:56 +01:00
Philipinho eaac79f25d chore(ee): bump submodule for create-document submit handler 2026-05-23 18:53:44 +01:00
Philipinho 49f5aaa880 chore(ee): bump submodule for home-create-doc handler 2026-05-23 18:46:30 +01:00
Philipinho 4e5d82c847 chore(ee): bump submodule for create-document modal builder 2026-05-23 18:44:27 +01:00
Philipinho 407d86c65c chore(ee): bump submodule for app_home_opened handler 2026-05-23 18:42:48 +01:00
Philipinho 1690691de0 chore(ee): bump submodule for slack home view block builder 2026-05-23 18:38:47 +01:00
Philipinho 4db5281c11 feat(slack): wire /docmost ask queue and bump submodule
- Add SLACK_ASK queue name and job constant for AI search backgrounding.
- Bump ee submodule to pick up new Slack handlers and converters.
2026-05-23 18:35:13 +01:00
Philipinho 2d74455c2b refactor(integrations): consolidate integrations schema into baseline migration 2026-05-23 13:11:28 +01:00
Philipinho 6a870adec9 fix(integrations): partial unique index so user-link doesn't clobber workspace row 2026-05-23 13:06:47 +01:00
Philipinho b4c917ac07 feat(integrations): defer workspace-scoped install until OAuth succeeds 2026-05-23 12:38:43 +01:00
Philipinho 91a2abd8d3 feat(integrations): route OAuth callbacks back to originating workspace host 2026-05-23 12:32:18 +01:00
Philipinho 2f313187ab feat(integrations): one-step install for workspace-scoped providers 2026-05-23 12:22:26 +01:00
Philipinho 2c80459674 chore(ee): bump submodule for completeLink workspace check 2026-05-23 12:08:51 +01:00
Philipinho a3897e1485 fix(integrations): don't wipe workspace bot row on per-user disconnect 2026-05-23 12:08:49 +01:00
Philipinho 305f2be527 chore(ee): bump submodule for Disconnect fix 2026-05-23 12:01:38 +01:00
Philipinho 3e86bef2b4 chore(ee): bump submodule for Slack disconnect button handler 2026-05-23 11:55:13 +01:00
Philipinho 9603ff4287 chore(ee): bump submodule for new-page modal and app_uninstalled handler 2026-05-23 05:17:20 +01:00
Philipinho 9696e06635 chore(ee): bump submodule for /docmost save and add-to-page shortcut 2026-05-23 05:10:03 +01:00
Philipinho 4c7c3ac44d chore(ee): bump submodule for page-picker review fixes 2026-05-23 05:06:44 +01:00
Philipinho 02d20c1ab6 chore(ee): bump submodule for page-picker submit and options handlers 2026-05-23 04:57:46 +01:00
Philipinho 013fa923e0 chore(ee): bump submodule for mrkdwn converter, page writer, picker blocks 2026-05-23 04:53:14 +01:00
Philipinho 872321c58c chore(ee): bump submodule for Slack workspace connectionScope 2026-05-23 04:35:59 +01:00
Philipinho 0ab6ae86e3 feat(integrations): add connectionScope (workspace|user) to OAuth flow 2026-05-23 04:35:59 +01:00
Philipinho 31043a9d98 chore(ee): bump submodule for SlackProvider.onConnected and SkipTransform 2026-05-23 04:17:21 +01:00
Philipinho 05bd04d87b feat(integrations): pass integrationId and workspaceId to provider.onConnected 2026-05-23 04:17:19 +01:00
Philipinho 677b5f95e0 chore(ee): bump submodule for recents, favs, search commands 2026-05-23 03:23:22 +01:00
Philipinho e44a0cd0e0 feat(search): add titlesOnly option to searchPage for efficient title-only searches 2026-05-23 03:22:29 +01:00
Philipinho a3755a2469 chore(ee): bump submodule for /docmost help command 2026-05-23 03:15:25 +01:00
Philipinho ba81f7143b chore(ee): bump submodule for LinkSharedHandler 2026-05-23 03:11:12 +01:00
Philipinho a14e239067 chore(ee): bump submodule for SlackUrlParser and unfurl blocks 2026-05-23 03:06:07 +01:00
Philipinho 9a225c07c3 feat(page): add canPageBeUnfurled predicate for Slack link previews 2026-05-23 03:05:05 +01:00
Philipinho 19107c3f8c chore(ee): bump submodule for expanded Slack OAuth scopes 2026-05-23 03:02:36 +01:00
Philipinho ddd309bf07 chore(ee): bump submodule for outbound channel-notif removal 2026-05-23 02:53:33 +01:00
Philipinho 68bc936e14 refactor(integrations): drop slackSettingsSchema and related types 2026-05-23 02:52:31 +01:00
Philipinho 03926e9f77 chore(ee): bump submodule for SlackController, processor, module wiring 2026-05-23 02:36:13 +01:00
Philipinho 6dce0e4eb5 feat(integrations): add SLACK_INBOUND queue and excluded auth paths 2026-05-23 02:35:00 +01:00
Philipinho d68cff0ee9 chore(ee): bump submodule for handler interface and dispatchers 2026-05-23 02:30:41 +01:00
Philipinho 75c79cbe9e feat(slack): add browser-side identity link confirmation page 2026-05-23 02:23:11 +01:00
Philipinho 32020c7c89 chore(ee): bump submodule for Slack link controller 2026-05-23 02:19:10 +01:00
Philipinho 7ff1e87a9a chore(ee): bump submodule for SlackIdentityResolver 2026-05-23 02:17:13 +01:00
Philipinho f3b7b1acc7 feat(integrations): add upsertUserLink for per-user identity bindings 2026-05-23 02:15:51 +01:00
Philipinho c8df56ceb2 chore(ee): bump submodule for link-prompt blocks 2026-05-23 02:12:02 +01:00
Philipinho d64562ae14 chore(ee): bump submodule for slack-link-state helper 2026-05-23 02:10:38 +01:00
Philipinho c2063e6c3d fix(env): match INTEGRATION_{TYPE}_* env-var convention for Slack signing secret 2026-05-23 02:08:07 +01:00
Philipinho 0d58b88db8 fix(integrations): don't filter per-user connections out of existing lookups 2026-05-23 02:04:47 +01:00
Philipinho 7e1bef8b7e feat(integrations): add kind-aware helpers for workspace/user connections 2026-05-23 02:02:35 +01:00
Philipinho d53054a267 feat(integrations): add findByTypeAndSettingsField for JSONB lookup 2026-05-23 02:01:06 +01:00
Philipinho a1f6aa1e43 feat(integrations): pass raw token-response body to provider.onConnected 2026-05-23 01:58:26 +01:00
Philipinho 4d8a5254f9 chore(ee): bump submodule for SlackSignatureGuard comment 2026-05-23 01:57:18 +01:00
Philipinho ff567e0cb4 chore(ee): bump submodule for SlackSignatureGuard 2026-05-23 01:51:21 +01:00
Philipinho 2cedf9ea6c feat(env): add SLACK_SIGNING_SECRET getter on EnvironmentService 2026-05-23 01:50:46 +01:00
Philipinho d1b46e383a chore(ee): bump submodule for SlackApiClient review fixes 2026-05-23 01:47:41 +01:00
Philipinho ca4207ef0a chore(ee): bump submodule for SlackApiClient 2026-05-23 01:40:33 +01:00
Philipinho 88888e1dc7 fix(integrations): scope Slack-kind backfill, fix index where-clause types 2026-05-23 01:35:08 +01:00
Philipinho cc17faf835 feat(integrations): add kind discriminator and nullable token to connections 2026-05-23 01:22:32 +01:00
Philipinho 392aa0aef9 remove zod 2026-05-22 23:00:14 +01:00
Philipinho af9e777009 merge main 2026-05-22 13:56:51 +01:00
Philipinho 8a0217527f token refresh, and filter inactive connections 2026-02-23 02:48:42 +00:00
Philipinho 5c3e715a10 refactor design 2026-02-23 02:33:03 +00:00
Philipinho 04f218833f UI 2026-02-22 23:58:09 +00:00
Philipinho 4664b6ac1d icons 2026-02-22 23:47:13 +00:00
Philipinho 602aaeb641 wip 2026-02-22 23:15:47 +00:00
Philipinho 81c6fb0d56 fix 2026-02-22 22:32:40 +00:00
Philipinho aeb30ad096 feat: integrations 2026-02-22 20:14:33 +00:00
90 changed files with 6819 additions and 6 deletions
+11
View File
@@ -51,6 +51,17 @@ DRAWIO_URL=
# Gotenberg URL for server-side PDF export
GOTENBERG_URL=
# Integration OAuth apps (create one per provider you enable)
INTEGRATION_GITHUB_CLIENT_ID=
INTEGRATION_GITHUB_CLIENT_SECRET=
# Set only for GitHub Enterprise Server, e.g https://github.example.com
INTEGRATION_GITHUB_BASE_URL=
INTEGRATION_GITLAB_CLIENT_ID=
INTEGRATION_GITLAB_CLIENT_SECRET=
# Set only for self-hosted GitLab, e.g https://gitlab.example.com
INTEGRATION_GITLAB_BASE_URL=
DISABLE_TELEMETRY=false
# Allow other sites to embed Docmost in an iframe.
@@ -14,6 +14,7 @@
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.",
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Are you sure you want to remove this user from the space? The user will lose all access to this space.",
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Are you sure you want to restore this version? Any changes not versioned will be lost.",
"Assigned to {{name}}": "Assigned to {{name}}",
"Can become members of groups and spaces in workspace": "Can become members of groups and spaces in workspace",
"Can create and edit pages in space.": "Can create and edit pages in space.",
"Can edit": "Can edit",
@@ -22,6 +23,7 @@
"Can view": "Can view",
"Can view pages in space but not edit.": "Can view pages in space but not edit.",
"Cancel": "Cancel",
"Card": "Card",
"Change email": "Change email",
"Change password": "Change password",
"Change photo": "Change photo",
@@ -30,6 +32,7 @@
"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 update": "Connect to {{name}} to update",
"Copy as Markdown": "Copy as Markdown",
"Copy link": "Copy link",
"Create": "Create",
@@ -41,6 +44,12 @@
"Dark": "Dark",
"Date": "Date",
"Delete": "Delete",
"Initiative": "Initiative",
"Last modified by {{name}}": "Last modified by {{name}}",
"Open in Slack": "Open in Slack",
"Paid": "Paid",
"Paste as": "Paste as",
"Project": "Project",
"Remove from page": "Remove from page",
"Base options": "Base options",
"Delete group": "Delete group",
@@ -150,6 +159,8 @@
"page": "page",
"Page deleted successfully": "Page deleted successfully",
"Page history": "Page history",
"replies": "replies",
"reply": "reply",
"Select version": "Select version",
"Highlight changes": "Highlight changes",
"Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.",
@@ -184,6 +195,8 @@
"Invitation sent": "Invitation sent",
"Settings": "Settings",
"Setup workspace": "Setup workspace",
"show less": "show less",
"show more": "show more",
"Sign In": "Sign In",
"Sign Up": "Sign Up",
"Slug": "Slug",
@@ -206,7 +219,9 @@
"Theme": "Theme",
"To change your email, you have to enter your password and new email.": "To change your email, you have to enter your password and new email.",
"Toggle full page width": "Toggle full page width",
"Toggle {{name}} integration": "Toggle {{name}} integration",
"Unable to import pages. Please try again.": "Unable to import pages. Please try again.",
"Unassigned": "Unassigned",
"untitled": "untitled",
"Untitled": "Untitled",
"Updated successfully": "Updated successfully",
+6
View File
@@ -38,6 +38,9 @@ import SpaceTrash from "@/pages/space/space-trash.tsx";
import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
import Integrations from "@/features/integration/pages/integrations.tsx";
import Connections from "@/features/integration/pages/connections.tsx";
import SlackLinkPage from "@/features/integration/pages/slack-link.tsx";
import BasePage from "@/ee/base/pages/base-page.tsx";
import AuditLogs from "@/ee/audit/pages/audit-logs.tsx";
import VerifiedPages from "@/ee/page-verification/pages/verified-pages.tsx";
@@ -87,6 +90,7 @@ export default function App() {
<Route path={"/pdf-render/:pageId"} element={<PdfRenderPage />} />
<Route path={"/share/:shareId"} element={<ShareRedirect />} />
<Route path={"/p/:pageSlug"} element={<PageRedirect />} />
<Route path={"/integrations/slack/link"} element={<SlackLinkPage />} />
<Route element={<Layout />}>
<Route path={"/home"} element={<Home />} />
@@ -116,6 +120,7 @@ export default function App() {
element={<AccountPreferences />}
/>
<Route path={"account/api-keys"} element={<UserApiKeys />} />
<Route path={"account/connections"} element={<Connections />} />
<Route path={"workspace"} element={<WorkspaceSettings />} />
<Route path={"members"} element={<WorkspaceMembers />} />
<Route path={"api-keys"} element={<WorkspaceApiKeys />} />
@@ -128,6 +133,7 @@ export default function App() {
<Route path={"ai/mcp"} element={<AiSettings />} />
<Route path={"audit"} element={<AuditLogs />} />
<Route path={"verifications"} element={<VerifiedPages />} />
<Route path={"integrations"} element={<Integrations />} />
{!isCloud() && <Route path={"license"} element={<License />} />}
{isCloud() && <Route path={"billing"} element={<Billing />} />}
</Route>
@@ -0,0 +1,22 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function GithubIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
style={{ width: rem(size), height: rem(size) }}
>
<path
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
d="M256 6.3C114.6 6.3 0 120.9 0 262.3c0 113.3 73.3 209 175 242.9 12.8 2.2 17.6-5.4 17.6-12.2 0-6.1-.3-26.2-.3-47.7-64.3 11.8-81-15.7-86.1-30.1-2.9-7.4-15.4-30.1-26.2-36.2-9-4.8-21.8-16.6-.3-17 20.2-.3 34.6 18.6 39.4 26.2 23 38.7 59.8 27.8 74.6 21.1 2.2-16.6 9-27.8 16.3-34.2-57-6.4-116.5-28.5-116.5-126.4 0-27.8 9.9-50.9 26.2-68.8-2.6-6.4-11.5-32.6 2.6-67.8 0 0 21.4-6.7 70.4 26.2 20.5-5.8 42.2-8.6 64-8.6s43.5 2.9 64 8.6c49-33.3 70.4-26.2 70.4-26.2 14.1 35.2 5.1 61.4 2.6 67.8 16.3 17.9 26.2 40.6 26.2 68.8 0 98.2-59.8 120-116.8 126.4 9.3 8 17.3 23.4 17.3 47.4 0 34.2-.3 61.8-.3 70.4 0 6.7 4.8 14.7 17.6 12.2C438.7 471.3 512 375.3 512 262.3c0-141.4-114.6-256-256-256"
/>
</svg>
);
}
@@ -0,0 +1,32 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function GitlabIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="93.97 97.52 192.05 184.99"
style={{ width: rem(size), height: rem(size) }}
>
<path
fill="#e24329"
d="m282.83 170.73-.27-.69-26.14-68.22a6.8 6.8 0 0 0-2.69-3.24 7 7 0 0 0-8 .43 7 7 0 0 0-2.32 3.52l-17.65 54h-71.47l-17.65-54a6.86 6.86 0 0 0-2.32-3.53 7 7 0 0 0-8-.43 6.87 6.87 0 0 0-2.69 3.24L97.44 170l-.26.69a48.54 48.54 0 0 0 16.1 56.1l.09.07.24.17 39.82 29.82 19.7 14.91 12 9.06a8.07 8.07 0 0 0 9.76 0l12-9.06 19.7-14.91 40.06-30 .1-.08a48.56 48.56 0 0 0 16.08-56.04"
/>
<path
fill="#fc6d26"
d="m282.83 170.73-.27-.69a88.3 88.3 0 0 0-35.15 15.8L190 229.25c19.55 14.79 36.57 27.64 36.57 27.64l40.06-30 .1-.08a48.56 48.56 0 0 0 16.1-56.08"
/>
<path
fill="#fca326"
d="m153.43 256.89 19.7 14.91 12 9.06a8.07 8.07 0 0 0 9.76 0l12-9.06 19.7-14.91S209.55 244 190 229.25c-19.55 14.75-36.57 27.64-36.57 27.64"
/>
<path
fill="#fc6d26"
d="M132.58 185.84A88.2 88.2 0 0 0 97.44 170l-.26.69a48.54 48.54 0 0 0 16.1 56.1l.09.07.24.17 39.82 29.82L190 229.21Z"
/>
</svg>
);
}
@@ -0,0 +1,28 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function GoogleDocsIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 47 65"
style={{ width: rem(size), height: rem(size) }}
>
<path
fill="#4285F4"
d="M29.375 0H4.406C1.983 0 0 1.994 0 4.432v56.136C0 63.006 1.983 65 4.406 65h38.188C45.017 65 47 63.006 47 60.568v-42.84L36.719 10.34z"
/>
<path
fill="#A1C2FA"
d="M29.375 0v13.295c0 2.449 1.972 4.432 4.406 4.432H47z"
/>
<path
fill="#F1F1F1"
d="M11.75 47.273h23.5v-2.955h-23.5zm0 5.909h17.625v-2.955H11.75zm0-20.682v2.955h23.5V32.5zm0 8.864h23.5v-2.955h-23.5z"
/>
</svg>
);
}
+8 -2
View File
@@ -1,10 +1,16 @@
export { AirtableIcon } from "./airtable-icon.tsx";
export { FigmaIcon } from "./figma-icon.tsx";
export { GithubIcon } from "./github-icon.tsx";
export { GitlabIcon } from "./gitlab-icon.tsx";
export { GoogleDocsIcon } from "./google-docs-icon.tsx";
export { GoogleDriveIcon } from "./google-drive-icon.tsx";
export { GoogleSheetsIcon } from "./google-sheets-icon.tsx";
export { JiraIcon } from "./jira-icon.tsx";
export { LinearIcon } from "./linear-icon.tsx";
export { TypeformIcon } from "./typeform-icon.tsx";
export { VimeoIcon } from "./vimeo-icon.tsx";
export { MiroIcon } from "./miro-icon.tsx";
export { GoogleDriveIcon } from "./google-drive-icon.tsx";
export { GoogleSheetsIcon } from "./google-sheets-icon.tsx";
export { SlackIcon } from "./slack-icon.tsx";
export { FramerIcon } from "./framer-icon.tsx";
export { LoomIcon } from "./loom-icon.tsx";
export { YoutubeIcon } from "./youtube-icon.tsx";
@@ -0,0 +1,38 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function JiraIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
style={{ width: rem(size), height: rem(size) }}
>
<defs>
<linearGradient id="jira-a" x1="380.896" x2="279.145" y1="390.609" y2="282.574" gradientTransform="matrix(1 0 0 -1 0 514)" gradientUnits="userSpaceOnUse">
<stop offset=".176" stopColor="#0052cc" />
<stop offset="1" stopColor="#2684ff" />
</linearGradient>
<linearGradient id="jira-b" x1="265.965" x2="148.117" y1="270.661" y2="152.607" gradientTransform="matrix(1 0 0 -1 0 514)" gradientUnits="userSpaceOnUse">
<stop offset=".176" stopColor="#0052cc" />
<stop offset="1" stopColor="#2684ff" />
</linearGradient>
</defs>
<path
fill="#2684ff"
d="M490.6 7.4H243.9c0 59.6 49.9 108 111.2 108h45.6v42.2c0 59.6 49.9 108 111.2 108V28.1c.1-11.7-9.2-20.7-21.3-20.7"
/>
<path
fill="url(#jira-a)"
d="M368.7 126.5H121.9c0 59.6 49.9 108 111.2 108h45.6v42.9c0 59.6 49.9 108 111.2 108V147.3c.2-11.1-9.1-20.8-21.2-20.8"
/>
<path
fill="url(#jira-b)"
d="M246.7 246.3H0c0 59.6 49.9 108 111.2 108h45.6v42.2c0 59.6 49.9 108 111.2 108V267.1c.1-11.8-9.9-20.8-21.3-20.8"
/>
</svg>
);
}
@@ -0,0 +1,21 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function LinearIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 100 100"
style={{ width: rem(size), height: rem(size) }}
>
<path
fill="currentColor"
d="M1.22541 61.5228c-.2225-.9485.90748-1.5459 1.59638-.857L39.3342 97.1782c.6889.6889.0915 1.8189-.857 1.5964C20.0515 94.4522 5.54779 79.9485 1.22541 61.5228ZM.00189135 46.8891c-.01764375.2833.08887215.5599.28957165.7606L52.3503 99.7085c.2007.2007.4773.3075.7606.2896 2.3692-.1476 4.6938-.46 6.9624-.9259.7645-.157 1.0301-1.0963.4782-1.6481L2.57595 39.4485c-.55186-.5519-1.49117-.2863-1.648174.4782-.465915 2.2686-.77832 4.5932-.92588465 6.9624ZM4.21093 29.7054c-.16649.3738-.08169.8106.20765 1.1l64.77602 64.776c.2894.2894.7262.3742 1.1.2077 1.7861-.7956 3.5171-1.6927 5.1855-2.684.5521-.328.6373-1.0867.1832-1.5407L8.43566 24.3367c-.45409-.4541-1.21271-.3689-1.54074.1832-.99132 1.6684-1.88843 3.3994-2.68399 5.1855ZM12.6587 18.074c-.3701-.3701-.393-.9637-.0443-1.3541C21.7795 6.45931 35.1114 0 49.9519 0 77.5927 0 100 22.4073 100 50.0481c0 14.8405-6.4593 28.1724-16.7199 37.3375-.3903.3487-.984.3258-1.3542-.0443L12.6587 18.074Z"
/>
</svg>
);
}
+11 -2
View File
@@ -8,11 +8,20 @@ export function MiroIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
viewBox="3 2 395 395"
style={{ width: rem(size), height: rem(size) }}
>
<path
d="M17.392 0H13.9L17 4.808 10.444 0H6.949l3.102 6.3L3.494 0H0l3.05 8.131L0 24h3.494L10.05 6.985 6.949 24h3.494L17 5.494 13.899 24h3.493L24 3.672 17.392 0z" />
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"
/>
<path
fill="#1C1C1E"
fillRule="evenodd"
clipRule="evenodd"
d="M265.573 77.3491H229.74L259.629 129.85L193.906 77.3491H158.072L190.934 141.468L122.238 77.3491H86.4041L122.238 159.031L86.4041 322.377H122.238L190.934 147.396L158.072 322.377H193.906L259.629 135.693L229.74 322.377H265.573L331.297 118.232L265.573 77.4335V77.3491Z"
/>
</svg>
);
}
@@ -0,0 +1,32 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function SlackIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
style={{ width: rem(size), height: rem(size) }}
>
<path
d="M107.9 323.6c0 29.7-24 53.8-53.8 53.8S.3 353.4.3 323.6c0-29.7 24-53.8 53.8-53.8h53.8zm26.9 0c0-29.7 24-53.8 53.8-53.8s53.8 24 53.8 53.8V458c0 29.7-24 53.8-53.8 53.8s-53.8-24-53.8-53.8z"
fill="#e01e5a"
/>
<path
d="M188.6 107.7c-29.7 0-53.8-24-53.8-53.8S158.8.1 188.6.1s53.8 24 53.8 53.8v53.8zm0 27.3c29.7 0 53.8 24 53.8 53.8s-24 53.8-53.8 53.8H53.8C24 242.6 0 218.5 0 188.8S24 135 53.8 135z"
fill="#36c5f0"
/>
<path
d="M404.1 188.8c0-29.7 24-53.8 53.8-53.8s53.8 24 53.8 53.8-24 53.8-53.8 53.8h-53.8zm-26.9 0c0 29.7-24 53.8-53.8 53.8-29.7 0-53.8-24-53.8-53.8V54c0-29.7 24-53.8 53.8-53.8s53.8 24 53.8 53.8z"
fill="#2eb67d"
/>
<path
d="M323.4 404.3c29.7 0 53.8 24 53.8 53.8 0 29.7-24 53.8-53.8 53.8-29.7 0-53.8-24-53.8-53.8v-53.8zm0-26.9c-29.7 0-53.8-24-53.8-53.8s24-53.8 53.8-53.8h134.8c29.7 0 53.8 24 53.8 53.8 0 29.7-24 53.8-53.8 53.8z"
fill="#ecb22e"
/>
</svg>
);
}
@@ -13,6 +13,7 @@ import {
IconKey,
IconWorld,
IconSparkles,
IconPlug,
IconHistory,
IconShieldCheck,
} from "@tabler/icons-react";
@@ -74,6 +75,11 @@ const groupedData: DataGroup[] = [
path: "/settings/account/api-keys",
feature: Feature.API_KEYS,
},
{
label: "Connections",
icon: IconPlug,
path: "/settings/account/connections",
},
],
},
{
@@ -125,6 +131,12 @@ const groupedData: DataGroup[] = [
role: "owner",
env: "selfhosted",
},
{
label: "Integrations",
icon: IconPlug,
path: "/settings/integrations",
role: "admin",
},
],
},
{
+1
View File
@@ -22,4 +22,5 @@ export const Feature = {
PERSONAL_SPACES: 'spaces:personal',
DOCX_EXPORT: 'export:docx',
BASES: 'bases',
INTEGRATIONS: 'integrations',
} as const;
@@ -5,6 +5,10 @@ import { uploadPdfAction } from "../pdf/upload-pdf-action";
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
import { Editor } from "@tiptap/core";
import { matchIntegrationLink } from "@docmost/editor-ext";
import { integrationPasteMenuKey } from "@/features/editor/extensions/integration-paste-menu";
import { queryClient } from "@/main.tsx";
import { Integration } from "@/features/integration/types/integration.types";
import {
getAttachmentInfo,
uploadFile,
@@ -22,6 +26,16 @@ const ATTACHMENT_NODE_TYPES = [
const ATTACHMENT_URL_RE = /\/api\/files\/([0-9a-f-]+)\//;
// Only installed providers get card treatment; anything else pastes as an
// ordinary link. The cache is prefetched when the page editor mounts;
// a cold cache also means ordinary link.
function isIntegrationInstalled(provider: string): boolean {
const installed = queryClient.getQueryData<Integration[]>([
"installed-integrations",
]);
return Boolean(installed?.some((i) => i.type === provider));
}
export const handlePaste = (
editor: Editor,
event: ClipboardEvent,
@@ -30,6 +44,59 @@ export const handlePaste = (
) => {
const clipboardData = event.clipboardData.getData("text/plain");
const integrationMatch = matchIntegrationLink(clipboardData.trim());
if (
integrationMatch &&
editor.state.selection.empty &&
isIntegrationInstalled(integrationMatch.provider)
) {
event.preventDefault();
const pastedUrl = clipboardData.trim();
editor
.chain()
.focus()
.setIntegrationLink({
url: pastedUrl,
provider: integrationMatch.provider,
})
// Anchor the "Paste as" menu to the inserted node, in the SAME
// transaction: BubbleMenu ignores meta-only transactions (it only
// re-evaluates when the doc or selection changed). Locate the node via
// the range this transaction's own steps touched, never by url, so a
// duplicate of the same link elsewhere in the doc can't steal the menu.
.command(({ tr }) => {
let start: number | null = null;
let end: number | null = null;
tr.mapping.maps.forEach((map, index) => {
const rest = tr.mapping.slice(index + 1);
map.forEach((_oldStart, _oldEnd, newStart, newEnd) => {
const mappedStart = rest.map(newStart, -1);
const mappedEnd = rest.map(newEnd, 1);
start = start === null ? mappedStart : Math.min(start, mappedStart);
end = end === null ? mappedEnd : Math.max(end, mappedEnd);
});
});
if (start === null || end === null) return true;
let pastedPos: number | null = null;
tr.doc.nodesBetween(
start,
Math.min(end, tr.doc.content.size),
(node, pos) => {
if (node.type.name === "integrationLink") {
pastedPos = pos;
}
},
);
if (pastedPos !== null) {
tr.setMeta(integrationPasteMenuKey, { pos: pastedPos });
}
return true;
})
.run();
return true;
}
if (INTERNAL_LINK_REGEX.test(clipboardData)) {
// we have to do this validation here to allow the default link extension to takeover if needs be
event.preventDefault();
@@ -0,0 +1,49 @@
// Light-scheme text per hue, measured to pass 4.5:1 on the light-variant
// badge background; hexes are darkened .9 shades for hues whose scale
// never gets dark enough.
const BADGE_TEXT_LIGHT: Record<string, string> = {
dark: "var(--mantine-color-dark-9)",
gray: "var(--mantine-color-gray-9)",
red: "var(--mantine-color-red-9)",
pink: "var(--mantine-color-pink-9)",
grape: "var(--mantine-color-grape-9)",
violet: "var(--mantine-color-violet-9)",
indigo: "var(--mantine-color-indigo-9)",
blue: "var(--mantine-color-blue-9)",
cyan: "var(--mantine-color-cyan-9)",
teal: "var(--mantine-color-teal-9)",
green: "#277c38",
lime: "#4e7e0b",
yellow: "#ad5900",
orange: "#c3410e",
};
export function badgeTextColor(color?: string): string | undefined {
if (!color) return undefined;
const light = BADGE_TEXT_LIGHT[color];
if (!light) return undefined;
// Dark scheme keeps Mantine's own light-variant text.
return `light-dark(${light}, var(--mantine-color-${color}-light-color))`;
}
export function toBadgeColor(raw?: string): string {
if (!raw) return "gray";
const hex = raw.toLowerCase().replace("#", "");
if (/^[0-9a-f]{6}$/.test(hex)) {
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2 / 255;
if (max - min < 30) return l > 0.6 ? "gray" : "dark";
if (r > g && r > b) return g > 160 ? "orange" : "red";
if (g > r && g > b) return r > 160 ? "lime" : "green";
if (b > r && b > g) return r > 100 ? "violet" : "blue";
if (r > 200 && g > 200) return "yellow";
if (r > 200 && b > 200) return "pink";
if (g > 200 && b > 200) return "cyan";
return "gray";
}
return raw;
}
@@ -0,0 +1,65 @@
.card {
max-width: 100%;
cursor: pointer;
transition: background-color 150ms ease;
margin: 4px 0;
}
.card:hover {
background-color: var(--mantine-color-gray-0);
}
:global([data-mantine-color-scheme="dark"]) .card:hover {
background-color: var(--mantine-color-dark-5);
}
.thumbnail {
display: block;
width: 100%;
max-height: 320px;
object-fit: cover;
background-color: var(--mantine-color-gray-0);
}
:global([data-mantine-color-scheme="dark"]) .thumbnail {
background-color: var(--mantine-color-dark-6);
}
.mention {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 100%;
vertical-align: text-bottom;
text-decoration: none;
color: inherit;
border-radius: var(--mantine-radius-sm);
}
:global(.node-integrationMention) .mention {
border-bottom: none !important;
font-weight: 400;
}
.mention:hover {
background-color: var(--mantine-color-gray-0);
}
:global([data-mantine-color-scheme="dark"]) .mention:hover {
background-color: var(--mantine-color-dark-5);
}
.mentionText {
text-decoration: underline;
text-decoration-color: var(--mantine-color-gray-4);
text-underline-offset: 3px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 340px;
font-weight: 500;
}
.mentionIcon {
flex-shrink: 0;
}
@@ -0,0 +1,473 @@
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 { getIntegrationIcon } from "@/features/integration/components/integration-icons";
import { getOAuthAuthorizeUrl } from "@/features/integration/services/integration-service";
import { timeAgo } from "@/lib/time";
import { useUnfurl } from "./use-unfurl";
import { badgeTextColor, toBadgeColor } from "./badge-color";
import classes from "./integration-link-view.module.css";
const SLACK_TEXT_CLAMP_LINES = 4;
function SlackMessageCard({
url,
unfurlData,
}: {
url: string;
unfurlData: Record<string, any>;
}) {
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 (
<NodeViewWrapper data-drag-handle="">
<Card className={classes.card} withBorder padding="sm" radius="sm">
<Group gap="sm" wrap="nowrap" align="flex-start">
<Avatar
src={unfurlData.authorAvatarUrl}
size={28}
radius="xl"
style={{ flexShrink: 0 }}
>
{(unfurlData.author ?? "?").charAt(0)}
</Avatar>
<Stack gap={4} style={{ flex: 1, minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600} truncate>
{unfurlData.author}
</Text>
{postedAt && (
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
{timeAgo(postedAt)}
</Text>
)}
</Group>
{text && (
<Text
size="sm"
lineClamp={expanded ? undefined : SLACK_TEXT_CLAMP_LINES}
style={{ whiteSpace: "pre-wrap" }}
>
{text}
</Text>
)}
{isLong && (
<Text
size="xs"
fw={600}
role="button"
tabIndex={0}
aria-expanded={expanded}
style={{ cursor: "pointer", width: "fit-content" }}
onClick={() => setExpanded((v) => !v)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setExpanded((v) => !v);
}
}}
>
{expanded ? t("show less") : t("show more")}
</Text>
)}
{footer && (
<Text size="xs" c="dimmed" truncate>
{footer}
</Text>
)}
</Stack>
<Anchor
href={url}
target="_blank"
rel="noopener"
aria-label={t("Open in Slack")}
style={{ flexShrink: 0, lineHeight: 0 }}
>
{getIntegrationIcon("slack", 18)}
</Anchor>
</Group>
</Card>
</NodeViewWrapper>
);
}
function JiraIssueCard({
url,
unfurlData,
}: {
url: string;
unfurlData: Record<string, any>;
}) {
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 (
<NodeViewWrapper data-drag-handle="">
<Card
className={classes.card}
withBorder
padding="sm"
radius="sm"
component="a"
href={url}
target="_blank"
rel="noopener"
style={{ textDecoration: "none", color: "inherit" }}
>
<Group gap="sm" wrap="nowrap">
{unfurlData.authorAvatarUrl ? (
<Avatar
src={unfurlData.authorAvatarUrl}
size={28}
radius="xl"
style={{ flexShrink: 0 }}
/>
) : (
<div style={{ flexShrink: 0 }}>{getIntegrationIcon("jira", 28)}</div>
)}
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Group gap="xs" wrap="nowrap">
<Text size="sm" fw={600} truncate>
{unfurlData.title}
</Text>
{unfurlData.status && (
<Badge
size="xs"
variant="light"
color={toBadgeColor(unfurlData.statusColor)}
c={badgeTextColor(toBadgeColor(unfurlData.statusColor))}
style={{ flexShrink: 0 }}
>
{unfurlData.status}
</Badge>
)}
</Group>
<Group gap={4} wrap="nowrap">
{meta.issueTypeIconUrl && (
<img
src={meta.issueTypeIconUrl}
width={14}
height={14}
alt=""
style={{ flexShrink: 0 }}
/>
)}
<Text size="xs" c="dimmed" truncate>
{infoLine}
</Text>
</Group>
</Stack>
<div style={{ flexShrink: 0, alignSelf: "center" }}>
{getIntegrationIcon("jira", 18)}
</div>
</Group>
</Card>
</NodeViewWrapper>
);
}
function FigmaFileCard({
url,
unfurlData,
}: {
url: string;
unfurlData: Record<string, any>;
}) {
const { t } = useTranslation();
// Figma thumbnail links are pre-signed and expire; drop the preview rather
// than render a broken image.
const [thumbnailFailed, setThumbnailFailed] = useState(false);
const meta = unfurlData.metadata ?? {};
const thumbnailUrl: string | undefined = meta.thumbnailUrl;
const showThumbnail = Boolean(thumbnailUrl) && !thumbnailFailed;
const subtitle = [
unfurlData.author
? t("Last modified by {{name}}", { name: unfurlData.author })
: unfurlData.description,
meta.lastModified ? timeAgo(new Date(meta.lastModified)) : null,
]
.filter(Boolean)
.join(" • ");
return (
<NodeViewWrapper data-drag-handle="">
<Card
className={classes.card}
withBorder
padding="sm"
radius="sm"
component="a"
href={url}
target="_blank"
rel="noopener"
style={{ textDecoration: "none", color: "inherit" }}
>
{showThumbnail && (
<Card.Section withBorder>
<img
src={thumbnailUrl}
alt=""
loading="lazy"
className={classes.thumbnail}
onError={() => setThumbnailFailed(true)}
/>
</Card.Section>
)}
<Group gap="sm" wrap="nowrap" mt={showThumbnail ? "sm" : undefined}>
<Avatar
src={unfurlData.authorAvatarUrl}
size={28}
radius="xl"
style={{ flexShrink: 0 }}
>
{(unfurlData.author ?? unfurlData.title ?? "F").charAt(0)}
</Avatar>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
{unfurlData.title}
</Text>
{subtitle && (
<Text size="xs" c="dimmed" truncate>
{subtitle}
</Text>
)}
</Stack>
<div style={{ flexShrink: 0, alignSelf: "center" }}>
{getIntegrationIcon("figma", 18)}
</div>
</Group>
</Card>
</NodeViewWrapper>
);
}
function IntegrationLinkView(props: any) {
const { node } = props;
const { url, provider } = node.attrs;
const { t } = useTranslation();
const unfurl = useUnfurl(url);
const [connecting, setConnecting] = useState(false);
const needsConnection =
unfurl.state === "needsConnection" ? unfurl.needsConnection : null;
const handleConnect = useCallback(
async (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (!needsConnection) return;
setConnecting(true);
try {
const result = await getOAuthAuthorizeUrl({
integrationId: needsConnection.integrationId,
returnPath: window.location.pathname,
});
window.location.href = result.authorizationUrl;
} catch (error) {
setConnecting(false);
notifications.show({
message:
error?.["response"]?.data?.message ||
t("Failed to start OAuth connection"),
color: "red",
});
}
},
[needsConnection, t],
);
if (needsConnection) {
return (
<NodeViewWrapper data-drag-handle="">
<Card className={classes.card} withBorder padding="sm" radius="sm">
<Group gap="sm" wrap="nowrap">
<div style={{ flexShrink: 0 }}>
{getIntegrationIcon(provider, 28)}
</div>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
{needsConnection.title}
</Text>
{needsConnection.description && (
<Text size="xs" c="dimmed" lineClamp={1}>
{needsConnection.description}
</Text>
)}
</Stack>
<Button
size="xs"
variant="filled"
color="dark"
loading={connecting}
onClick={handleConnect}
style={{ flexShrink: 0 }}
>
{t("Connect to {{name}} to update", {
name: needsConnection.integrationName,
})}
</Button>
</Group>
</Card>
</NodeViewWrapper>
);
}
if (unfurl.state === "loading") {
return (
<NodeViewWrapper data-drag-handle="">
<Card className={classes.card} withBorder padding="sm" radius="sm">
<Group gap="sm">
<Skeleton circle height={24} />
<Stack gap={4} style={{ flex: 1 }}>
<Skeleton height={14} width="60%" />
<Skeleton height={10} width="80%" />
</Stack>
</Group>
</Card>
</NodeViewWrapper>
);
}
if (unfurl.state !== "loaded") {
// anonymous or error: a plain link card, no third-party data
return (
<NodeViewWrapper data-drag-handle="">
<Card className={classes.card} withBorder padding="sm" radius="sm">
<Anchor href={url} target="_blank" rel="noopener" size="sm">
{url}
</Anchor>
</Card>
</NodeViewWrapper>
);
}
const unfurlData = unfurl.data;
// metadata.ts marks legacy message unfurls stored before metadata.type existed.
const slackMeta = provider === "slack" ? unfurlData.metadata : null;
if (slackMeta?.type === "message" || (slackMeta && !slackMeta.type && slackMeta.ts)) {
return <SlackMessageCard url={url} unfurlData={unfurlData} />;
}
if (provider === "jira" && unfurlData.metadata?.issueKey) {
return <JiraIssueCard url={url} unfurlData={unfurlData} />;
}
if (provider === "figma") {
return <FigmaFileCard url={url} unfurlData={unfurlData} />;
}
return (
<NodeViewWrapper data-drag-handle="">
<Card
className={classes.card}
withBorder
padding="sm"
radius="sm"
component="a"
href={url}
target="_blank"
rel="noopener"
style={{ textDecoration: "none", color: "inherit" }}
>
<Group gap="sm" wrap="nowrap">
{unfurlData.authorAvatarUrl ? (
<Avatar src={unfurlData.authorAvatarUrl} size={28} radius="xl" style={{ flexShrink: 0 }} />
) : (
<div style={{ flexShrink: 0 }}>{getIntegrationIcon(provider, 28)}</div>
)}
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Group gap="xs" wrap="nowrap">
<Text size="sm" fw={600} truncate>
{unfurlData.title}
</Text>
{unfurlData.status && (
<Badge
size="xs"
variant="light"
color={toBadgeColor(unfurlData.statusColor)}
c={badgeTextColor(toBadgeColor(unfurlData.statusColor))}
style={{ flexShrink: 0 }}
>
{unfurlData.status}
</Badge>
)}
</Group>
{unfurlData.description && (
<Text size="xs" c="dimmed" lineClamp={1}>
{unfurlData.description}
</Text>
)}
</Stack>
{provider && (
<div style={{ flexShrink: 0, alignSelf: "center" }}>
{getIntegrationIcon(provider, 18)}
</div>
)}
</Group>
</Card>
</NodeViewWrapper>
);
}
export default memo(IntegrationLinkView);
@@ -0,0 +1,162 @@
import { NodeViewWrapper } from "@tiptap/react";
import { Avatar, Badge, Text } from "@mantine/core";
import { memo } from "react";
import { useTranslation } from "react-i18next";
import { getIntegrationIcon } from "@/features/integration/components/integration-icons";
import { useUnfurl } from "./use-unfurl";
import { badgeTextColor, toBadgeColor } from "./badge-color";
import classes from "./integration-link-view.module.css";
function shortUrl(url: string): string {
try {
const parsed = new URL(url);
return `${parsed.host}${parsed.pathname}`;
} catch {
return url;
}
}
function IntegrationMentionView(props: any) {
const { node } = props;
const { url, provider } = node.attrs;
const { t } = useTranslation();
const unfurl = useUnfurl(url);
const data = unfurl.state === "loaded" ? unfurl.data : null;
const 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 ? (
<Badge
size="xs"
variant="light"
color={toBadgeColor(data.statusColor)}
c={badgeTextColor(toBadgeColor(data.statusColor))}
className={classes.mentionIcon}
>
{data.status}
</Badge>
) : null;
let content;
if (!data) {
// anonymous / loading / error / needs-connection: a compact link chip
content = (
<>
{getIntegrationIcon(provider, 14)}
<span className={classes.mentionText}>{shortUrl(url)}</span>
</>
);
} else if (isSlackMessage) {
content = (
<>
<Avatar
src={data.authorAvatarUrl}
size={16}
radius="xl"
className={classes.mentionIcon}
>
{(data.author ?? "?").charAt(0)}
</Avatar>
{data.author && (
<Text component="span" size="sm" c="dimmed">
{data.author}
</Text>
)}
<span className={classes.mentionText}>
{(data.description ?? "").split("\n")[0] || shortUrl(url)}
</span>
{getIntegrationIcon("slack", 14)}
{data.status && (
<Badge
size="xs"
variant="light"
color="gray"
c={badgeTextColor("gray")}
tt="none"
className={classes.mentionIcon}
>
{data.status}
</Badge>
)}
</>
);
} else if (meta.issueKey) {
// Jira: type icon leads, provider icon trails.
content = (
<>
{meta.issueTypeIconUrl ? (
<img
src={meta.issueTypeIconUrl}
width={14}
height={14}
alt=""
className={classes.mentionIcon}
/>
) : (
getIntegrationIcon(provider, 14)
)}
<Text component="span" size="sm" c="dimmed">
{meta.issueKey}
</Text>
<span className={classes.mentionText}>{data.title}</span>
{statusBadge}
{meta.issueTypeIconUrl && getIntegrationIcon(provider, 14)}
</>
);
} else if (issueNumber) {
content = (
<>
{getIntegrationIcon(provider, 14)}
<Text component="span" size="sm" c="dimmed">
#{issueNumber}
</Text>
<span className={classes.mentionText}>{data.title}</span>
{statusBadge}
</>
);
} else if (typeLabel) {
content = (
<>
{getIntegrationIcon(provider, 14)}
<Text component="span" size="sm" c="dimmed">
{typeLabel}
</Text>
<span className={classes.mentionText}>{data.title}</span>
{statusBadge}
</>
);
} else {
content = (
<>
{getIntegrationIcon(provider, 14)}
<span className={classes.mentionText}>{data.title || shortUrl(url)}</span>
{statusBadge}
</>
);
}
return (
<NodeViewWrapper as="span" style={{ display: "inline" }}>
<a
href={url}
target="_blank"
rel="noopener"
title={url}
className={classes.mention}
>
{content}
</a>
</NodeViewWrapper>
);
}
export default memo(IntegrationMentionView);
@@ -0,0 +1,164 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback, useEffect } from "react";
import { Button, Paper, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
import { integrationPasteMenuKey } from "@/features/editor/extensions/integration-paste-menu";
const INTEGRATION_NODE_TYPES = ["integrationLink", "integrationMention"];
export function IntegrationPasteMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const menuState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) return null;
return integrationPasteMenuKey.getState(ctx.editor.state) ?? null;
},
});
const findTarget = useCallback(() => {
const state = integrationPasteMenuKey.getState(editor.state);
if (!state) return null;
const node = editor.state.doc.nodeAt(state.pos);
if (!node || !INTEGRATION_NODE_TYPES.includes(node.type.name)) return null;
return { node, pos: state.pos };
}, [editor]);
const shouldShow = useCallback(() => Boolean(findTarget()), [findTarget]);
const getReferencedVirtualElement = useCallback(() => {
const target = findTarget();
if (!target) return undefined;
const dom = editor.view.nodeDOM(target.pos) as HTMLElement | null;
const domRect =
dom?.getBoundingClientRect?.() ??
posToDOMRect(
editor.view,
target.pos,
target.pos + target.node.nodeSize,
);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}, [editor, findTarget]);
const dismiss = useCallback(() => {
editor.view.dispatch(
editor.state.tr.setMeta(integrationPasteMenuKey, null),
);
}, [editor]);
useEffect(() => {
if (!menuState) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") dismiss();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [menuState, dismiss]);
const convert = useCallback(
(target: "card" | "mention" | "url") => {
const found = findTarget();
if (!found) {
dismiss();
return;
}
const { node, pos } = found;
const attrs = { ...node.attrs };
const from = pos;
const to = pos + node.nodeSize;
const isBlock = node.type.name === "integrationLink";
// Always replace, even when the node is already in the requested form:
// the menu closes via the doc change, and BubbleMenu never re-evaluates
// on meta-only transactions, so a bare dismiss would leave it stuck.
let content: Record<string, any>;
if (target === "card") {
content = { type: "integrationLink", attrs };
} else if (target === "mention") {
const mention = { type: "integrationMention", attrs };
content = isBlock
? {
type: "paragraph",
content: [mention, { type: "text", text: " " }],
}
: mention;
} else {
const linkText = {
type: "text",
text: attrs.url,
marks: [{ type: "link", attrs: { href: attrs.url } }],
};
content = isBlock
? { type: "paragraph", content: [linkText] }
: linkText;
}
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.deleteRange({ from, to })
.insertContentAt(from, content)
.run();
},
[editor, findTarget, dismiss],
);
return (
<BaseBubbleMenu
editor={editor}
pluginKey="integration-paste-menu"
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{ placement: "bottom-start", flip: true }}
shouldShow={shouldShow}
>
{/* Content is gated on the plugin state too: meta-only dismissals
(Escape) are invisible to BubbleMenu's update cycle. */}
{menuState ? (
<Paper shadow="md" radius="md" withBorder p={4} miw={140}>
<Text size="xs" c="dimmed" px={8} py={4}>
{t("Paste as")}
</Text>
<Stack gap={2}>
<Button
variant="subtle"
color="gray"
size="compact-sm"
fullWidth
justify="flex-start"
onClick={() => convert("card")}
>
{t("Card")}
</Button>
<Button
variant="subtle"
color="gray"
size="compact-sm"
fullWidth
justify="flex-start"
onClick={() => convert("mention")}
>
{t("Mention")}
</Button>
<Button
variant="subtle"
color="gray"
size="compact-sm"
fullWidth
justify="flex-start"
onClick={() => convert("url")}
>
{t("URL")}
</Button>
</Stack>
</Paper>
) : null}
</BaseBubbleMenu>
);
}
@@ -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 }) => (
<Provider store={store}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</Provider>
);
}
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"));
});
});
@@ -0,0 +1,47 @@
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { unfurlUrl } from "@/features/integration/services/integration-service";
import {
UnfurlNeedsConnection,
UnfurlResult,
} from "@/features/integration/types/integration.types";
const UNFURL_STALE_TIME = 5 * 60 * 1000; // mirrors the server-side Redis TTL
export type UnfurlState =
| { state: "anonymous" }
| { state: "loading" }
| { state: "error" }
| { state: "needsConnection"; needsConnection: UnfurlNeedsConnection }
| { state: "loaded"; data: UnfurlResult };
// Resolves the unfurl per viewer at render time. Nothing is written back into
// the document, so third-party permissions are enforced on every view:
// unconnected viewers get needsConnection and anonymous viewers never fetch.
export function useUnfurl(url: string): UnfurlState {
const currentUser = useAtomValue(currentUserAtom);
const isAuthenticated = Boolean(currentUser?.user);
const query = useQuery({
queryKey: ["unfurl", url],
queryFn: () => unfurlUrl({ url }),
enabled: isAuthenticated && Boolean(url),
staleTime: UNFURL_STALE_TIME,
retry: false,
});
if (!isAuthenticated || !url) {
return { state: "anonymous" };
}
if (query.isPending) {
return { state: "loading" };
}
if (query.isError || !query.data) {
return { state: "error" };
}
if ("needsConnection" in query.data) {
return { state: "needsConnection", needsConnection: query.data };
}
return { state: "loaded", data: query.data };
}
@@ -57,6 +57,8 @@ import {
Indent,
UniqueID,
SharedStorage,
IntegrationLink,
IntegrationMention,
Columns,
Column,
Status,
@@ -95,6 +97,9 @@ import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-v
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
import PdfView from "@/features/editor/components/pdf/pdf-view.tsx";
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
import IntegrationLinkView from "@/features/editor/components/integration-link/integration-link-view.tsx";
import IntegrationMentionView from "@/features/editor/components/integration-link/integration-mention-view.tsx";
import { IntegrationPasteMenuExtension } from "@/features/editor/extensions/integration-paste-menu";
import TransclusionView from "@/features/editor/components/transclusion/transclusion-view.tsx";
import TransclusionReferenceView from "@/features/editor/components/transclusion/transclusion-reference-view.tsx";
import { BaseEmbedView } from "@/features/editor/components/base-embed/base-embed-view.tsx";
@@ -387,6 +392,13 @@ export const mainExtensions = [
Subpages.configure({
view: SubpagesView,
}),
IntegrationLink.configure({
view: IntegrationLinkView,
}),
IntegrationMention.configure({
view: IntegrationMentionView,
}),
IntegrationPasteMenuExtension,
Status.configure({
view: StatusView,
}),
@@ -0,0 +1,44 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
export type IntegrationPasteMenuState = { pos: number } | null;
export const integrationPasteMenuKey = new PluginKey<IntegrationPasteMenuState>(
"integrationPasteMenu",
);
// Holds the position of a just-pasted integration node so the "Paste as"
// menu can anchor to it. Any other edit or selection change dismisses it.
export const IntegrationPasteMenuExtension = Extension.create({
name: "integrationPasteMenu",
addProseMirrorPlugins() {
return [
new Plugin({
key: integrationPasteMenuKey,
state: {
init: (): IntegrationPasteMenuState => null,
apply(tr, prev): IntegrationPasteMenuState {
const meta = tr.getMeta(integrationPasteMenuKey);
if (meta !== undefined) return meta;
if (!prev) return null;
// Clicking or typing elsewhere dismisses.
if (tr.selectionSet) return null;
// Structural follow-ups (unique-id assignment, trailing node)
// keep the menu anchored: remap and re-validate the position.
if (tr.docChanged) {
const pos = tr.mapping.map(prev.pos);
const node = tr.doc.nodeAt(pos);
const isIntegrationNode =
node &&
(node.type.name === "integrationLink" ||
node.type.name === "integrationMention");
return isIntegrationNode ? { pos } : null;
}
return prev;
},
},
}),
];
},
});
@@ -75,6 +75,8 @@ import { useEditorScroll } from "./hooks/use-editor-scroll";
import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu";
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
import { IntegrationPasteMenu } from "@/features/editor/components/integration-link/integration-paste-menu.tsx";
import { getInstalledIntegrations } from "@/features/integration/services/integration-service";
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
import { useTranslation } from "react-i18next";
import {
@@ -326,6 +328,15 @@ function CollabPageEditor({
[pageId, editable, extensions],
);
useEffect(() => {
// Warm the cache the paste handler reads to decide whether a pasted
// integration url becomes a card or stays an ordinary link.
queryClient.prefetchQuery({
queryKey: ["installed-integrations"],
queryFn: getInstalledIntegrations,
});
}, []);
useLayoutEffect(() => {
if (editor && !editor.isDestroyed) {
// @ts-ignore
@@ -454,6 +465,7 @@ function CollabPageEditor({
<ExcalidrawMenu editor={editor} />
<DrawioMenu editor={editor} />
<ColumnsMenu editor={editor} />
<IntegrationPasteMenu editor={editor} />
</div>
)}
{editor && !editorIsEditable && (editable || canComment) && (
@@ -0,0 +1,108 @@
import { Group, Text, Button, Box } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { IntegrationDefinition, UserConnection } from "../types/integration.types";
import { getIntegrationIcon } from "./integration-icons";
type ConnectionRowProps = {
definition: IntegrationDefinition;
connection?: UserConnection;
onConnect: (type: string) => void;
onDisconnect: (integrationId: string) => void;
disconnectingId?: string;
};
export default function ConnectionRow({
definition,
connection,
onConnect,
onDisconnect,
disconnectingId,
}: ConnectionRowProps) {
const { t } = useTranslation();
const isWorkspaceScoped = definition.oauth?.connectionScope === 'workspace';
return (
<Box
py="sm"
px="xs"
style={{
borderBottom: "1px solid var(--mantine-color-default-border)",
}}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
{getIntegrationIcon(definition.type, 28)}
<div>
<Text size="sm" fw={500}>
{definition.name}
</Text>
<Text size="xs" c="dimmed">
{definition.description}
</Text>
</div>
</Group>
<Group gap="sm" wrap="nowrap" style={{ flexShrink: 0 }}>
{isWorkspaceScoped ? (
<>
{connection ? (
<Text size="xs" c="green">
{t("Linked")}
{connection.providerUserId && ` as @${connection.providerUserId}`}
</Text>
) : (
<Text size="xs" c="dimmed">
{t("Use")} <code>/docmost help</code> {t("in")} {definition.name} {t("to link your account.")}
</Text>
)}
</>
) : (
<>
{connection ? (
<>
{connection.invalidatedAt ? (
<>
<Text size="xs" c="orange">
{t("Connection expired")}
</Text>
<Button
size="xs"
variant="light"
color="orange"
onClick={() => onConnect(definition.type)}
>
{t("Reconnect")}
</Button>
</>
) : (
<Text size="xs" c="green">
{t("Connected")}
{connection.providerUserId && ` (${connection.providerUserId})`}
</Text>
)}
<Button
size="xs"
variant="subtle"
color="red"
onClick={() => onDisconnect(connection.integrationId)}
loading={disconnectingId === connection.integrationId}
>
{t("Disconnect")}
</Button>
</>
) : (
<Button
size="xs"
variant="light"
onClick={() => onConnect(definition.type)}
>
{t("Connect")}
</Button>
)}
</>
)}
</Group>
</Group>
</Box>
);
}
@@ -0,0 +1,30 @@
import { ReactNode } from "react";
import {
FigmaIcon,
GithubIcon,
GitlabIcon,
GoogleDocsIcon,
JiraIcon,
LinearIcon,
SlackIcon,
} from "@/components/icons";
import { IconPuzzle } from "@tabler/icons-react";
const integrationIconMap: Record<string, (size: number) => ReactNode> = {
github: (size) => <GithubIcon size={size} />,
gitlab: (size) => <GitlabIcon size={size} />,
slack: (size) => <SlackIcon size={size} />,
linear: (size) => <LinearIcon size={size} />,
jira: (size) => <JiraIcon size={size} />,
figma: (size) => <FigmaIcon size={size} />,
google_docs: (size) => <GoogleDocsIcon size={size} />,
};
export function getIntegrationIcon(
type: string,
size: number,
): ReactNode {
const renderIcon = integrationIconMap[type];
if (renderIcon) return renderIcon(size);
return <IconPuzzle size={size} stroke={1.5} />;
}
@@ -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 (
<Stack gap={0} aria-hidden="true">
{Array.from({ length: rows }, (_, index) => (
<Box
key={index}
py="sm"
px="xs"
style={{
borderBottom: "1px solid var(--mantine-color-default-border)",
}}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
<Skeleton height={28} circle style={{ flexShrink: 0 }} />
<Stack gap={2} style={{ minWidth: 0 }}>
<Group gap="xs" wrap="nowrap" h={20}>
<Skeleton
height={12}
width={TITLE_WIDTHS[index % TITLE_WIDTHS.length]}
radius="xs"
/>
{withBadges && (
<>
<Skeleton height={16} width={52} radius="xl" />
<Skeleton height={16} width={52} radius="xl" />
</>
)}
</Group>
<Group h={17}>
<Skeleton
height={10}
width={
DESCRIPTION_WIDTHS[index % DESCRIPTION_WIDTHS.length]
}
maw="100%"
radius="xs"
/>
</Group>
</Stack>
</Group>
<Skeleton
height={30}
width={64}
radius="sm"
style={{ flexShrink: 0 }}
/>
</Group>
</Box>
))}
</Stack>
);
}
@@ -0,0 +1,98 @@
import {
Group,
Text,
Badge,
Button,
Box,
Stack,
Tooltip,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import {
IntegrationDefinition,
Integration,
} from "../types/integration.types";
import { getIntegrationIcon } from "./integration-icons";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
type IntegrationRowProps = {
definition: IntegrationDefinition;
installation?: Integration;
onInstall: (type: string) => void;
onUninstall: (integrationId: string) => void;
};
export default function IntegrationRow({
definition,
installation,
onInstall,
onUninstall,
}: IntegrationRowProps) {
const { t } = useTranslation();
const isInstalled = !!installation;
const hasAccess = useHasFeature(Feature.INTEGRATIONS);
const locked = !!definition.requiresLicense && !hasAccess;
const upgradeLabel = useUpgradeLabel();
return (
<Box
py="sm"
px="xs"
style={{
borderBottom: "1px solid var(--mantine-color-default-border)",
}}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
{getIntegrationIcon(definition.type, 28)}
<Stack gap={2} style={{ minWidth: 0 }}>
<Group gap="xs" wrap="nowrap">
<Text size="sm" fw={500}>
{definition.name}
</Text>
{locked && (
<Badge size="xs" variant="light" color="violet">
{t("Paid")}
</Badge>
)}
{definition.capabilities.map((cap) => (
<Badge key={cap} size="xs" variant="light">
{cap}
</Badge>
))}
</Group>
<Text size="xs" c="dimmed" truncate>
{definition.description}
</Text>
</Stack>
</Group>
<Group gap="sm" wrap="nowrap" style={{ flexShrink: 0 }}>
{isInstalled ? (
<Button
size="xs"
variant="subtle"
color="red"
onClick={() => onUninstall(installation.id)}
>
{t("Uninstall")}
</Button>
) : (
<Tooltip label={upgradeLabel} disabled={!locked}>
<Button
size="xs"
variant="light"
disabled={locked}
onClick={() => onInstall(definition.type)}
>
{t("Install")}
</Button>
</Tooltip>
)}
</Group>
</Group>
</Box>
);
}
@@ -0,0 +1,111 @@
import { Text, Alert, Stack } from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications";
import { getAppName } from "@/lib/config";
import SettingsTitle from "@/components/settings/settings-title";
import ConnectionRow from "../components/connection-row";
import IntegrationListSkeleton from "../components/integration-list-skeleton";
import {
useAvailableIntegrations,
useInstalledIntegrations,
useMyConnections,
useDisconnectIntegration,
} from "../queries/integration-query";
import * as integrationService from "../services/integration-service";
export default function Connections() {
const { t } = useTranslation();
const { data: available, isLoading: loadingAvailable } =
useAvailableIntegrations();
const { data: installed, isLoading: loadingInstalled } =
useInstalledIntegrations();
const { data: myConnections, isLoading: loadingConnections } =
useMyConnections();
const disconnectMutation = useDisconnectIntegration();
const isLoading = loadingAvailable || loadingInstalled || loadingConnections;
const handleConnect = async (type: string) => {
const integration = installed?.find((i) => i.type === type);
if (!integration) return;
try {
const result = await integrationService.getOAuthAuthorizeUrl({
integrationId: integration.id,
});
window.location.href = result.authorizationUrl;
} catch (error) {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to start OAuth connection"),
color: "red",
});
}
};
const handleDisconnect = (integrationId: string) => {
disconnectMutation.mutate({ integrationId });
};
// Only the row being disconnected shows a loader; isPending alone is shared by every row.
const disconnectingId = disconnectMutation.isPending
? disconnectMutation.variables?.integrationId
: undefined;
const error = new URLSearchParams(window.location.search).get("error");
return (
<>
<Helmet>
<title>
{t("Connections")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Connections")} />
<Text size="sm" c="dimmed" mb="md">
{t("Manage the apps you have connected to your account.")}
</Text>
{error === "oauth_failed" && (
<Alert color="red" mb="md">
{t("OAuth connection failed. Please try again.")}
</Alert>
)}
{isLoading ? (
<IntegrationListSkeleton rows={3} withBadges={false} />
) : !available?.length ? (
<Text c="dimmed" size="sm">
{t("No integrations available.")}
</Text>
) : (
<Stack gap={0}>
{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 (
<ConnectionRow
key={def.type}
definition={def}
connection={connection}
onConnect={handleConnect}
onDisconnect={handleDisconnect}
disconnectingId={disconnectingId}
/>
);
})}
</Stack>
)}
</>
);
}
@@ -0,0 +1,137 @@
import { Text, Alert, Stack } from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { useCallback } from "react";
import { getAppName } from "@/lib/config";
import SettingsTitle from "@/components/settings/settings-title";
import IntegrationRow from "../components/integration-row";
import IntegrationListSkeleton from "../components/integration-list-skeleton";
import {
useAvailableIntegrations,
useInstalledIntegrations,
useInstallIntegration,
useUninstallIntegration,
} from "../queries/integration-query";
import {
getOAuthAuthorizeUrl,
getOAuthInstallUrl,
} from "../services/integration-service";
import { Integration } from "../types/integration.types";
import { notifications } from "@mantine/notifications";
export default function Integrations() {
const { t } = useTranslation();
const { data: available, isLoading: loadingAvailable } =
useAvailableIntegrations();
const { data: installed, isLoading: loadingInstalled } =
useInstalledIntegrations();
const installMutation = useInstallIntegration();
const uninstallMutation = useUninstallIntegration();
const handleInstall = useCallback(
async (type: string) => {
const definition = available?.find((d) => d.type === type);
// Workspace-scoped (Slack): the install row is only persisted when the
// OAuth callback succeeds. Skip the upfront install API call entirely.
if (definition?.oauth?.connectionScope === "workspace") {
try {
const { authorizationUrl } = await getOAuthInstallUrl({ type });
window.location.href = authorizationUrl;
} catch (err: any) {
notifications.show({
message:
err?.response?.data?.message ?? t("Failed to start installation"),
color: "red",
});
}
return;
}
// Per-user OAuth providers (GitLab, Jira, GitHub, ...): create the
// integration row, then send the installing admin straight into their
// own OAuth so they leave with a working connection. Other members
// connect for themselves from /settings/account/connections.
let integration: Integration;
try {
integration = await installMutation.mutateAsync({ type });
} catch {
return; // the mutation reports its own failure
}
if (!definition?.capabilities?.includes("oauth")) return;
try {
const { authorizationUrl } = await getOAuthAuthorizeUrl({
integrationId: integration.id,
returnPath: "/settings/integrations",
});
window.location.href = authorizationUrl;
} catch (err: any) {
// The integration stays installed; the admin can connect later.
notifications.show({
message:
err?.response?.data?.message ??
t("Failed to start OAuth connection"),
color: "red",
});
}
},
[installMutation, available, t],
);
const handleUninstall = useCallback(
(integrationId: string) => {
uninstallMutation.mutate({ integrationId });
},
[uninstallMutation],
);
const isLoading = loadingAvailable || loadingInstalled;
const error = new URLSearchParams(window.location.search).get("error");
return (
<>
<Helmet>
<title>
{t("Integrations")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Integrations")} />
<Text size="sm" c="dimmed" mb="md">
{t("Manage workspace integrations.")}
</Text>
{error === "oauth_failed" && (
<Alert color="red" mb="md">
{t("OAuth connection failed. Please try again.")}
</Alert>
)}
{isLoading ? (
<IntegrationListSkeleton />
) : !available?.length ? (
<Text c="dimmed" size="sm">
{t("No integrations available.")}
</Text>
) : (
<Stack gap={0}>
{available.map((def) => {
const installation = installed?.find((i) => i.type === def.type);
return (
<IntegrationRow
key={def.type}
definition={def}
installation={installation}
onInstall={handleInstall}
onUninstall={handleUninstall}
/>
);
})}
</Stack>
)}
</>
);
}
@@ -0,0 +1,112 @@
import { useEffect, useState } from "react";
import { useSearchParams, useNavigate } from "react-router-dom";
import { Alert, Button, Card, Group, Loader, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useAtomValue } from "jotai";
import {
decodeSlackLinkState,
confirmSlackLink,
SlackLinkStateInfo,
} from "../services/slack-link-service";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import APP_ROUTE from "@/lib/app-route";
export default function SlackLinkPage() {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const state = searchParams.get("state");
const currentUser = useAtomValue(currentUserAtom);
const [info, setInfo] = useState<SlackLinkStateInfo | null>(null);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [done, setDone] = useState(false);
useEffect(() => {
if (!currentUser) {
const redirectPath = window.location.pathname + window.location.search;
navigate(`${APP_ROUTE.AUTH.LOGIN}?redirect=${encodeURIComponent(redirectPath)}`);
return;
}
if (!state) {
setError(t("Missing state parameter"));
return;
}
decodeSlackLinkState(state)
.then(setInfo)
.catch((e) => setError(e?.response?.data?.message ?? e.message));
}, [state, t, currentUser, navigate]);
async function onConfirm() {
if (!state) return;
setSubmitting(true);
try {
await confirmSlackLink(state);
setDone(true);
} catch (e: any) {
setError(e?.response?.data?.message ?? e.message);
} finally {
setSubmitting(false);
}
}
if (done) {
return (
<Card maw={500} mx="auto" mt={80} p="lg">
<Stack>
<Text fw={600}>{t("Connected")}</Text>
<Text c="dimmed">{t("You can close this tab and return to Slack.")}</Text>
</Stack>
</Card>
);
}
if (error) {
return (
<Card maw={500} mx="auto" mt={80} p="lg">
<Alert color="red" title={t("Could not link account")}>
{error}
</Alert>
</Card>
);
}
if (!info || !currentUser) {
return (
<div style={{ display: "flex", justifyContent: "center", marginTop: 80 }}>
<Loader />
</div>
);
}
return (
<Card maw={500} mx="auto" mt={80} p="lg">
<Stack>
<Text fw={600}>{t("Link your Docmost account to Slack")}</Text>
<Text>
{t("Connect Docmost account")}{" "}
<b>{currentUser.user.email}</b>{" "}
{t("to Slack user")} <b>@{info.slackUserName}</b>
{info.slackTeamName && (
<>
{" "}
{t("in")} <b>{info.slackTeamName}</b>
</>
)}
?
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={() => window.close()}>
{t("Cancel")}
</Button>
<Button onClick={onConfirm} loading={submitting}>
{t("Confirm")}
</Button>
</Group>
</Stack>
</Card>
);
}
@@ -0,0 +1,100 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications";
import * as integrationService from "../services/integration-service";
export function useAvailableIntegrations() {
return useQuery({
queryKey: ["available-integrations"],
queryFn: integrationService.getAvailableIntegrations,
});
}
export function useInstalledIntegrations() {
return useQuery({
queryKey: ["installed-integrations"],
queryFn: integrationService.getInstalledIntegrations,
});
}
export function useInstallIntegration() {
const qc = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: integrationService.installIntegration,
onSuccess: () => {
notifications.show({ message: t("Integration installed successfully") });
qc.invalidateQueries({ queryKey: ["installed-integrations"] });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to install integration"),
color: "red",
});
},
});
}
export function useUninstallIntegration() {
const qc = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: integrationService.uninstallIntegration,
onSuccess: () => {
notifications.show({
message: t("Integration uninstalled successfully"),
});
qc.invalidateQueries({ queryKey: ["installed-integrations"] });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to uninstall integration"),
color: "red",
});
},
});
}
export function useMyConnections() {
return useQuery({
queryKey: ["my-connections"],
queryFn: integrationService.getMyConnections,
});
}
export function useConnectionStatus(integrationId: string | undefined) {
return useQuery({
queryKey: ["integration-connection", integrationId],
queryFn: () =>
integrationService.getConnectionStatus({
integrationId: integrationId!,
}),
enabled: !!integrationId,
});
}
export function useDisconnectIntegration() {
const qc = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: integrationService.disconnectIntegration,
onSuccess: (_data, variables) => {
notifications.show({ message: t("Integration disconnected") });
qc.invalidateQueries({
queryKey: ["integration-connection", variables.integrationId],
});
qc.invalidateQueries({ queryKey: ["my-connections"] });
// removeQueries, not invalidate: refetchOnMount false leaves invalidated inactive queries unrefreshed
qc.removeQueries({ queryKey: ["unfurl"] });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to disconnect integration"),
color: "red",
});
},
});
}
@@ -0,0 +1,92 @@
import api from "@/lib/api-client";
import {
IntegrationDefinition,
Integration,
ConnectionStatus,
UserConnection,
UnfurlResult,
UnfurlNeedsConnection,
} from "../types/integration.types";
export async function getAvailableIntegrations(): Promise<
IntegrationDefinition[]
> {
const req = await api.post<IntegrationDefinition[]>(
"/integrations/available",
);
return req.data;
}
export async function getInstalledIntegrations(): Promise<Integration[]> {
const req = await api.post<Integration[]>("/integrations/list");
return req.data;
}
export async function installIntegration(data: {
type: string;
}): Promise<Integration> {
const req = await api.post<Integration>("/integrations/install", data);
return req.data;
}
export async function uninstallIntegration(data: {
integrationId: string;
}): Promise<void> {
await api.post("/integrations/uninstall", data);
}
export async function getMyConnections(): Promise<UserConnection[]> {
const req = await api.post<UserConnection[]>("/integrations/connections/mine");
return req.data;
}
export async function getConnectionStatus(data: {
integrationId: string;
}): Promise<ConnectionStatus> {
const req = await api.post<ConnectionStatus>(
"/integrations/connection/status",
data,
);
return req.data;
}
export async function getOAuthAuthorizeUrl(data: {
integrationId: string;
returnPath?: string;
}): Promise<{ authorizationUrl: string }> {
const req = await api.post<{ authorizationUrl: string }>(
"/integrations/oauth/authorize",
data,
);
return req.data;
}
/**
* For workspace-scoped providers: returns the authorize URL WITHOUT creating
* the integration row. The row is created atomically when the OAuth callback
* succeeds; a cancelled OAuth leaves no half-installed state.
*/
export async function getOAuthInstallUrl(data: {
type: string;
}): Promise<{ authorizationUrl: string }> {
const req = await api.post<{ authorizationUrl: string }>(
"/integrations/oauth/install",
data,
);
return req.data;
}
export async function disconnectIntegration(data: {
integrationId: string;
}): Promise<void> {
await api.post("/integrations/oauth/disconnect", data);
}
export async function unfurlUrl(data: {
url: string;
}): Promise<UnfurlResult | UnfurlNeedsConnection | null> {
const req = await api.post<{
data: UnfurlResult | UnfurlNeedsConnection | null;
}>("/integrations/unfurl", data);
return req.data.data;
}
@@ -0,0 +1,23 @@
import api from "@/lib/api-client";
export type SlackLinkStateInfo = {
slackUserName: string;
slackUserId: string;
slackTeamId: string;
slackTeamName: string | null;
integrationWorkspaceId: string | undefined;
};
export async function decodeSlackLinkState(
state: string,
): Promise<SlackLinkStateInfo> {
const req = await api.post<SlackLinkStateInfo>(
"/integrations/slack/link/state",
{ state },
);
return req.data;
}
export async function confirmSlackLink(state: string): Promise<void> {
await api.post("/integrations/slack/link", { state });
}
@@ -0,0 +1,65 @@
export type IntegrationCapability = "oauth" | "unfurl" | "actions" | "webhooks";
export type OAuthConfig = {
authUrl: string;
tokenUrl: string;
scopes: string[];
connectionScope?: 'workspace' | 'user';
};
export type IntegrationDefinition = {
type: string;
name: string;
description: string;
icon: string;
capabilities: IntegrationCapability[];
oauth?: OAuthConfig;
requiresLicense?: boolean;
};
export type Integration = {
id: string;
workspaceId: string;
type: string;
settings: Record<string, any> | null;
installedById: string | null;
createdAt: string;
updatedAt: string;
};
export type ConnectionStatus = {
connected: boolean;
providerUserId?: string;
};
export type UserConnection = {
integrationId: string;
type: string;
providerUserId: string | null;
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<string, any>;
};
// Returned when the link's provider needs a per-user connection the
// requesting user has not authorized yet.
export type UnfurlNeedsConnection = {
needsConnection: true;
integrationId: string;
integrationType: string;
integrationName: string;
title: string;
description?: string;
};
+1
View File
@@ -27,6 +27,7 @@ const APP_ROUTE = {
SPACES: "/settings/spaces",
BILLING: "/settings/billing",
SECURITY: "/settings/security",
INTEGRATIONS: "/settings/integrations",
},
},
};
@@ -49,6 +49,8 @@ import {
Footnotes,
Footnote,
FootnoteReference,
IntegrationLink,
IntegrationMention,
} from '@docmost/editor-ext';
import {
extensions as coreExtensions,
@@ -128,6 +130,8 @@ export const tiptapExtensions = [
Footnotes,
Footnote,
FootnoteReference,
IntegrationLink,
IntegrationMention
] as any;
export function jsonToHtml(tiptapJson: any) {
@@ -107,7 +107,6 @@ export const EXCLUDED_AUDIT_EVENTS: Set<string> = 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,
@@ -15,6 +15,7 @@ export enum EventName {
WORKSPACE_CREATED = 'workspace.created',
WORKSPACE_UPDATED = 'workspace.updated',
WORKSPACE_DELETED = 'workspace.deleted',
NOTIFICATION_CREATED = 'notification.created',
BASE_CREATED = 'base.created',
BASE_UPDATED = 'base.updated',
+1
View File
@@ -23,6 +23,7 @@ export const Feature = {
PERSONAL_SPACES: 'spaces:personal',
DOCX_EXPORT: 'export:docx',
BASES: 'bases',
INTEGRATIONS: 'integrations',
} as const;
export type FeatureKey = (typeof Feature)[keyof typeof Feature];
@@ -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;
}
});
});
+38
View File
@@ -0,0 +1,38 @@
import { EnvHttpProxyAgent, fetch as undiciFetch } from 'undici';
const LOOPBACK_BYPASS = ['localhost', '127.0.0.1', '::1'];
let cachedAgent: EnvHttpProxyAgent | undefined;
function hasProxyEnv(): boolean {
return Boolean(
process.env.HTTP_PROXY ||
process.env.HTTPS_PROXY ||
process.env.http_proxy ||
process.env.https_proxy,
);
}
function buildAgent(): EnvHttpProxyAgent {
const existing = process.env.NO_PROXY || process.env.no_proxy || '';
const merged = [existing, ...LOOPBACK_BYPASS]
.map((s) => s.trim())
.filter(Boolean)
.join(',');
return new EnvHttpProxyAgent({ noProxy: merged });
}
export function getProxyAwareFetch(): typeof fetch | undefined {
if (!hasProxyEnv()) return undefined;
cachedAgent ??= buildAgent();
const agent = cachedAgent;
return ((input, init) =>
undiciFetch(input as any, {
...(init as any),
dispatcher: agent,
}) as unknown as Promise<Response>) as typeof fetch;
}
// Drop-in replacement for direct fetch calls: proxies when configured, platform fetch otherwise.
export const proxyFetch: typeof fetch = (input, init) =>
(getProxyAwareFetch() ?? fetch)(input, init);
+7
View File
@@ -21,6 +21,9 @@ import { ShareModule } from './share/share.module';
import { LabelModule } from './label/label.module';
import { NotificationModule } from './notification/notification.module';
import { WatcherModule } from './watcher/watcher.module';
import { IntegrationModule } from './integration/integration.module';
import { GitHubModule } from './integration/providers/github/github.module';
import { GitLabModule } from './integration/providers/gitlab/gitlab.module';
import { FavoriteModule } from './favorite/favorite.module';
import { SessionModule } from './session/session.module';
import { ClsMiddleware } from 'nestjs-cls';
@@ -43,6 +46,9 @@ import { ClsMiddleware } from 'nestjs-cls';
LabelModule,
NotificationModule,
WatcherModule,
IntegrationModule,
GitHubModule,
GitLabModule,
SessionModule,
],
})
@@ -53,6 +59,7 @@ export class CoreModule implements NestModule {
{ path: 'health', method: RequestMethod.GET },
{ path: 'health/live', method: RequestMethod.GET },
{ path: 'billing/stripe/webhook', method: RequestMethod.POST },
{ path: 'integrations/oauth/*/callback', method: RequestMethod.GET },
];
consumer
@@ -0,0 +1,9 @@
export enum IntegrationType {
SLACK = 'slack',
GITHUB = 'github',
GITLAB = 'gitlab',
JIRA = 'jira',
LINEAR = 'linear',
GOOGLE_DOCS = 'google_docs',
FIGMA = 'figma',
}
@@ -0,0 +1,36 @@
import * as crypto from 'crypto';
function deriveEncryptionKey(appSecret: string): Buffer {
return crypto.createHash('sha256').update(appSecret).digest();
}
export function encryptToken(token: string, appSecret: string): string {
const algorithm = 'aes-256-gcm';
const key = deriveEncryptionKey(appSecret);
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(algorithm, key, iv);
let encrypted = cipher.update(token, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted;
}
export function decryptToken(encryptedToken: string, appSecret: string): string {
const algorithm = 'aes-256-gcm';
const key = deriveEncryptionKey(appSecret);
const parts = encryptedToken.split(':');
const iv = Buffer.from(parts[0], 'hex');
const authTag = Buffer.from(parts[1], 'hex');
const encrypted = parts[2];
const decipher = crypto.createDecipheriv(algorithm, key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
@@ -0,0 +1,42 @@
import { z } from 'zod';
export const githubSettingsSchema = z.object({
baseUrl: z.string().url().optional(),
});
export const gitlabSettingsSchema = z.object({
baseUrl: z.string().url().optional(),
});
export const jiraSettingsSchema = z.object({
baseUrl: z.string().url().optional(),
});
const integrationSettingsSchemas: Record<string, z.ZodType> = {
github: githubSettingsSchema,
gitlab: gitlabSettingsSchema,
jira: jiraSettingsSchema,
};
export function validateIntegrationSettings(
type: string,
settings: unknown,
): { success: true; data: Record<string, any> } | { success: false; error: string } {
const schema = integrationSettingsSchemas[type];
if (!schema) {
if (settings && typeof settings === 'object') {
return { success: true, data: settings as Record<string, any> };
}
return { success: true, data: {} };
}
const result = schema.safeParse(settings);
if (!result.success) {
const messages = result.error.issues.map(
(i) => `${i.path.join('.')}: ${i.message}`,
);
return { success: false, error: messages.join(', ') };
}
return { success: true, data: result.data };
}
@@ -0,0 +1,68 @@
import {
IsNotEmpty,
IsObject,
IsOptional,
IsString,
Matches,
MaxLength,
} from 'class-validator';
export class InstallIntegrationDto {
@IsNotEmpty()
@IsString()
type: string;
}
export class UninstallIntegrationDto {
@IsNotEmpty()
@IsString()
integrationId: string;
}
export class UpdateIntegrationDto {
@IsNotEmpty()
@IsString()
integrationId: string;
@IsOptional()
@IsObject()
settings?: Record<string, any>;
}
export class IntegrationIdDto {
@IsNotEmpty()
@IsString()
integrationId: string;
}
export class UnfurlDto {
@IsNotEmpty()
@IsString()
url: string;
}
export class OAuthAuthorizeDto {
@IsNotEmpty()
@IsString()
integrationId: string;
// In-app path to land on after OAuth; single leading slash keeps the
// redirect on the workspace origin.
@IsOptional()
@IsString()
@MaxLength(512)
@Matches(/^\/(?!\/)[^\s\\]*$/)
returnPath?: string;
}
export class OAuthDisconnectDto {
@IsNotEmpty()
@IsString()
integrationId: string;
}
export class OAuthInstallDto {
@IsNotEmpty()
@IsString()
type: string;
}
@@ -0,0 +1,87 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { IntegrationConnectionRepo } from './repos/integration-connection.repo';
import { IntegrationRepo } from './repos/integration.repo';
import { IntegrationConnection } from '@docmost/db/types/entity.types';
import { UnfurlService } from './unfurl/unfurl.service';
@Injectable()
export class IntegrationConnectionService {
constructor(
private readonly connectionRepo: IntegrationConnectionRepo,
private readonly integrationRepo: IntegrationRepo,
private readonly unfurlService: UnfurlService,
) {}
async getConnectionStatus(
integrationId: string,
userId: string,
workspaceId: string,
): Promise<{ connected: boolean; providerUserId?: string }> {
const integration = await this.integrationRepo.findById(integrationId);
if (!integration || integration.workspaceId !== workspaceId) {
throw new NotFoundException('Integration not found');
}
const connection = await this.connectionRepo.findByIntegrationAndUser(
integrationId,
userId,
);
return {
connected: !!connection && !connection.invalidatedAt,
providerUserId: connection?.providerUserId ?? undefined,
};
}
async findByIntegrationAndUser(
integrationId: string,
userId: string,
): Promise<IntegrationConnection | undefined> {
return this.connectionRepo.findByIntegrationAndUser(integrationId, userId);
}
async findByWorkspaceTypeAndUser(
workspaceId: string,
integrationType: string,
userId: string,
): Promise<IntegrationConnection | undefined> {
return this.connectionRepo.findByWorkspaceTypeAndUser(
workspaceId,
integrationType,
userId,
);
}
async getUserConnections(userId: string, workspaceId: string) {
const rows = await this.connectionRepo.findByUserAndWorkspace(
userId,
workspaceId,
);
return rows.map((row) => ({
integrationId: row.integrationId,
type: row.type,
providerUserId: row.providerUserId ?? null,
connectedAt: row.createdAt,
invalidatedAt: row.invalidatedAt ?? null,
}));
}
async disconnect(
integrationId: string,
userId: string,
workspaceId: string,
): Promise<void> {
const integration = await this.integrationRepo.findById(integrationId);
if (!integration || integration.workspaceId !== workspaceId) {
throw new NotFoundException('Integration not found');
}
await this.connectionRepo.deleteByIntegrationAndUser(
integrationId,
userId,
);
await this.unfurlService.purgeUserCache(workspaceId, userId);
}
}
@@ -0,0 +1,162 @@
import {
Body,
Controller,
ForbiddenException,
HttpCode,
HttpStatus,
Post,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { IntegrationService } from './integration.service';
import { IntegrationConnectionService } from './integration-connection.service';
import {
InstallIntegrationDto,
UninstallIntegrationDto,
UpdateIntegrationDto,
IntegrationIdDto,
} from './dto/integration.dto';
import { IntegrationRegistry } from './registry/integration-registry';
import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory';
import {
WorkspaceCaslAction,
WorkspaceCaslSubject,
} from '../casl/interfaces/workspace-ability.type';
import { LicenseCheckService } from '../../integrations/environment/license-check.service';
import { Feature } from '../../common/features';
@Controller('integrations')
export class IntegrationController {
constructor(
private readonly integrationService: IntegrationService,
private readonly connectionService: IntegrationConnectionService,
private readonly workspaceAbility: WorkspaceAbilityFactory,
private readonly licenseCheckService: LicenseCheckService,
private readonly registry: IntegrationRegistry,
) {}
private assertIntegrationsLicensed(workspace: Workspace) {
if (
!this.licenseCheckService.hasFeature(
workspace.licenseKey,
Feature.INTEGRATIONS,
workspace.plan,
)
) {
throw new ForbiddenException('This feature requires a valid license');
}
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('available')
async getAvailableIntegrations() {
return this.integrationService.getAvailableIntegrations();
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('list')
async getInstalledIntegrations(
@AuthWorkspace() workspace: Workspace,
) {
return this.integrationService.getInstalledIntegrations(workspace.id);
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('install')
async install(
@Body() dto: InstallIntegrationDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const ability = this.workspaceAbility.createForUser(user, workspace);
if (
ability.cannot(
WorkspaceCaslAction.Manage,
WorkspaceCaslSubject.Settings,
)
) {
throw new ForbiddenException();
}
if (this.registry.getProvider(dto.type)?.definition.requiresLicense) {
this.assertIntegrationsLicensed(workspace);
}
return this.integrationService.install(dto.type, workspace.id, user.id);
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('uninstall')
async uninstall(
@Body() dto: UninstallIntegrationDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const ability = this.workspaceAbility.createForUser(user, workspace);
if (
ability.cannot(
WorkspaceCaslAction.Manage,
WorkspaceCaslSubject.Settings,
)
) {
throw new ForbiddenException();
}
await this.integrationService.uninstall(dto.integrationId, workspace.id);
return { success: true };
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('update')
async update(
@Body() dto: UpdateIntegrationDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const ability = this.workspaceAbility.createForUser(user, workspace);
if (
ability.cannot(
WorkspaceCaslAction.Manage,
WorkspaceCaslSubject.Settings,
)
) {
throw new ForbiddenException();
}
return this.integrationService.update(dto.integrationId, workspace.id, {
settings: dto.settings,
});
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('connections/mine')
async getMyConnections(
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
return this.connectionService.getUserConnections(user.id, workspace.id);
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('connection/status')
async getConnectionStatus(
@Body() dto: IntegrationIdDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
return this.connectionService.getConnectionStatus(
dto.integrationId,
user.id,
workspace.id,
);
}
}
@@ -0,0 +1,55 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { QueueJob, QueueName } from '../../integrations/queue/constants';
import { EventName } from '../../common/events/event.contants';
const TOKEN_REFRESH_SCHEDULER_ID = 'integration-token-refresh-scheduler';
const TOKEN_REFRESH_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes
@Injectable()
export class IntegrationListener implements OnApplicationBootstrap {
private readonly logger = new Logger(IntegrationListener.name);
constructor(
@InjectQueue(QueueName.INTEGRATION_QUEUE)
private readonly integrationQueue: Queue,
) {}
async onApplicationBootstrap() {
await this.integrationQueue.upsertJobScheduler(
TOKEN_REFRESH_SCHEDULER_ID,
{ every: TOKEN_REFRESH_INTERVAL_MS },
{
name: QueueJob.INTEGRATION_TOKEN_REFRESH,
data: {},
},
);
this.logger.debug('Integration token refresh scheduler created');
}
@OnEvent(EventName.PAGE_CREATED)
async onPageCreated(payload: any) {
await this.integrationQueue.add(QueueJob.INTEGRATION_EVENT, {
eventName: EventName.PAGE_CREATED,
...payload,
});
}
@OnEvent(EventName.PAGE_UPDATED)
async onPageUpdated(payload: any) {
await this.integrationQueue.add(QueueJob.INTEGRATION_EVENT, {
eventName: EventName.PAGE_UPDATED,
...payload,
});
}
@OnEvent(EventName.PAGE_DELETED)
async onPageDeleted(payload: any) {
await this.integrationQueue.add(QueueJob.INTEGRATION_EVENT, {
eventName: EventName.PAGE_DELETED,
...payload,
});
}
}
@@ -0,0 +1,39 @@
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 { IntegrationWebhookRepo } from './repos/integration-webhook.repo';
import { IntegrationListener } from './integration.listener';
import { IntegrationProcessor } from './integration.processor';
@Module({
controllers: [IntegrationController, OAuthController, UnfurlController],
providers: [
IntegrationRegistry,
IntegrationService,
IntegrationConnectionService,
OAuthService,
UnfurlService,
IntegrationRepo,
IntegrationConnectionRepo,
IntegrationWebhookRepo,
IntegrationListener,
IntegrationProcessor,
],
exports: [
IntegrationRegistry,
IntegrationService,
IntegrationConnectionService,
OAuthService,
IntegrationRepo,
IntegrationConnectionRepo,
],
})
export class IntegrationModule {}
@@ -0,0 +1,133 @@
import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger, NotFoundException } from '@nestjs/common';
import { IntegrationConnection } from '@docmost/db/types/entity.types';
import { TokenInvalidError } from './registry/integration-provider.interface';
import { Job } from 'bullmq';
import { QueueJob, QueueName } from '../../integrations/queue/constants/queue.constants';
import { IntegrationRegistry } from './registry/integration-registry';
import { IntegrationRepo } from './repos/integration.repo';
import { IntegrationConnectionRepo } from './repos/integration-connection.repo';
import { OAuthService } from './oauth/oauth.service';
const TOKEN_REFRESH_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
@Processor(QueueName.INTEGRATION_QUEUE)
export class IntegrationProcessor extends WorkerHost {
private readonly logger = new Logger(IntegrationProcessor.name);
constructor(
private readonly registry: IntegrationRegistry,
private readonly integrationRepo: IntegrationRepo,
private readonly connectionRepo: IntegrationConnectionRepo,
private readonly oauthService: OAuthService,
) {
super();
}
async process(job: Job): Promise<void> {
switch (job.name) {
case QueueJob.INTEGRATION_EVENT:
await this.handleIntegrationEvent(job);
break;
case QueueJob.INTEGRATION_TOKEN_REFRESH:
await this.handleTokenRefresh();
break;
default:
this.logger.warn(`Unknown job: ${job.name}`);
}
}
// Route worker-level errors (e.g. lock renewal after laptop sleep) through
// the logger instead of bullmq's raw console.error fallback.
@OnWorkerEvent('error')
onError(err: Error): void {
this.logger.error(`Worker error: ${err.message}`);
}
private async handleTokenRefresh(): Promise<void> {
const connections = await this.connectionRepo.findExpiringTokens(
TOKEN_REFRESH_WINDOW_MS,
);
if (connections.length === 0) {
return;
}
this.logger.log(
`Refreshing tokens for ${connections.length} connection(s)`,
);
for (const connection of connections) {
try {
await this.oauthService.getValidAccessToken(connection);
} catch (err) {
this.logger.error(
`Token refresh failed for connection ${connection.id}: ${(err as Error).message}`,
);
// Dead credential or orphaned row: retire it so findExpiringTokens stops selecting it.
if (
err instanceof NotFoundException ||
err instanceof TokenInvalidError
) {
await this.connectionRepo
.invalidate(connection.id)
.catch(() => undefined);
}
}
}
}
private async handleIntegrationEvent(job: Job): Promise<void> {
const { eventName, workspaceId, ...payload } = job.data;
if (!workspaceId) {
return;
}
const integrations =
await this.integrationRepo.findAllByWorkspace(workspaceId);
for (const integration of integrations) {
const provider = this.registry.getProvider(integration.type);
if (!provider?.handleEvent) {
continue;
}
let connection: IntegrationConnection | undefined;
try {
const connections = await this.connectionRepo.findByIntegration(
integration.id,
);
connection = connections[0];
let accessToken: string | undefined;
if (connection) {
accessToken = await this.oauthService.getValidAccessToken(connection);
}
await provider.handleEvent({
eventName,
payload,
integration: {
id: integration.id,
type: integration.type,
settings: integration.settings as Record<string, any> | null,
},
connection: connection
? { accessToken, userId: connection.userId }
: undefined,
});
} catch (err) {
this.logger.error(
`Integration event handler failed for ${integration.type}: ${(err as Error).message}`,
);
if (err instanceof TokenInvalidError && connection) {
await this.connectionRepo
.invalidate(connection.id)
.catch(() => undefined);
}
}
}
}
}
@@ -0,0 +1,103 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { executeTx } from '@docmost/db/utils';
import { IntegrationRepo } from './repos/integration.repo';
import { IntegrationConnectionRepo } from './repos/integration-connection.repo';
import { IntegrationWebhookRepo } from './repos/integration-webhook.repo';
import { IntegrationRegistry } from './registry/integration-registry';
import { Integration } from '@docmost/db/types/entity.types';
import { validateIntegrationSettings } from './dto/integration-settings.schema';
@Injectable()
export class IntegrationService {
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly integrationRepo: IntegrationRepo,
private readonly connectionRepo: IntegrationConnectionRepo,
private readonly webhookRepo: IntegrationWebhookRepo,
private readonly registry: IntegrationRegistry,
) {}
async getAvailableIntegrations() {
return this.registry.getAvailableIntegrations();
}
async getInstalledIntegrations(workspaceId: string): Promise<Integration[]> {
return this.integrationRepo.findAllByWorkspace(workspaceId);
}
async findById(integrationId: string): Promise<Integration | undefined> {
return this.integrationRepo.findById(integrationId);
}
async install(
type: string,
workspaceId: string,
userId: string,
): Promise<Integration> {
const provider = this.registry.getProvider(type);
if (!provider || provider.definition.hidden) {
throw new BadRequestException(`Unknown integration type: ${type}`);
}
const existing = await this.integrationRepo.findByWorkspaceAndType(
workspaceId,
type,
);
if (existing) {
throw new BadRequestException(
`Integration "${type}" is already installed`,
);
}
return this.integrationRepo.insertOrRestore({
type,
workspaceId,
installedById: userId,
});
}
async uninstall(integrationId: string, workspaceId: string): Promise<void> {
const integration = await this.integrationRepo.findById(integrationId);
if (!integration || integration.workspaceId !== workspaceId) {
throw new NotFoundException('Integration not found');
}
// Delete child rows first so no orphan connections keep feeding the token refresh scheduler.
await executeTx(this.db, async (trx) => {
await this.connectionRepo.deleteByIntegration(integrationId, trx);
await this.webhookRepo.deleteByIntegration(integrationId, trx);
await this.integrationRepo.softDelete(integrationId, trx);
});
}
async update(
integrationId: string,
workspaceId: string,
data: { settings?: Record<string, any> },
): Promise<Integration> {
const integration = await this.integrationRepo.findById(integrationId);
if (!integration || integration.workspaceId !== workspaceId) {
throw new NotFoundException('Integration not found');
}
if (data.settings !== undefined) {
const validation = validateIntegrationSettings(
integration.type,
data.settings,
);
if (validation.success === false) {
throw new BadRequestException(`Invalid settings: ${validation.error}`);
}
data.settings = validation.data;
}
return this.integrationRepo.update(integrationId, {
...(data.settings !== undefined && { settings: data.settings }),
});
}
}
@@ -0,0 +1,153 @@
import {
BadRequestException,
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Logger,
Param,
Post,
Query,
Res,
UseGuards,
} from '@nestjs/common';
import { FastifyReply } from 'fastify';
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
import { AuthUser } from '../../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { OAuthService } from './oauth.service';
import {
OAuthAuthorizeDto,
OAuthDisconnectDto,
OAuthInstallDto,
} from '../dto/integration.dto';
import { IntegrationConnectionService } from '../integration-connection.service';
import { IntegrationRegistry } from '../registry/integration-registry';
import { LicenseCheckService } from '../../../integrations/environment/license-check.service';
import { Feature } from '../../../common/features';
import { ForbiddenException } from '@nestjs/common';
@Controller('integrations/oauth')
export class OAuthController {
private readonly logger = new Logger(OAuthController.name);
constructor(
private readonly oauthService: OAuthService,
private readonly connectionService: IntegrationConnectionService,
private readonly licenseCheckService: LicenseCheckService,
private readonly registry: IntegrationRegistry,
) {}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('authorize')
async authorize(
@Body() dto: OAuthAuthorizeDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const { authorizationUrl } = await this.oauthService.getAuthorizationUrl(
dto.integrationId,
workspace.id,
user.id,
dto.returnPath,
);
return { authorizationUrl };
}
/**
* Install-and-authorize for workspace-scoped providers (Slack model).
* Returns the authorize URL without first creating the integration row;
* the row is created atomically on successful OAuth callback so a cancelled
* OAuth flow leaves no half-installed state.
*/
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('install')
async installAndAuthorize(
@Body() dto: OAuthInstallDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
// This flow creates the integration row on callback success; gate it
// like a plain install.
if (
this.registry.getProvider(dto.type)?.definition.requiresLicense &&
!this.licenseCheckService.hasFeature(
workspace.licenseKey,
Feature.INTEGRATIONS,
workspace.plan,
)
) {
throw new ForbiddenException('This feature requires a valid license');
}
const { authorizationUrl } = await this.oauthService.getInstallAuthorizationUrl(
dto.type,
workspace.id,
user.id,
);
return { authorizationUrl };
}
@Get(':type/callback')
async callback(
@Param('type') type: string,
@Query('code') code: string,
@Query('state') state: string,
@Res() res: FastifyReply,
) {
if (!code || !state) {
throw new BadRequestException('Missing code or state parameter');
}
const statePayload = this.oauthService.verifySignedState(state);
if (!statePayload) {
throw new BadRequestException('Invalid or expired OAuth state');
}
// returnUrl is derived server-side at authorize time from the workspace's
// own hostname/customDomain (canonical DB truth, not user input), then
// signed into the state JWT. Tampering would invalidate the signature.
const returnUrl = statePayload.returnUrl;
// States signed before returnPath existed fall back to the admin page.
const returnPath = statePayload.returnPath ?? '/settings/integrations';
try {
await this.oauthService.exchangeCodeForTokens(
type,
code,
statePayload.integrationId,
statePayload.userId,
statePayload.workspaceId,
);
return res.redirect(`${returnUrl}${returnPath}`, 302).send();
} catch (err) {
this.logger.error(`OAuth callback error for ${type}: ${(err as Error).message}`);
return res
.redirect(`${returnUrl}${returnPath}?error=oauth_failed`, 302)
.send();
}
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('disconnect')
async disconnect(
@Body() dto: OAuthDisconnectDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
await this.connectionService.disconnect(
dto.integrationId,
user.id,
workspace.id,
);
return { success: true };
}
}
@@ -0,0 +1,478 @@
import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
import { DomainService } from '../../../integrations/environment/domain.service';
import { IntegrationRegistry } from '../registry/integration-registry';
import { IntegrationRepo } from '../repos/integration.repo';
import { IntegrationConnectionRepo } from '../repos/integration-connection.repo';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { encryptToken, decryptToken } from '../crypto/token-crypto';
import { IntegrationConnection } from '@docmost/db/types/entity.types';
import {
OAuthConfig,
TokenInvalidError,
} from '../registry/integration-provider.interface';
import { proxyFetch } from '../../../common/proxy-fetch';
import * as crypto from 'crypto';
const OAUTH_HTTP_TIMEOUT_MS = 10_000;
type OAuthTokenResponse = {
access_token: string;
refresh_token?: string;
expires_in?: number;
token_type?: string;
scope?: string;
};
export type OAuthStatePayload = {
// For "authorize-only" flows (per-user OAuth on an already-installed
// integration) integrationId is set; for "install-and-authorize" flows
// (workspace-scoped providers like Slack) it's null until the callback
// resolves-or-creates the row atomically with token exchange success.
integrationId: string | null;
type: string;
userId: string;
workspaceId: string;
// Workspace's canonical URL at authorize time. Cloud workspaces are routed
// through a single central OAuth callback (the only redirect_uri Slack/etc.
// accept), and this lets the callback redirect the user back to their own
// workspace host (subdomain or custom domain) after token exchange.
returnUrl: string;
// Settings page (relative to returnUrl) to land on after the callback.
// Derived server-side from the flow that started it, never from user input.
returnPath?: string;
exp: number;
};
@Injectable()
export class OAuthService {
private readonly logger = new Logger(OAuthService.name);
constructor(
private readonly environmentService: EnvironmentService,
private readonly domainService: DomainService,
private readonly registry: IntegrationRegistry,
private readonly integrationRepo: IntegrationRepo,
private readonly connectionRepo: IntegrationConnectionRepo,
private readonly workspaceRepo: WorkspaceRepo,
) {}
async getAuthorizationUrl(
integrationId: string,
workspaceId: string,
userId: string,
returnPathOverride?: string,
): Promise<{ authorizationUrl: string }> {
const integration = await this.integrationRepo.findById(integrationId);
if (!integration || integration.workspaceId !== workspaceId) {
throw new NotFoundException('Integration not found');
}
const provider = this.registry.getProvider(integration.type);
if (!provider || !provider.definition.oauth) {
throw new BadRequestException('Integration does not support OAuth');
}
const oauthConfig = provider.getOAuthConfig
? provider.getOAuthConfig((integration.settings as Record<string, any>) ?? {})
: provider.definition.oauth;
const callbackUrl = this.buildCallbackUrl(integration.type);
const workspace = await this.workspaceRepo.findById(workspaceId);
const returnUrl = this.domainService.getWorkspaceUrl(
workspace ?? { hostname: null, customDomain: null },
);
// Per-user connects are initiated from the account connections page;
// workspace-scoped authorizes from the admin integrations page. A connect
// started elsewhere (e.g. an editor connect card) passes its own path.
const returnPath =
returnPathOverride ??
((provider.definition.oauth.connectionScope ?? 'user') === 'workspace'
? '/settings/integrations'
: '/settings/account/connections');
const state = this.createSignedState({
integrationId,
type: integration.type,
userId,
workspaceId,
returnUrl,
returnPath,
exp: Date.now() + 10 * 60 * 1000,
});
const params = new URLSearchParams({
client_id: this.getClientId(integration.type),
redirect_uri: callbackUrl,
response_type: 'code',
state,
});
const scope = oauthConfig.scopes
.map((s) => encodeURIComponent(s))
.join('%20');
return {
authorizationUrl: `${oauthConfig.authUrl}?${params.toString()}&scope=${scope}`,
};
}
/**
* Install-and-authorize for workspace-scoped providers (Slack model).
*
* Skips creating the integration row up front. The callback (atomic with
* token exchange success) is what actually persists the integration; if the
* user cancels at Slack's consent screen, nothing is written. Refusing the
* already-installed case here keeps the install button idempotent.
*/
async getInstallAuthorizationUrl(
type: string,
workspaceId: string,
userId: string,
): Promise<{ authorizationUrl: string }> {
const provider = this.registry.getProvider(type);
if (!provider || !provider.definition.oauth) {
throw new BadRequestException('Integration does not support OAuth');
}
if (provider.definition.oauth.connectionScope !== 'workspace') {
throw new BadRequestException(
'This integration uses per-user OAuth; use the standard install + authorize flow',
);
}
const existing = await this.integrationRepo.findByWorkspaceAndType(
workspaceId,
type,
);
if (existing) {
throw new BadRequestException(
`Integration "${type}" is already installed`,
);
}
const oauthConfig = provider.getOAuthConfig
? provider.getOAuthConfig({})
: provider.definition.oauth;
const callbackUrl = this.buildCallbackUrl(type);
const workspace = await this.workspaceRepo.findById(workspaceId);
const returnUrl = this.domainService.getWorkspaceUrl(
workspace ?? { hostname: null, customDomain: null },
);
const state = this.createSignedState({
integrationId: null,
type,
userId,
workspaceId,
returnUrl,
returnPath: '/settings/integrations',
exp: Date.now() + 10 * 60 * 1000,
});
const params = new URLSearchParams({
client_id: this.getClientId(type),
redirect_uri: callbackUrl,
response_type: 'code',
state,
});
const scope = oauthConfig.scopes
.map((s) => encodeURIComponent(s))
.join('%20');
return {
authorizationUrl: `${oauthConfig.authUrl}?${params.toString()}&scope=${scope}`,
};
}
verifySignedState(state: string): OAuthStatePayload | null {
const dotIndex = state.lastIndexOf('.');
if (dotIndex === -1) return null;
const data = state.substring(0, dotIndex);
const signature = state.substring(dotIndex + 1);
const secret = this.environmentService.getAppSecret();
const expected = crypto
.createHmac('sha256', secret)
.update(data)
.digest('base64url');
if (signature !== expected) return null;
try {
const payload: OAuthStatePayload = JSON.parse(
Buffer.from(data, 'base64url').toString(),
);
if (payload.exp < Date.now()) return null;
return payload;
} catch {
return null;
}
}
async exchangeCodeForTokens(
type: string,
code: string,
integrationId: string | null,
userId: string,
workspaceId: string,
): Promise<IntegrationConnection> {
const provider = this.registry.getProvider(type);
if (!provider || !provider.definition.oauth) {
throw new BadRequestException('Integration does not support OAuth');
}
// Install-and-authorize flow (workspace-scoped providers): no integration
// row exists yet. Create or restore it now that OAuth has succeeded.
let integration = integrationId
? await this.integrationRepo.findById(integrationId)
: null;
if (!integration) {
integration = await this.integrationRepo.insertOrRestore({
type,
workspaceId,
installedById: userId,
});
integrationId = integration.id;
}
const settings = (integration.settings as Record<string, any>) ?? {};
const oauthConfig = provider.getOAuthConfig
? provider.getOAuthConfig(settings)
: provider.definition.oauth;
const tokenResponse = await this.requestTokens(
oauthConfig,
type,
code,
);
const appSecret = this.environmentService.getAppSecret();
const encryptedAccessToken = encryptToken(
tokenResponse.access_token,
appSecret,
);
const encryptedRefreshToken = tokenResponse.refresh_token
? encryptToken(tokenResponse.refresh_token, appSecret)
: null;
const tokenExpiresAt = tokenResponse.expires_in
? new Date(Date.now() + tokenResponse.expires_in * 1000)
: null;
const connectionScope =
provider.definition.oauth?.connectionScope ?? 'user';
const connection =
connectionScope === 'workspace'
? await this.connectionRepo.upsertWorkspaceConnection({
integrationId,
userId,
workspaceId,
accessToken: encryptedAccessToken,
refreshToken: encryptedRefreshToken,
tokenExpiresAt,
scopes: tokenResponse.scope ?? null,
})
: await this.connectionRepo.upsert({
integrationId,
userId,
workspaceId,
accessToken: encryptedAccessToken,
refreshToken: encryptedRefreshToken,
tokenExpiresAt,
scopes: tokenResponse.scope ?? null,
});
if (provider.onConnected) {
await provider.onConnected({
integrationId,
workspaceId,
accessToken: tokenResponse.access_token,
refreshToken: tokenResponse.refresh_token,
providerUserId: '',
metadata: tokenResponse,
});
}
return connection;
}
async getValidAccessToken(
connection: IntegrationConnection,
): Promise<string> {
if (connection.invalidatedAt) {
throw new TokenInvalidError();
}
const appSecret = this.environmentService.getAppSecret();
const accessToken = decryptToken(connection.accessToken, appSecret);
const needsRefresh =
connection.tokenExpiresAt &&
connection.refreshToken &&
new Date(connection.tokenExpiresAt).getTime() - Date.now() < 5 * 60 * 1000;
if (!needsRefresh) {
return accessToken;
}
return this.refreshAccessToken(connection);
}
private async refreshAccessToken(
connection: IntegrationConnection,
): Promise<string> {
const appSecret = this.environmentService.getAppSecret();
const refreshToken = decryptToken(connection.refreshToken, appSecret);
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<string, any>) ?? {})
: provider.definition.oauth;
const params = new URLSearchParams({
grant_type: 'refresh_token',
client_id: this.getClientId(integration.type),
client_secret: this.getClientSecret(integration.type),
refresh_token: refreshToken,
});
try {
const response = await proxyFetch(oauthConfig.tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
body: params.toString(),
signal: AbortSignal.timeout(OAUTH_HTTP_TIMEOUT_MS),
});
if (!response.ok) {
this.logger.error(
`Token refresh failed for ${integration.type}: ${response.status}`,
);
// 400/401 from the token endpoint means invalid_grant/invalid_client:
// the refresh token is dead, not a transient failure.
if (response.status === 400 || response.status === 401) {
throw new TokenInvalidError(
`Refresh token rejected for ${integration.type}`,
);
}
throw new BadRequestException('Token refresh failed');
}
const data: OAuthTokenResponse = await response.json();
const encryptedAccessToken = encryptToken(data.access_token, appSecret);
const encryptedRefreshToken = data.refresh_token
? encryptToken(data.refresh_token, appSecret)
: connection.refreshToken;
const tokenExpiresAt = data.expires_in
? new Date(Date.now() + data.expires_in * 1000)
: null;
await this.connectionRepo.update(connection.id, {
accessToken: encryptedAccessToken,
refreshToken: encryptedRefreshToken,
tokenExpiresAt,
invalidatedAt: null,
});
return data.access_token;
} catch (err) {
if (err instanceof TokenInvalidError) {
throw err;
}
this.logger.error(`Token refresh error: ${(err as Error).message}`);
throw new BadRequestException('Failed to refresh token');
}
}
private async requestTokens(
oauthConfig: OAuthConfig,
type: string,
code: string,
): Promise<OAuthTokenResponse> {
const params = new URLSearchParams({
grant_type: 'authorization_code',
client_id: this.getClientId(type),
client_secret: this.getClientSecret(type),
code,
redirect_uri: this.buildCallbackUrl(type),
});
const response = await proxyFetch(oauthConfig.tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
body: params.toString(),
signal: AbortSignal.timeout(OAUTH_HTTP_TIMEOUT_MS),
});
if (!response.ok) {
const body = await response.text();
this.logger.error(`Token exchange failed for ${type}: ${response.status} ${body}`);
throw new BadRequestException('OAuth token exchange failed');
}
return response.json();
}
buildCallbackUrl(type: string): string {
const appUrl = this.environmentService.getAppUrl();
return `${appUrl}/api/integrations/oauth/${type}/callback`;
}
private createSignedState(payload: OAuthStatePayload): string {
const data = Buffer.from(JSON.stringify(payload)).toString('base64url');
const secret = this.environmentService.getAppSecret();
const signature = crypto
.createHmac('sha256', secret)
.update(data)
.digest('base64url');
return `${data}.${signature}`;
}
private getClientId(type: string): string {
const envKey = `INTEGRATION_${type.toUpperCase()}_CLIENT_ID`;
const value = process.env[envKey];
if (!value) {
throw new BadRequestException(
`Missing environment variable: ${envKey}`,
);
}
return value;
}
private getClientSecret(type: string): string {
const envKey = `INTEGRATION_${type.toUpperCase()}_CLIENT_SECRET`;
const value = process.env[envKey];
if (!value) {
throw new BadRequestException(
`Missing environment variable: ${envKey}`,
);
}
return value;
}
}
@@ -0,0 +1,72 @@
import { UnfurlPattern } from '../../registry/integration-provider.interface';
function escapeForRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function buildGitHubPatterns(baseUrl: string): UnfurlPattern[] {
const escaped = escapeForRegex(baseUrl);
return [
// Commit within a PR: /:owner/:repo/pull/:num/commits/:sha
{
regex: new RegExp(
`^${escaped}\\/([^\\/]+)\\/([^\\/]+)\\/pull\\/(\\d+)\\/commits\\/([a-f0-9]+)`,
),
type: 'github-pr-commit',
},
// PR sub-pages: /:owner/:repo/pull/:num(/checks|/commits|/files)?
{
regex: new RegExp(`^${escaped}\\/([^\\/]+)\\/([^\\/]+)\\/pull\\/(\\d+)`),
type: 'github-pr',
},
// Single issue: /:owner/:repo/issues/:num
{
regex: new RegExp(
`^${escaped}\\/([^\\/]+)\\/([^\\/]+)\\/issues\\/(\\d+)`,
),
type: 'github-issue',
},
// Commit: /:owner/:repo/commit(s)/:sha
{
regex: new RegExp(
`^${escaped}\\/([^\\/]+)\\/([^\\/]+)\\/commits?\\/([a-f0-9]+)`,
),
type: 'github-commit',
},
// File/blob: /:owner/:repo/blob/:ref/:path(#L:start(-L:end))?
{
regex: new RegExp(
`^${escaped}\\/([^\\/]+)\\/([^\\/]+)\\/blob\\/([^\\/]+)\\/(.+?)(?:#L(\\d+)(?:-L(\\d+))?)?$`,
),
type: 'github-file',
},
// Pulls list: /:owner/:repo/pulls
{
regex: new RegExp(
`^${escaped}\\/([^\\/]+)\\/([^\\/]+)\\/pulls(?:\\/.*)?(?:\\?.*)?$`,
),
type: 'github-pulls-list',
},
// Issues list: /:owner/:repo/issues(/created_by/...|/assigned/...)?
{
regex: new RegExp(
`^${escaped}\\/([^\\/]+)\\/([^\\/]+)\\/issues(?:\\/(?:created_by|assigned)\\/[\\w.\\/-]+)?\\/?(?:\\?.*)?$`,
),
type: 'github-issues-list',
},
// Releases: /:owner/:repo/releases
{
regex: new RegExp(
`^${escaped}\\/([^\\/]+)\\/([^\\/]+)\\/releases(?:\\/.*)?(?:\\?.*)?$`,
),
type: 'github-releases-list',
},
// Repo: /:owner/:repo
{
regex: new RegExp(
`^${escaped}\\/([a-zA-Z0-9\\-_.]+)\\/([a-zA-Z0-9\\-_.]+)\\/?$`,
),
type: 'github-repo',
},
];
}
@@ -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);
}
}
@@ -0,0 +1,157 @@
import { Injectable } from '@nestjs/common';
import {
IntegrationProvider,
IntegrationDefinition,
LinkDescription,
OAuthConfig,
UnfurlPattern,
UnfurlOpts,
UnfurlResult,
} from '../../registry/integration-provider.interface';
import { GitHubService } from './github.service';
import { buildGitHubPatterns } from './github-patterns';
const DEFAULT_BASE_URL = 'https://github.com';
@Injectable()
export class GitHubProvider extends IntegrationProvider {
definition: IntegrationDefinition = {
type: 'github',
name: 'GitHub',
description: 'Link previews for repos, pull requests, issues, commits, and files',
icon: 'github',
capabilities: ['oauth', 'unfurl'],
oauth: {
authUrl: 'https://github.com/login/oauth/authorize',
tokenUrl: 'https://github.com/login/oauth/access_token',
scopes: ['repo', 'read:user'],
},
unfurlPatterns: buildGitHubPatterns('https://github.com'),
};
constructor(private readonly githubService: GitHubService) {
super();
}
getOAuthConfig(settings: Record<string, any>): OAuthConfig {
const baseUrl = this.resolveBaseUrl(settings);
return {
authUrl: `${baseUrl}/login/oauth/authorize`,
tokenUrl: `${baseUrl}/login/oauth/access_token`,
scopes: ['repo', 'read:user'],
};
}
getUnfurlPatterns(settings: Record<string, any>): UnfurlPattern[] {
const baseUrl = this.resolveBaseUrl(settings);
if (baseUrl === DEFAULT_BASE_URL) return [];
return buildGitHubPatterns(baseUrl);
}
async unfurl(opts: UnfurlOpts): Promise<UnfurlResult> {
const { match, patternType, accessToken, url } = opts;
const apiBaseUrl = this.resolveApiBaseUrl(url);
const owner = match[1];
const repo = match[2];
switch (patternType) {
case 'github-pr': {
const number = parseInt(match[3], 10);
return this.githubService.unfurlPullRequest(
accessToken, apiBaseUrl, owner, repo, number, url,
);
}
case 'github-issue': {
const number = parseInt(match[3], 10);
return this.githubService.unfurlIssue(
accessToken, apiBaseUrl, owner, repo, number, url,
);
}
case 'github-repo':
return this.githubService.unfurlRepo(
accessToken, apiBaseUrl, owner, repo, url,
);
case 'github-commit': {
const sha = match[3];
return this.githubService.unfurlCommit(
accessToken, apiBaseUrl, owner, repo, sha, url,
);
}
case 'github-pr-commit': {
const sha = match[4];
return this.githubService.unfurlCommit(
accessToken, apiBaseUrl, owner, repo, sha, url,
);
}
case 'github-file': {
const ref = match[3];
const path = match[4];
const startLine = match[5] ? parseInt(match[5], 10) : undefined;
const endLine = match[6] ? parseInt(match[6], 10) : undefined;
return this.githubService.unfurlFile(
owner, repo, ref, path, startLine, endLine, url,
);
}
case 'github-pulls-list':
case 'github-issues-list':
case 'github-releases-list':
return this.githubService.unfurlCollectionPage(
accessToken, apiBaseUrl, owner, repo, patternType.replace('github-', ''), url,
);
default:
throw new Error(`Unknown GitHub pattern type: ${patternType}`);
}
}
describeLink(
patternType: string,
match: RegExpMatchArray,
): LinkDescription | null {
const repo = `${match[1]}/${match[2]}`;
switch (patternType) {
case 'github-pr':
return { title: `Pull Request #${match[3]}`, description: repo };
case 'github-pr-commit':
return { title: `Commit ${match[4].slice(0, 7)}`, description: repo };
case 'github-issue':
return { title: `Issue #${match[3]}`, description: repo };
case 'github-commit':
return { title: `Commit ${match[3].slice(0, 7)}`, description: repo };
case 'github-file':
return { title: match[4], description: repo };
case 'github-pulls-list':
return { title: 'Pull Requests', description: repo };
case 'github-issues-list':
return { title: 'Issues', description: repo };
case 'github-releases-list':
return { title: 'Releases', description: repo };
case 'github-repo':
return { title: repo };
default:
return null;
}
}
private resolveBaseUrl(settings: Record<string, any>): string {
// env wins: the OAuth app credentials in env are registered on that instance
const baseUrl =
process.env.INTEGRATION_GITHUB_BASE_URL ||
(settings?.baseUrl as string | undefined);
return baseUrl ? baseUrl.replace(/\/+$/, '') : DEFAULT_BASE_URL;
}
private resolveApiBaseUrl(url: string): string {
const parsed = new URL(url);
if (parsed.hostname === 'github.com') {
return 'https://api.github.com';
}
return `${parsed.origin}/api/v3`;
}
}
@@ -0,0 +1,267 @@
import { Injectable, Logger } from '@nestjs/common';
import { UnfurlResult } from '../../registry/integration-provider.interface';
import { relativeTime } from '../../utils/relative-time';
import { providerApiFetch } from '../../utils/provider-fetch';
@Injectable()
export class GitHubService {
private readonly logger = new Logger(GitHubService.name);
async unfurlPullRequest(
accessToken: string,
apiBaseUrl: string,
owner: string,
repo: string,
number: number,
url: string,
): Promise<UnfurlResult> {
const data = await this.apiGet(
accessToken,
apiBaseUrl,
`/repos/${owner}/${repo}/pulls/${number}`,
);
const prAuthor = data.user?.login;
const prDesc = [
`#${data.number}`,
relativeTime(data.updated_at ?? data.created_at),
prAuthor,
].filter(Boolean).join(' · ');
return {
title: data.title,
description: prDesc,
url,
provider: 'github',
providerIcon: 'github',
status: this.formatPrStatus(data),
statusColor: this.getPrStatusColor(data),
author: prAuthor,
authorAvatarUrl: data.user?.avatar_url,
metadata: {
type: 'pr',
number: data.number,
repo: `${owner}/${repo}`,
labels: data.labels?.map((l: any) => l.name) ?? [],
draft: data.draft,
additions: data.additions,
deletions: data.deletions,
},
};
}
async unfurlIssue(
accessToken: string,
apiBaseUrl: string,
owner: string,
repo: string,
number: number,
url: string,
): Promise<UnfurlResult> {
const data = await this.apiGet(
accessToken,
apiBaseUrl,
`/repos/${owner}/${repo}/issues/${number}`,
);
const issueAuthor = data.user?.login;
const issueDesc = [
`#${data.number}`,
relativeTime(data.updated_at ?? data.created_at),
issueAuthor,
].filter(Boolean).join(' · ');
return {
title: data.title,
description: issueDesc,
url,
provider: 'github',
providerIcon: 'github',
status: data.state,
statusColor: data.state === 'open' ? 'green' : 'purple',
author: issueAuthor,
authorAvatarUrl: data.user?.avatar_url,
metadata: {
type: 'issue',
number: data.number,
repo: `${owner}/${repo}`,
labels: data.labels?.map((l: any) => l.name) ?? [],
assignees: data.assignees?.map((a: any) => a.login) ?? [],
},
};
}
async unfurlRepo(
accessToken: string,
apiBaseUrl: string,
owner: string,
repo: string,
url: string,
): Promise<UnfurlResult> {
const data = await this.apiGet(
accessToken,
apiBaseUrl,
`/repos/${owner}/${repo}`,
);
const visibility = data.private ? 'Private' : 'Public';
return {
title: data.full_name,
description: data.description?.slice(0, 200) ?? undefined,
url,
provider: 'github',
providerIcon: 'github',
status: visibility,
statusColor: data.private ? 'gray' : 'green',
author: data.owner?.login,
authorAvatarUrl: data.owner?.avatar_url,
metadata: {
type: 'repo',
repo: `${owner}/${repo}`,
stars: data.stargazers_count,
forks: data.forks_count,
language: data.language,
defaultBranch: data.default_branch,
},
};
}
async unfurlCommit(
accessToken: string,
apiBaseUrl: string,
owner: string,
repo: string,
sha: string,
url: string,
): Promise<UnfurlResult> {
const data = await this.apiGet(
accessToken,
apiBaseUrl,
`/repos/${owner}/${repo}/commits/${sha}`,
);
const shortSha = data.sha?.slice(0, 7);
const commitAuthor = data.author?.login ?? data.commit?.author?.name;
const commitDesc = [
shortSha,
relativeTime(data.commit?.author?.date ?? data.commit?.committer?.date),
commitAuthor,
].filter(Boolean).join(' · ');
return {
title: data.commit?.message?.split('\n')[0] ?? shortSha,
description: commitDesc,
url,
provider: 'github',
providerIcon: 'github',
author: commitAuthor,
authorAvatarUrl: data.author?.avatar_url,
metadata: {
type: 'commit',
sha: data.sha,
shortSha,
repo: `${owner}/${repo}`,
stats: data.stats,
},
};
}
unfurlFile(
owner: string,
repo: string,
ref: string,
path: string,
startLine: number | undefined,
endLine: number | undefined,
url: string,
): UnfurlResult {
const fileName = path.split('/').pop() ?? path;
const lineRange = startLine
? endLine
? `L${startLine}-L${endLine}`
: `L${startLine}`
: undefined;
return {
title: lineRange ? `${fileName}#${lineRange}` : fileName,
description: `${owner}/${repo} · ${ref.slice(0, 7)}`,
url,
provider: 'github',
providerIcon: 'github',
metadata: {
type: 'file',
repo: `${owner}/${repo}`,
ref,
path,
startLine,
endLine,
},
};
}
async unfurlCollectionPage(
accessToken: string,
apiBaseUrl: string,
owner: string,
repo: string,
collectionType: string,
url: string,
): Promise<UnfurlResult> {
const data = await this.apiGet(
accessToken,
apiBaseUrl,
`/repos/${owner}/${repo}`,
);
const labels: Record<string, string> = {
'pulls-list': 'Pull Requests',
'issues-list': 'Issues',
'releases-list': 'Releases',
};
return {
title: `${labels[collectionType] ?? collectionType} · ${data.full_name}`,
description: `${owner}/${repo}`,
url,
provider: 'github',
providerIcon: 'github',
author: data.owner?.login,
authorAvatarUrl: data.owner?.avatar_url,
metadata: {
type: collectionType,
repo: `${owner}/${repo}`,
},
};
}
private formatPrStatus(pr: any): string {
if (pr.merged) return 'merged';
if (pr.draft) return 'draft';
return pr.state;
}
private getPrStatusColor(pr: any): string {
if (pr.merged) return 'purple';
if (pr.draft) return 'gray';
if (pr.state === 'open') return 'green';
return 'red';
}
private async apiGet(
accessToken: string,
apiBaseUrl: string,
path: string,
): Promise<any> {
const response = await providerApiFetch('GitHub', `${apiBaseUrl}${path}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'Docmost',
},
});
return response.json();
}
}
@@ -0,0 +1,68 @@
import { UnfurlPattern } from '../../registry/integration-provider.interface';
function escapeForRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function buildGitLabPatterns(baseUrl: string): UnfurlPattern[] {
const escaped = escapeForRegex(baseUrl);
return [
{
regex: new RegExp(
`^${escaped}\\/(.+)\\/-\\/merge_requests\\/(\\d+)\\/diffs\\?.*commit_id=([a-f0-9]+)`,
),
type: 'gitlab-commit-in-mr',
},
{
regex: new RegExp(
`^${escaped}\\/(.+)\\/-\\/merge_requests\\/(\\d+)`,
),
type: 'gitlab-mr',
},
{
regex: new RegExp(
`^${escaped}\\/(.+)\\/-\\/issues\\/(\\d+)`,
),
type: 'gitlab-issue',
},
// Issues renamed to work items; same iid, resolved via the issues API.
{
regex: new RegExp(
`^${escaped}\\/(.+)\\/-\\/work_items\\/(\\d+)`,
),
type: 'gitlab-issue',
},
// Work item opened as a drawer over the list; the target is base64 JSON
// in the show param, decoded by the provider.
{
regex: new RegExp(
`^${escaped}\\/(.+)\\/-\\/work_items\\/?\\?(?:.*&)?show=`,
),
type: 'gitlab-work-item-drawer',
},
{
regex: new RegExp(
`^${escaped}\\/(.+)\\/-\\/commits?\\/([a-f0-9]+)`,
),
type: 'gitlab-commit',
},
{
regex: new RegExp(
`^${escaped}\\/(.+)\\/-\\/issues(?:\\/)?(?:\\?.*)?$`,
),
type: 'gitlab-issues-list',
},
{
regex: new RegExp(
`^${escaped}\\/(.+)\\/-\\/merge_requests(?:\\/)?(?:\\?.*)?$`,
),
type: 'gitlab-merges-list',
},
{
regex: new RegExp(
`^${escaped}\\/([a-zA-Z0-9\\-_.]+)\\/([a-zA-Z0-9\\-_]+)\\/?$`,
),
type: 'gitlab-project',
},
];
}
@@ -0,0 +1,21 @@
import { Module, OnModuleInit } from '@nestjs/common';
import { GitLabProvider } from './gitlab.provider';
import { GitLabService } from './gitlab.service';
import { IntegrationRegistry } from '../../registry/integration-registry';
import { IntegrationModule } from '../../integration.module';
@Module({
imports: [IntegrationModule],
providers: [GitLabProvider, GitLabService],
exports: [GitLabProvider],
})
export class GitLabModule implements OnModuleInit {
constructor(
private readonly registry: IntegrationRegistry,
private readonly gitlabProvider: GitLabProvider,
) {}
onModuleInit() {
this.registry.register(this.gitlabProvider);
}
}
@@ -0,0 +1,191 @@
import { Injectable } from '@nestjs/common';
import {
IntegrationProvider,
IntegrationDefinition,
LinkDescription,
OAuthConfig,
UnfurlPattern,
UnfurlOpts,
UnfurlResult,
} from '../../registry/integration-provider.interface';
import { GitLabService } from './gitlab.service';
import { buildGitLabPatterns } from './gitlab-patterns';
const DEFAULT_BASE_URL = 'https://gitlab.com';
@Injectable()
export class GitLabProvider extends IntegrationProvider {
definition: IntegrationDefinition = {
type: 'gitlab',
name: 'GitLab',
description: 'Link previews for projects, merge requests, issues, and commits',
icon: 'gitlab',
capabilities: ['oauth', 'unfurl'],
oauth: {
authUrl: 'https://gitlab.com/oauth/authorize',
tokenUrl: 'https://gitlab.com/oauth/token',
scopes: ['read_api', 'read_user'],
},
unfurlPatterns: buildGitLabPatterns('https://gitlab.com'),
};
constructor(private readonly gitlabService: GitLabService) {
super();
}
getOAuthConfig(settings: Record<string, any>): OAuthConfig {
const baseUrl = this.resolveBaseUrl(settings);
return {
authUrl: `${baseUrl}/oauth/authorize`,
tokenUrl: `${baseUrl}/oauth/token`,
scopes: ['read_api', 'read_user'],
};
}
getUnfurlPatterns(settings: Record<string, any>): UnfurlPattern[] {
const baseUrl = this.resolveBaseUrl(settings);
if (baseUrl === DEFAULT_BASE_URL) return [];
return buildGitLabPatterns(baseUrl);
}
async unfurl(opts: UnfurlOpts): Promise<UnfurlResult> {
const { match, patternType, accessToken, url } = opts;
const apiBaseUrl = this.resolveApiBaseUrl(url);
switch (patternType) {
case 'gitlab-mr': {
const projectPath = match[1];
const iid = parseInt(match[2], 10);
return this.gitlabService.unfurlMergeRequest(
accessToken, apiBaseUrl, projectPath, iid, url,
);
}
case 'gitlab-issue': {
const projectPath = match[1];
const iid = parseInt(match[2], 10);
return this.gitlabService.unfurlIssue(
accessToken, apiBaseUrl, projectPath, iid, url,
);
}
case 'gitlab-project': {
const projectPath = `${match[1]}/${match[2]}`;
return this.gitlabService.unfurlProject(
accessToken, apiBaseUrl, projectPath, url,
);
}
case 'gitlab-commit': {
const projectPath = match[1];
const commitSha = match[2];
return this.gitlabService.unfurlCommit(
accessToken, apiBaseUrl, projectPath, commitSha, url,
);
}
case 'gitlab-commit-in-mr': {
const projectPath = match[1];
const commitSha = match[3];
return this.gitlabService.unfurlCommit(
accessToken, apiBaseUrl, projectPath, commitSha, url,
);
}
case 'gitlab-work-item-drawer': {
const target = this.decodeWorkItemShowParam(url);
if (!target) {
throw new Error('Could not decode work item show param');
}
return this.gitlabService.unfurlIssue(
accessToken, apiBaseUrl, target.fullPath, target.iid, url,
);
}
case 'gitlab-issues-list': {
const projectPath = match[1];
return this.gitlabService.unfurlIssuesList(
accessToken, apiBaseUrl, projectPath, url,
);
}
case 'gitlab-merges-list': {
const projectPath = match[1];
return this.gitlabService.unfurlMergesList(
accessToken, apiBaseUrl, projectPath, url,
);
}
default:
throw new Error(`Unknown GitLab pattern type: ${patternType}`);
}
}
describeLink(
patternType: string,
match: RegExpMatchArray,
url: string,
): LinkDescription | null {
const projectPath = match[1];
switch (patternType) {
case 'gitlab-mr':
return { title: `Merge Request !${match[2]}`, description: projectPath };
case 'gitlab-issue':
return { title: `Issue #${match[2]}`, description: projectPath };
case 'gitlab-work-item-drawer': {
const target = this.decodeWorkItemShowParam(url);
return target
? { title: `Issue #${target.iid}`, description: target.fullPath }
: { title: 'Work item', description: projectPath };
}
case 'gitlab-commit':
return { title: `Commit ${match[2].slice(0, 8)}`, description: projectPath };
case 'gitlab-commit-in-mr':
return { title: `Commit ${match[3].slice(0, 8)}`, description: projectPath };
case 'gitlab-issues-list':
return { title: 'Issues', description: projectPath };
case 'gitlab-merges-list':
return { title: 'Merge Requests', description: projectPath };
case 'gitlab-project':
return { title: `${match[1]}/${match[2]}` };
default:
return null;
}
}
// The work items list opens an item as a drawer and encodes it in the URL
// as ?show=base64({ iid, full_path, id }). full_path beats the URL path:
// a drawer opened from a group-level list still names the actual project.
private decodeWorkItemShowParam(
url: string,
): { fullPath: string; iid: number } | null {
try {
const show = new URL(url).searchParams.get('show');
if (!show) return null;
const base64 = show.replace(/-/g, '+').replace(/_/g, '/');
const payload = JSON.parse(
Buffer.from(base64, 'base64').toString('utf8'),
);
const iid = parseInt(payload.iid, 10);
if (typeof payload.full_path !== 'string' || Number.isNaN(iid)) {
return null;
}
return { fullPath: payload.full_path, iid };
} catch {
return null;
}
}
private resolveBaseUrl(settings: Record<string, any>): string {
// env wins: the OAuth app credentials in env are registered on that instance
const baseUrl =
process.env.INTEGRATION_GITLAB_BASE_URL ||
(settings?.baseUrl as string | undefined);
return baseUrl ? baseUrl.replace(/\/+$/, '') : DEFAULT_BASE_URL;
}
private resolveApiBaseUrl(url: string): string {
const parsed = new URL(url);
return `${parsed.origin}/api/v4`;
}
}
@@ -0,0 +1,253 @@
import { Injectable, Logger } from '@nestjs/common';
import { UnfurlResult } from '../../registry/integration-provider.interface';
import { relativeTime } from '../../utils/relative-time';
import { providerApiFetch } from '../../utils/provider-fetch';
@Injectable()
export class GitLabService {
private readonly logger = new Logger(GitLabService.name);
async unfurlMergeRequest(
accessToken: string,
apiBaseUrl: string,
projectPath: string,
iid: number,
url: string,
): Promise<UnfurlResult> {
const encodedProject = encodeURIComponent(projectPath);
const data = await this.apiGet(
accessToken,
apiBaseUrl,
`/projects/${encodedProject}/merge_requests/${iid}`,
);
const authorName = data.author?.name ?? data.author?.username;
const desc = [
`!${data.iid}`,
relativeTime(data.updated_at ?? data.created_at),
authorName,
].filter(Boolean).join(' · ');
return {
title: data.title,
description: desc,
url,
provider: 'gitlab',
providerIcon: 'gitlab',
status: this.formatMrStatus(data),
statusColor: this.getMrStatusColor(data),
author: authorName,
authorAvatarUrl: data.author?.avatar_url,
metadata: {
type: 'mr',
iid: data.iid,
project: projectPath,
labels: data.labels ?? [],
draft: data.draft ?? data.work_in_progress,
},
};
}
async unfurlIssue(
accessToken: string,
apiBaseUrl: string,
projectPath: string,
iid: number,
url: string,
): Promise<UnfurlResult> {
const encodedProject = encodeURIComponent(projectPath);
const data = await this.apiGet(
accessToken,
apiBaseUrl,
`/projects/${encodedProject}/issues/${iid}`,
);
const issueAuthor = data.author?.name ?? data.author?.username;
const issueDesc = [
`#${data.iid}`,
relativeTime(data.updated_at ?? data.created_at),
issueAuthor,
].filter(Boolean).join(' · ');
return {
title: data.title,
description: issueDesc,
url,
provider: 'gitlab',
providerIcon: 'gitlab',
status: data.state,
statusColor: data.state === 'opened' ? 'green' : 'blue',
author: issueAuthor,
authorAvatarUrl: data.author?.avatar_url,
metadata: {
type: 'issue',
iid: data.iid,
project: projectPath,
labels: data.labels ?? [],
assignees:
data.assignees?.map((a: any) => a.name ?? a.username) ?? [],
},
};
}
async unfurlProject(
accessToken: string,
apiBaseUrl: string,
projectPath: string,
url: string,
): Promise<UnfurlResult> {
const encodedProject = encodeURIComponent(projectPath);
const data = await this.apiGet(
accessToken,
apiBaseUrl,
`/projects/${encodedProject}`,
);
const visibility = data.visibility === 'public' ? 'Public' : data.visibility === 'internal' ? 'Internal' : 'Private';
return {
title: data.name,
description: data.description?.slice(0, 200) ?? undefined,
url,
provider: 'gitlab',
providerIcon: 'gitlab',
status: visibility,
statusColor: data.visibility === 'public' ? 'green' : 'gray',
author: data.namespace?.name,
authorAvatarUrl: data.avatar_url ?? data.namespace?.avatar_url,
metadata: {
type: 'project',
project: projectPath,
stars: data.star_count,
forks: data.forks_count,
defaultBranch: data.default_branch,
},
};
}
async unfurlCommit(
accessToken: string,
apiBaseUrl: string,
projectPath: string,
commitSha: string,
url: string,
): Promise<UnfurlResult> {
const encodedProject = encodeURIComponent(projectPath);
const data = await this.apiGet(
accessToken,
apiBaseUrl,
`/projects/${encodedProject}/repository/commits/${commitSha}`,
);
const shortSha = data.short_id ?? data.id?.slice(0, 8);
const commitDesc = [
shortSha,
relativeTime(data.committed_date ?? data.created_at),
data.author_name,
].filter(Boolean).join(' · ');
return {
title: data.title ?? data.message?.split('\n')[0],
description: commitDesc,
url,
provider: 'gitlab',
providerIcon: 'gitlab',
author: data.author_name,
authorAvatarUrl: undefined,
metadata: {
type: 'commit',
sha: data.id,
shortSha,
project: projectPath,
stats: data.stats,
},
};
}
async unfurlIssuesList(
accessToken: string,
apiBaseUrl: string,
projectPath: string,
url: string,
): Promise<UnfurlResult> {
const encodedProject = encodeURIComponent(projectPath);
const data = await this.apiGet(
accessToken,
apiBaseUrl,
`/projects/${encodedProject}?statistics=false`,
);
return {
title: `Issues · ${data.name}`,
description: projectPath,
url,
provider: 'gitlab',
providerIcon: 'gitlab',
author: data.namespace?.name,
authorAvatarUrl: data.avatar_url ?? data.namespace?.avatar_url,
metadata: {
type: 'issues-list',
project: projectPath,
openIssuesCount: data.open_issues_count,
},
};
}
async unfurlMergesList(
accessToken: string,
apiBaseUrl: string,
projectPath: string,
url: string,
): Promise<UnfurlResult> {
const encodedProject = encodeURIComponent(projectPath);
const data = await this.apiGet(
accessToken,
apiBaseUrl,
`/projects/${encodedProject}?statistics=false`,
);
return {
title: `Merge Requests · ${data.name}`,
description: projectPath,
url,
provider: 'gitlab',
providerIcon: 'gitlab',
author: data.namespace?.name,
authorAvatarUrl: data.avatar_url ?? data.namespace?.avatar_url,
metadata: {
type: 'merges-list',
project: projectPath,
},
};
}
private formatMrStatus(mr: any): string {
if (mr.state === 'merged') return 'merged';
if (mr.draft || mr.work_in_progress) return 'draft';
return mr.state;
}
private getMrStatusColor(mr: any): string {
if (mr.state === 'merged') return 'purple';
if (mr.draft || mr.work_in_progress) return 'gray';
if (mr.state === 'opened') return 'green';
if (mr.state === 'closed') return 'red';
return 'gray';
}
private async apiGet(
accessToken: string,
apiBaseUrl: string,
path: string,
): Promise<any> {
const response = await providerApiFetch('GitLab', `${apiBaseUrl}${path}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
});
return response.json();
}
}
@@ -0,0 +1,152 @@
export type IntegrationCapability = 'oauth' | 'unfurl' | 'actions' | 'webhooks';
export type OAuthConfig = {
authUrl: string;
tokenUrl: string;
scopes: string[];
// 'workspace' = one shared bot/app connection per integration (Slack model);
// 'user' (default) = each Docmost user OAuths separately and gets their own token (Linear, Jira, GitHub model)
connectionScope?: 'workspace' | 'user';
};
export type UnfurlPattern = {
regex: RegExp;
type: string;
};
export type UnfurlResult = {
title: string;
description?: string;
url: string;
provider: string;
providerIcon?: string;
status?: string;
statusColor?: string;
author?: string;
authorAvatarUrl?: string;
metadata?: Record<string, any>;
};
export type IntegrationDefinition = {
type: string;
name: string;
description: string;
icon: string;
capabilities: IntegrationCapability[];
oauth?: OAuthConfig;
unfurlPatterns?: UnfurlPattern[];
// Kept out of the available list and refused for install; existing
// installations keep unfurling.
hidden?: boolean;
// Install requires the INTEGRATIONS license feature; unset = free.
requiresLicense?: boolean;
};
export type ConnectedEvent = {
integrationId: string;
workspaceId: string;
accessToken: string;
refreshToken?: string;
providerUserId: string;
metadata: Record<string, any>;
};
export type HandleEventOpts = {
eventName: string;
payload: Record<string, any>;
integration: {
id: string;
type: string;
settings: Record<string, any> | null;
};
connection?: {
accessToken: string;
userId: string;
};
};
export type UnfurlOpts = {
url: string;
accessToken: string;
match: RegExpMatchArray;
patternType: string;
settings?: Record<string, any>;
// The requesting Docmost user and integration. Providers backed by a shared
// (workspace) connection MUST authorize the requester against the target
// resource before returning content: the shared bot token is not itself
// proof that the requester may see it.
userId: string;
integrationId: string;
};
// Thrown by a provider's unfurl() when the requesting user is not authorized
// to view the linked resource. UnfurlService turns it into a null result
// (no card) rather than logging it as an error.
export class UnfurlForbiddenError extends Error {
constructor(message = 'Not authorized to unfurl this link') {
super(message);
this.name = 'UnfurlForbiddenError';
}
}
// Thrown when the provider definitively rejects the stored credential (API 401,
// or invalid_grant at the token endpoint). Callers retire the connection.
export class TokenInvalidError extends Error {
constructor(message = 'Integration credential is no longer valid') {
super(message);
this.name = 'TokenInvalidError';
}
}
export class ProviderApiError extends Error {
constructor(
readonly provider: string,
readonly status: number,
statusText = '',
) {
super(`${provider} API error: ${status} ${statusText}`.trimEnd());
this.name = 'ProviderApiError';
}
}
export type LinkDescription = {
title: string;
description?: string;
};
// Returned instead of an UnfurlResult when the link needs a per-user
// connection the requesting user does not have yet.
export type UnfurlNeedsConnection = {
needsConnection: true;
integrationId: string;
integrationType: string;
integrationName: string;
title: string;
description?: string;
};
export abstract class IntegrationProvider {
abstract definition: IntegrationDefinition;
getOAuthConfig?(
workspaceSettings: Record<string, any>,
): OAuthConfig;
getUnfurlPatterns?(
workspaceSettings: Record<string, any>,
): UnfurlPattern[];
onConnected?(opts: ConnectedEvent): Promise<void>;
unfurl?(opts: UnfurlOpts): Promise<UnfurlResult>;
// Tokenless summary of a matched link (e.g. "Pull Request #13337"),
// shown on the connect prompt before the user has authorized.
describeLink?(
patternType: string,
match: RegExpMatchArray,
url: string,
): LinkDescription | null;
handleEvent?(opts: HandleEventOpts): Promise<void>;
}
@@ -0,0 +1,47 @@
import { Injectable } from '@nestjs/common';
import {
IntegrationDefinition,
IntegrationProvider,
} from './integration-provider.interface';
@Injectable()
export class IntegrationRegistry {
private providers = new Map<string, IntegrationProvider>();
register(provider: IntegrationProvider): void {
this.providers.set(provider.definition.type, provider);
}
getProvider(type: string): IntegrationProvider | undefined {
return this.providers.get(type);
}
getAllProviders(): IntegrationProvider[] {
return Array.from(this.providers.values());
}
getAvailableIntegrations(): IntegrationDefinition[] {
return this.getAllProviders()
.map((p) => p.definition)
.filter((definition) => !definition.hidden);
}
findUnfurlProvider(
url: string,
): {
provider: IntegrationProvider;
match: RegExpMatchArray;
patternType: string;
} | null {
for (const provider of this.providers.values()) {
if (!provider.definition.unfurlPatterns) continue;
for (const pattern of provider.definition.unfurlPatterns) {
const match = url.match(pattern.regex);
if (match) {
return { provider, match, patternType: pattern.type };
}
}
}
return null;
}
}
@@ -0,0 +1,357 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { sql } from 'kysely';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import {
IntegrationConnection,
InsertableIntegrationConnection,
UpdatableIntegrationConnection,
} from '@docmost/db/types/entity.types';
import { dbOrTx } from '@docmost/db/utils';
@Injectable()
export class IntegrationConnectionRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
async findById(
connectionId: string,
trx?: KyselyTransaction,
): Promise<IntegrationConnection | undefined> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('integrationConnections')
.selectAll()
.where('id', '=', connectionId)
.executeTakeFirst();
}
async findByIntegrationAndUser(
integrationId: string,
userId: string,
trx?: KyselyTransaction,
): Promise<IntegrationConnection | undefined> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('integrationConnections')
.selectAll()
.where('integrationId', '=', integrationId)
.where('userId', '=', userId)
.executeTakeFirst();
}
async findByWorkspaceTypeAndUser(
workspaceId: string,
integrationType: string,
userId: string,
trx?: KyselyTransaction,
): Promise<IntegrationConnection | undefined> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('integrationConnections')
.innerJoin(
'integrations',
'integrations.id',
'integrationConnections.integrationId',
)
.selectAll('integrationConnections')
.where('integrations.workspaceId', '=', workspaceId)
.where('integrations.type', '=', integrationType)
.where('integrations.deletedAt', 'is', null)
.where('integrationConnections.userId', '=', userId)
.executeTakeFirst();
}
async findByIntegration(
integrationId: string,
trx?: KyselyTransaction,
): Promise<IntegrationConnection[]> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('integrationConnections')
.selectAll()
.where('integrationId', '=', integrationId)
.execute();
}
async upsert(
connection: InsertableIntegrationConnection,
trx?: KyselyTransaction,
): Promise<IntegrationConnection> {
const db = dbOrTx(this.db, trx);
// The (integration_id, user_id) unique index is partial on kind='user';
// ON CONFLICT must repeat that predicate or Postgres cannot infer it.
return db
.insertInto('integrationConnections')
.values(connection)
.onConflict((oc) =>
oc
.columns(['integrationId', 'userId'])
.where(sql.ref('kind'), '=', 'user')
.doUpdateSet({
accessToken: connection.accessToken,
refreshToken: connection.refreshToken,
tokenExpiresAt: connection.tokenExpiresAt,
invalidatedAt: null,
scopes: connection.scopes,
providerUserId: connection.providerUserId,
metadata: connection.metadata,
updatedAt: new Date(),
}),
)
.returningAll()
.executeTakeFirstOrThrow();
}
async upsertWorkspaceConnection(
input: {
integrationId: string;
userId: string;
workspaceId: string;
accessToken: string;
refreshToken?: string | null;
tokenExpiresAt?: Date | null;
scopes?: string | null;
},
trx?: KyselyTransaction,
): Promise<IntegrationConnection> {
const db = dbOrTx(this.db, trx);
const existing = await this.findWorkspaceConnection(input.integrationId, trx);
if (existing) {
return this.update(
existing.id,
{
accessToken: input.accessToken,
refreshToken: input.refreshToken ?? null,
tokenExpiresAt: input.tokenExpiresAt ?? null,
invalidatedAt: null,
scopes: input.scopes ?? null,
userId: input.userId,
},
trx,
);
}
// No need to clear other rows: the (integration_id, user_id) unique index
// is partial on kind='user', so a workspace insert never conflicts with
// the installer's user-link row.
return db
.insertInto('integrationConnections')
.values({
integrationId: input.integrationId,
userId: input.userId,
workspaceId: input.workspaceId,
accessToken: input.accessToken,
refreshToken: input.refreshToken ?? null,
tokenExpiresAt: input.tokenExpiresAt ?? null,
scopes: input.scopes ?? null,
kind: 'workspace',
})
.returningAll()
.executeTakeFirstOrThrow();
}
async update(
connectionId: string,
data: UpdatableIntegrationConnection,
trx?: KyselyTransaction,
): Promise<IntegrationConnection> {
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<void> {
const db = dbOrTx(this.db, trx);
// Never delete a kind='workspace' row from a per-user disconnect.
// For Slack (and any future workspace-scoped provider) the installer's
// userId matches the workspace connection's userId; without this filter
// a single user clicking Disconnect would wipe the shared bot token and
// break the integration for the whole workspace. Full uninstall uses
// deleteByIntegration which intentionally has no kind filter.
await db
.deleteFrom('integrationConnections')
.where('integrationId', '=', integrationId)
.where('userId', '=', userId)
.where('kind', '!=', 'workspace')
.execute();
}
async findByUserAndWorkspace(
userId: string,
workspaceId: string,
trx?: KyselyTransaction,
) {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('integrationConnections')
.innerJoin(
'integrations',
'integrations.id',
'integrationConnections.integrationId',
)
.select([
'integrationConnections.integrationId',
'integrations.type',
'integrationConnections.providerUserId',
'integrationConnections.createdAt',
'integrationConnections.invalidatedAt',
])
.where('integrationConnections.userId', '=', userId)
.where('integrations.workspaceId', '=', workspaceId)
.where('integrations.deletedAt', 'is', null)
.execute();
}
async findExpiringTokens(
expiresBeforeMs: number,
): Promise<IntegrationConnection[]> {
const threshold = new Date(Date.now() + expiresBeforeMs);
return this.db
.selectFrom('integrationConnections')
.innerJoin(
'integrations',
'integrations.id',
'integrationConnections.integrationId',
)
.selectAll('integrationConnections')
.where('integrations.deletedAt', 'is', null)
.where('integrationConnections.invalidatedAt', 'is', null)
.where('integrationConnections.refreshToken', 'is not', null)
.where('integrationConnections.tokenExpiresAt', 'is not', null)
.where('integrationConnections.tokenExpiresAt', '<', threshold)
.execute();
}
// Retire a rejected credential: flag for reconnect UX, drop the dead refresh token; no-op if the row is gone.
async invalidate(connectionId: string): Promise<void> {
await this.db
.updateTable('integrationConnections')
.set({
invalidatedAt: new Date(),
refreshToken: null,
tokenExpiresAt: null,
updatedAt: new Date(),
})
.where('id', '=', connectionId)
.execute();
}
async deleteByIntegration(
integrationId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.deleteFrom('integrationConnections')
.where('integrationId', '=', integrationId)
.execute();
}
async findWorkspaceConnection(
integrationId: string,
trx?: KyselyTransaction,
): Promise<IntegrationConnection | undefined> {
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<IntegrationConnection | undefined> {
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<IntegrationConnection | undefined> {
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<void> {
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<string, unknown>;
},
trx?: KyselyTransaction,
): Promise<IntegrationConnection> {
const db = dbOrTx(this.db, trx);
// Target the partial unique index uq_integration_connections_user_per_integration
// (integration_id, user_id) WHERE kind = 'user'. Without the .where() hint,
// ON CONFLICT can't match a partial index. The kind discriminator means a
// workspace bot row sharing (integration_id, user_id) with this user-link
// is no longer a conflict, so we cannot flip its kind.
return await db
.insertInto('integrationConnections')
.values({
integrationId: input.integrationId,
workspaceId: input.workspaceId,
userId: input.userId,
providerUserId: input.providerUserId,
kind: 'user',
metadata: input.metadata as any,
accessToken: null,
})
.onConflict((oc) =>
oc
.columns(['integrationId', 'userId'])
.where(sql.ref('kind'), '=', 'user')
.doUpdateSet({
providerUserId: input.providerUserId,
metadata: input.metadata as any,
updatedAt: new Date(),
}),
)
.returningAll()
.executeTakeFirstOrThrow();
}
}
@@ -0,0 +1,101 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import {
IntegrationWebhook,
InsertableIntegrationWebhook,
UpdatableIntegrationWebhook,
} from '@docmost/db/types/entity.types';
import { dbOrTx } from '@docmost/db/utils';
@Injectable()
export class IntegrationWebhookRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
async findById(
webhookId: string,
trx?: KyselyTransaction,
): Promise<IntegrationWebhook | undefined> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('integrationWebhooks')
.selectAll()
.where('id', '=', webhookId)
.executeTakeFirst();
}
async findByIntegration(
integrationId: string,
trx?: KyselyTransaction,
): Promise<IntegrationWebhook[]> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('integrationWebhooks')
.selectAll()
.where('integrationId', '=', integrationId)
.execute();
}
async findEnabledByEvent(
workspaceId: string,
eventType: string,
trx?: KyselyTransaction,
): Promise<IntegrationWebhook[]> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('integrationWebhooks')
.selectAll()
.where('workspaceId', '=', workspaceId)
.where('eventType', '=', eventType)
.where('isEnabled', '=', true)
.execute();
}
async insert(
webhook: InsertableIntegrationWebhook,
trx?: KyselyTransaction,
): Promise<IntegrationWebhook> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('integrationWebhooks')
.values(webhook)
.returningAll()
.executeTakeFirstOrThrow();
}
async update(
webhookId: string,
data: UpdatableIntegrationWebhook,
trx?: KyselyTransaction,
): Promise<IntegrationWebhook> {
const db = dbOrTx(this.db, trx);
return db
.updateTable('integrationWebhooks')
.set({ ...data, updatedAt: new Date() })
.where('id', '=', webhookId)
.returningAll()
.executeTakeFirstOrThrow();
}
async delete(
webhookId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.deleteFrom('integrationWebhooks')
.where('id', '=', webhookId)
.execute();
}
async deleteByIntegration(
integrationId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.deleteFrom('integrationWebhooks')
.where('integrationId', '=', integrationId)
.execute();
}
}
@@ -0,0 +1,127 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { sql } from 'kysely';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import {
Integration,
InsertableIntegration,
UpdatableIntegration,
} from '@docmost/db/types/entity.types';
import { dbOrTx } from '@docmost/db/utils';
@Injectable()
export class IntegrationRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
async findById(
integrationId: string,
trx?: KyselyTransaction,
): Promise<Integration | undefined> {
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<Integration | undefined> {
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<Integration[]> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('integrations')
.selectAll()
.where('workspaceId', '=', workspaceId)
.where('deletedAt', 'is', null)
.execute();
}
async insert(
integration: InsertableIntegration,
trx?: KyselyTransaction,
): Promise<Integration> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('integrations')
.values(integration)
.returningAll()
.executeTakeFirstOrThrow();
}
async insertOrRestore(
integration: InsertableIntegration,
trx?: KyselyTransaction,
): Promise<Integration> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('integrations')
.values(integration)
.onConflict((oc) =>
oc.columns(['type', 'workspaceId']).doUpdateSet({
deletedAt: null,
installedById: integration.installedById,
updatedAt: new Date(),
}),
)
.returningAll()
.executeTakeFirstOrThrow();
}
async update(
integrationId: string,
data: UpdatableIntegration,
trx?: KyselyTransaction,
): Promise<Integration> {
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<void> {
const db = dbOrTx(this.db, trx);
await db
.updateTable('integrations')
.set({ deletedAt: new Date() })
.where('id', '=', integrationId)
.execute();
}
async findByTypeAndSettingsField(
type: string,
key: string,
value: string,
): Promise<Integration | undefined> {
return this.db
.selectFrom('integrations')
.selectAll()
.where('type', '=', type)
.where('deletedAt', 'is', null)
.where(sql<string>`settings->>${sql.lit(key)}`, '=', value)
.executeTakeFirst();
}
}
@@ -0,0 +1,35 @@
import {
Body,
Controller,
HttpCode,
HttpStatus,
Post,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
import { AuthUser } from '../../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { UnfurlService } from './unfurl.service';
import { UnfurlDto } from '../dto/integration.dto';
@Controller('integrations')
export class UnfurlController {
constructor(private readonly unfurlService: UnfurlService) {}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('unfurl')
async unfurl(
@Body() dto: UnfurlDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const result = await this.unfurlService.unfurl(
dto.url,
user.id,
workspace.id,
);
return { data: result };
}
}
@@ -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,
TokenInvalidError,
ProviderApiError,
IntegrationProvider,
} from '../registry/integration-provider.interface';
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
import type { Redis } from 'ioredis';
import * as crypto from 'crypto';
const UNFURL_CACHE_TTL = 300; // 5 minutes
// Transient failures get a short negative cache so a broken provider is not
// re-fetched on every view; 404s cache at the normal TTL (the target is gone).
const UNFURL_ERROR_CACHE_TTL = 60;
const UNFURL_CACHE_PREFIX = 'unfurl:';
@Injectable()
export class UnfurlService {
private readonly logger = new Logger(UnfurlService.name);
private readonly redis: Redis;
constructor(
private readonly registry: IntegrationRegistry,
private readonly integrationRepo: IntegrationRepo,
private readonly connectionRepo: IntegrationConnectionRepo,
private readonly oauthService: OAuthService,
private readonly redisService: RedisService,
) {
this.redis = this.redisService.getOrThrow();
}
async unfurl(
url: string,
userId: string,
workspaceId: string,
): Promise<UnfurlResult | UnfurlNeedsConnection | null> {
const cacheKey = this.buildCacheKey(workspaceId, userId, url);
const cached = await this.redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
const resolved = await this.resolveProvider(url, workspaceId);
if (!resolved) {
return null;
}
const { provider, match, patternType, integration } = resolved;
if (!provider.unfurl) {
return null;
}
// Workspace-scoped providers (Slack) share one bot connection that serves
// every member; user-scoped providers need the requester's own token.
const connectionScope =
provider.definition.oauth?.connectionScope ?? 'user';
const connection =
connectionScope === 'workspace'
? await this.connectionRepo.findWorkspaceConnection(integration.id)
: await this.connectionRepo.findByIntegrationAndUser(
integration.id,
userId,
);
if (!connection || connection.invalidatedAt) {
// Dead workspace connections need an admin re-install; members get no card.
if (connectionScope === 'workspace') {
return null;
}
// Not cached: the card should load as soon as the user (re)connects.
return this.buildNeedsConnection(
provider,
integration.id,
patternType,
match,
url,
);
}
try {
const accessToken =
await this.oauthService.getValidAccessToken(connection);
const unfurlResult = await provider.unfurl({
url,
accessToken,
match,
patternType,
settings: (integration.settings as Record<string, any>) ?? {},
userId,
integrationId: integration.id,
});
await this.redis.set(
cacheKey,
JSON.stringify(unfurlResult),
'EX',
UNFURL_CACHE_TTL,
);
return unfurlResult;
} catch (err) {
// Not-authorized is an expected outcome (no card), not an error.
if (err instanceof UnfurlForbiddenError) {
this.logger.debug(
`Unfurl not authorized for ${url}: ${(err as Error).message}`,
);
await this.cacheNull(cacheKey, UNFURL_ERROR_CACHE_TTL);
return null;
}
if (err instanceof TokenInvalidError) {
this.logger.warn(
`Retiring connection ${connection.id}: ${(err as Error).message}`,
);
await this.connectionRepo
.invalidate(connection.id)
.catch(() => undefined);
if (connectionScope === 'workspace') {
return null;
}
// Not cached so the card heals the moment the user reconnects.
return this.buildNeedsConnection(
provider,
integration.id,
patternType,
match,
url,
);
}
this.logger.error(`Unfurl failed for ${url}: ${(err as Error).message}`);
const ttl =
err instanceof ProviderApiError && err.status === 404
? UNFURL_CACHE_TTL
: UNFURL_ERROR_CACHE_TTL;
await this.cacheNull(cacheKey, ttl);
return null;
}
}
private async cacheNull(cacheKey: string, ttl: number): Promise<void> {
await this.redis.set(cacheKey, 'null', 'EX', ttl);
}
async purgeUserCache(workspaceId: string, userId: string): Promise<void> {
const pattern = `${UNFURL_CACHE_PREFIX}${workspaceId}:${userId}:*`;
try {
const stream = this.redis.scanStream({ match: pattern, count: 100 });
for await (const keys of stream as AsyncIterable<string[]>) {
if (keys.length) {
await this.redis.unlink(...keys);
}
}
} catch (err) {
// best-effort by design: never fail a disconnect on cache purge; the TTL is the backstop
this.logger.error(
`Failed to purge unfurl cache for user ${userId}: ${(err as Error).message}`,
);
}
}
private buildNeedsConnection(
provider: IntegrationProvider,
integrationId: string,
patternType: string,
match: RegExpMatchArray,
url: string,
): UnfurlNeedsConnection {
const described =
provider.describeLink?.(patternType, match, url) ?? null;
let fallbackDescription: string | undefined;
try {
const parsed = new URL(url);
fallbackDescription = `${parsed.host}${parsed.pathname}`;
} catch {
fallbackDescription = undefined;
}
return {
needsConnection: true,
integrationId,
integrationType: provider.definition.type,
integrationName: provider.definition.name,
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<string, any>) ?? {};
const patterns = provider.getUnfurlPatterns(settings);
for (const pattern of patterns) {
const match = url.match(pattern.regex);
if (match) {
return { provider, match, patternType: pattern.type, integration };
}
}
}
return null;
}
private buildCacheKey(workspaceId: string, userId: string, url: string): string {
const hash = crypto
.createHash('sha256')
.update(url)
.digest('hex')
.slice(0, 16);
return `${UNFURL_CACHE_PREFIX}${workspaceId}:${userId}:${hash}`;
}
}
@@ -0,0 +1,61 @@
import {
ProviderApiError,
TokenInvalidError,
UnfurlForbiddenError,
} from '../registry/integration-provider.interface';
import { proxyFetch } from '../../../common/proxy-fetch';
export const INTEGRATION_HTTP_TIMEOUT_MS = 10_000;
// Providers explain refusals in the response body ("insufficient scope",
// "not allowed", ...); without it a 403 is undiagnosable from the logs.
const MAX_ERROR_BODY_CHARS = 300;
async function readErrorBody(response: Response): Promise<string> {
try {
const text = await response.text();
return text.replace(/\s+/g, ' ').trim().slice(0, MAX_ERROR_BODY_CHARS);
} catch {
return '';
}
}
// Bounds every provider call and maps 401 to TokenInvalidError so callers can retire the connection.
export async function providerApiFetch(
providerName: string,
url: string,
init: RequestInit = {},
): Promise<Response> {
const response = await proxyFetch(url, {
...init,
signal: AbortSignal.timeout(INTEGRATION_HTTP_TIMEOUT_MS),
});
if (response.status === 401) {
throw new TokenInvalidError(
`${providerName} API error: 401 Unauthorized ${await readErrorBody(response)}`.trimEnd(),
);
}
if (!response.ok) {
const body = await readErrorBody(response);
// 403 normally means the viewer simply can't reach that resource, which is
// an expected "no card" outcome. GitHub also spends 403 on secondary rate
// limits, so quota signals stay a real error an operator can see.
const rateLimited =
response.headers.get('retry-after') !== null ||
response.headers.get('x-ratelimit-remaining') === '0';
if (response.status === 403 && !rateLimited) {
throw new UnfurlForbiddenError(
`${providerName} API error: 403 ${body}`.trimEnd(),
);
}
throw new ProviderApiError(
providerName,
response.status,
`${response.statusText} ${body}`.trim(),
);
}
return response;
}
@@ -0,0 +1,5 @@
import { formatDistanceStrict } from 'date-fns';
export function relativeTime(iso: string): string {
return formatDistanceStrict(new Date(iso), new Date(), { addSuffix: true });
}
@@ -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 });
@@ -102,6 +102,19 @@ export class PageAccessService {
return { hasRestriction: hasAnyRestriction };
}
/**
* Validate user can create a root page in the space, throws if not.
* Mirrors the space-level check the HTTP create endpoint enforces so
* non-HTTP callers (Slack, integrations) cannot bypass it. A non-member
* (including a space in another workspace) throws from createForUser.
*/
async validateCanCreate(spaceId: string, user: User): Promise<void> {
const ability = await this.spaceAbility.createForUser(user, spaceId);
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
}
async validateCanComment(
page: Page,
user: User,
@@ -27,6 +27,7 @@ export class SearchService {
opts: {
userId?: string;
workspaceId: string;
titlesOnly?: boolean;
},
): Promise<{ items: SearchResponseDto[] }> {
const { query } = searchParams;
@@ -34,6 +35,12 @@ export class SearchService {
if (query.length < 1) {
return { items: [] };
}
// Use ILIKE titles-only search if titlesOnly flag is set
if (opts.titlesOnly) {
return this.searchPageTitlesOnly(searchParams, opts);
}
const searchQuery = tsquery(query.trim() + '*');
let queryResults = this.db
@@ -151,6 +158,71 @@ export class SearchService {
return { items: searchResults };
}
private async searchPageTitlesOnly(
searchParams: SearchDTO,
opts: {
userId?: string;
workspaceId: string;
},
): Promise<{ items: SearchResponseDto[] }> {
const { query } = searchParams;
let queryResults = this.db
.selectFrom('pages')
.select([
'id',
'slugId',
'title',
'icon',
'parentPageId',
'creatorId',
'createdAt',
'updatedAt',
])
.where('title', 'ilike', `%${query}%`)
.where('deletedAt', 'is', null)
.orderBy('updatedAt', 'desc')
.limit(searchParams.limit || 10)
.offset(searchParams.offset || 0);
if (searchParams.spaceId) {
// search by spaceId
queryResults = queryResults.where('spaceId', '=', searchParams.spaceId);
} else if (opts.userId) {
// only search spaces the user is a member of
queryResults = queryResults
.where(
'spaceId',
'in',
this.spaceMemberRepo.getUserSpaceIdsQuery(opts.userId),
)
.where('workspaceId', '=', opts.workspaceId);
} else {
return { items: [] };
}
queryResults = queryResults.select((eb) => this.pageRepo.withSpace(eb));
//@ts-ignore
let results: any[] = await queryResults.execute();
// Filter results by page-level permissions (if user is authenticated)
if (opts.userId && results.length > 0) {
const pageIds = results.map((r: any) => r.id);
const accessibleIds =
await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds,
userId: opts.userId,
spaceId: searchParams.spaceId,
});
const accessibleSet = new Set(accessibleIds);
results = results.filter((r: any) => accessibleSet.has(r.id));
}
//@ts-ignore
return { items: results };
}
async searchSuggestions(
suggestion: SearchSuggestionDTO,
userId: string,
@@ -0,0 +1,131 @@
import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
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')
// Nullable: workspace-scoped rows carry a token; user-scoped identity-link
// rows have no token (the binding alone is what they store).
.addColumn('access_token', 'text')
.addColumn('refresh_token', 'text')
.addColumn('token_expires_at', 'timestamptz')
// Set when the provider definitively rejects the credential; a reconnect clears it.
.addColumn('invalidated_at', 'timestamptz')
.addColumn('scopes', 'text')
.addColumn('metadata', 'jsonb')
// 'workspace' = one shared bot/app connection per integration (Slack);
// 'user' = a per-user OAuth token or identity link (Linear, GitHub, Slack
// identity binding). Enforced via a check constraint below.
.addColumn('kind', 'text', (col) => col.notNull().defaultTo('user'))
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.execute();
await sql`
ALTER TABLE integration_connections
ADD CONSTRAINT integration_connections_kind_check
CHECK (kind IN ('workspace', 'user'))
`.execute(db);
// One workspace-bot connection per integration.
await db.schema
.createIndex('uq_integration_connections_workspace_per_integration')
.on('integration_connections')
.column('integration_id')
.where(sql.ref('kind'), '=', 'workspace')
.unique()
.execute();
// One user-link row per (integration, user). Partial on kind='user' so a
// workspace bot row sharing (integration_id, user_id) with the installer's
// personal user-link is NOT a conflict — they are semantically different
// rows with their own constraints.
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
.createTable('integration_webhooks')
.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('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').onDelete('cascade').notNull(),
)
.addColumn('event_type', 'text', (col) => col.notNull())
.addColumn('webhook_url', 'text')
.addColumn('secret', 'text')
.addColumn('is_enabled', 'boolean', (col) => col.notNull().defaultTo(true))
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.execute();
await db.schema
.createIndex('idx_integration_webhooks_integration_event')
.ifNotExists()
.on('integration_webhooks')
.columns(['integration_id', 'event_type'])
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('integration_webhooks').execute();
await db.schema.dropTable('integration_connections').execute();
await db.schema.dropTable('integrations').execute();
}
+43
View File
@@ -506,6 +506,46 @@ export interface Watchers {
createdAt: Generated<Timestamp>;
}
export interface Integrations {
id: Generated<string>;
workspaceId: string;
type: string;
settings: Json | null;
installedById: string | null;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
deletedAt: Timestamp | null;
}
export interface IntegrationConnections {
id: Generated<string>;
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<Timestamp>;
updatedAt: Generated<Timestamp>;
}
export interface IntegrationWebhooks {
id: Generated<string>;
integrationId: string;
workspaceId: string;
eventType: string;
webhookUrl: string | null;
secret: string | null;
isEnabled: Generated<boolean>;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
}
export interface Labels {
id: Generated<string>;
name: string;
@@ -654,6 +694,9 @@ export interface DB {
fileTasks: FileTasks;
groups: Groups;
groupUsers: GroupUsers;
integrationConnections: IntegrationConnections;
integrationWebhooks: IntegrationWebhooks;
integrations: Integrations;
labels: Labels;
notifications: Notifications;
pageAccess: PageAccess;
@@ -1,6 +1,14 @@
import { DB } from '@docmost/db/types/db';
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
import {
Integrations,
IntegrationConnections,
IntegrationWebhooks,
} from '@docmost/db/types/db';
export interface DbInterface extends DB {
pageEmbeddings: PageEmbeddings;
integrations: Integrations;
integrationConnections: IntegrationConnections;
integrationWebhooks: IntegrationWebhooks;
}
@@ -8,6 +8,9 @@ import {
BaseViews,
Comments,
Groups,
Integrations as _Integrations,
IntegrationConnections as _IntegrationConnections,
IntegrationWebhooks as _IntegrationWebhooks,
Labels,
Notifications,
PageLabels,
@@ -199,6 +202,26 @@ export type Watcher = Selectable<Watchers>;
export type InsertableWatcher = Insertable<Watchers>;
export type UpdatableWatcher = Updateable<Omit<Watchers, 'id'>>;
// Integration
export type Integration = Selectable<_Integrations>;
export type InsertableIntegration = Insertable<_Integrations>;
export type UpdatableIntegration = Updateable<Omit<_Integrations, 'id'>>;
// Integration Connection
export type IntegrationConnection = Selectable<_IntegrationConnections>;
export type InsertableIntegrationConnection =
Insertable<_IntegrationConnections>;
export type UpdatableIntegrationConnection = Updateable<
Omit<_IntegrationConnections, 'id'>
>;
// Integration Webhook
export type IntegrationWebhook = Selectable<_IntegrationWebhooks>;
export type InsertableIntegrationWebhook = Insertable<_IntegrationWebhooks>;
export type UpdatableIntegrationWebhook = Updateable<
Omit<_IntegrationWebhooks, 'id'>
>;
// Label
export type Label = Selectable<Labels>;
export type InsertableLabel = Insertable<Labels>;
@@ -18,4 +18,20 @@ export class DomainService {
const protocol = this.environmentService.isHttps() ? 'https' : 'http';
return `${protocol}://${hostname}.${domain}`;
}
// Canonical workspace URL: prefers customDomain, falls back to {hostname}.{cloud-domain},
// falls back to APP_URL for self-hosted. Used for multi-tenant OAuth return-redirects.
getWorkspaceUrl(workspace: {
hostname?: string | null;
customDomain?: string | null;
}): string {
if (!this.environmentService.isCloud()) {
return this.environmentService.getAppUrl();
}
if (workspace.customDomain) {
const protocol = this.environmentService.isHttps() ? 'https' : 'http';
return `${protocol}://${workspace.customDomain}`;
}
return this.getUrl(workspace.hostname ?? undefined);
}
}
@@ -360,4 +360,8 @@ export class EnvironmentService {
.map((o) => o.trim())
.filter(Boolean);
}
getSlackSigningSecret(): string | undefined {
return this.configService.get<string>('INTEGRATION_SLACK_SIGNING_SECRET');
}
}
@@ -8,7 +8,15 @@ export enum QueueName {
AI_QUEUE = '{ai-queue}',
HISTORY_QUEUE = '{history-queue}',
NOTIFICATION_QUEUE = '{notification-queue}',
INTEGRATION_QUEUE = '{integration-queue}',
AUDIT_QUEUE = '{audit-queue}',
SLACK_INBOUND = '{slack-inbound}',
// Separate queue for /docmost ask: AI work takes seconds and would
// otherwise starve fast inbound event dispatch.
SLACK_ASK = '{slack-ask}',
// Outbound notification DMs; isolated so Slack API latency and retries
// never block inbound event dispatch.
SLACK_NOTIFY = '{slack-notify}',
BASE_QUEUE = '{base-queue}',
}
@@ -85,6 +93,12 @@ export enum QueueJob {
PDF_EXPORT_TASK = 'pdf-export-task',
PDF_EXPORT_CLEANUP = 'pdf-export-cleanup',
INTEGRATION_EVENT = 'integration-event',
INTEGRATION_TOKEN_REFRESH = 'integration-token-refresh',
SLACK_EVENT = 'slack-event',
SLACK_ASK = 'slack-ask',
SLACK_NOTIFICATION = 'slack-notification',
BASE_TYPE_CONVERSION = 'base-type-conversion',
BASE_CELL_GC = 'base-cell-gc',
BASE_FORMULA_RECOMPUTE = 'base-formula-recompute',
@@ -92,6 +92,14 @@ import { GeneralQueueProcessor } from './processors/general-queue.processor';
attempts: 3,
},
}),
BullModule.registerQueue({
name: QueueName.INTEGRATION_QUEUE,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: { count: 50 },
attempts: 3,
},
}),
BullModule.registerQueue({
name: QueueName.BASE_QUEUE,
defaultJobOptions: {
+4
View File
@@ -118,6 +118,10 @@ async function bootstrap() {
'/api/workspace/create',
'/api/workspace/joined',
'/api/workspace/find-by-email',
'/api/integrations/oauth',
'/api/integrations/slack/events',
'/api/integrations/slack/commands',
'/api/integrations/slack/interactivity',
];
if (
+1
View File
@@ -28,6 +28,7 @@ export * from "./lib/heading/heading";
export * from "./lib/unique-id";
export * from "./lib/shared-storage";
export * from "./lib/recreate-transform";
export * from "./lib/integration-link";
export * from "./lib/columns";
export * from "./lib/status";
export * from "./lib/pdf";
@@ -0,0 +1,11 @@
export { IntegrationLink, createIntegrationAttributes } from "./integration-link";
export type {
IntegrationLinkOptions,
IntegrationLinkAttributes,
} from "./integration-link";
export { IntegrationMention } from "./integration-mention";
export {
integrationLinkPatterns,
matchIntegrationLink,
} from "./integration-link-patterns";
export type { IntegrationLinkPattern } from "./integration-link-patterns";
@@ -0,0 +1,210 @@
export type IntegrationLinkPattern = {
provider: string;
regex: RegExp;
};
export const integrationLinkPatterns: IntegrationLinkPattern[] = [
// Slack message permalink (host-specific; must precede the host-agnostic
// GitHub patterns, whose repo form would swallow /archives/<channel>)
{
provider: "slack",
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",
regex:
/^https?:\/\/[a-z0-9-]+\.slack\.com\/archives\/([a-zA-Z0-9-]+)\/?$/,
},
// Jira issue (cloud + self-hosted): /browse/KEY-123, tolerating ?atlOrigin=…
// Must precede the GitHub repo pattern, which would swallow the two-segment
// /browse/KEY path on any host.
{
provider: "jira",
regex: /^https?:\/\/[^\/]+\/browse\/([A-Za-z0-9]+-\d+)/,
},
// Jira legacy board (cloud + self-hosted): RapidBoard.jspa?…selectedIssue=KEY
{
provider: "jira",
regex:
/^https?:\/\/[^\/]+\/secure\/RapidBoard\.jspa\?(?:.*&)?selectedIssue=([A-Za-z0-9]+-\d+)/,
},
// Jira cloud board/backlog with a selected issue
{
provider: "jira",
regex:
/^https?:\/\/[a-z0-9-]+\.atlassian\.net\/jira\/software(?:\/c)?\/projects\/[\w-]+\/boards\/\d+(?:\/\w+)?\?(?:.*&)?selectedIssue=([A-Za-z0-9]+-\d+)/,
},
// GitHub PR commit (must be before generic PR pattern)
{
provider: "github",
regex:
/^https?:\/\/[^\/]+\/([^\/]+)\/([^\/]+)\/pull\/(\d+)\/commits\/([a-f0-9]+)/,
},
// GitHub PR (with optional /checks, /commits, /files sub-pages)
{
provider: "github",
regex:
/^https?:\/\/[^\/]+\/([^\/]+)\/([^\/]+)\/pull\/(\d+)/,
},
// GitHub issue
{
provider: "github",
regex:
/^https?:\/\/[^\/]+\/([^\/]+)\/([^\/]+)\/issues\/(\d+)/,
},
// GitHub commit
{
provider: "github",
regex:
/^https?:\/\/[^\/]+\/([^\/]+)\/([^\/]+)\/commits?\/([a-f0-9]+)/,
},
// GitHub file/blob
{
provider: "github",
regex:
/^https?:\/\/[^\/]+\/([^\/]+)\/([^\/]+)\/blob\/([^\/]+)\/(.+?)(?:#L(\d+)(?:-L(\d+))?)?$/,
},
// GitHub pulls list
{
provider: "github",
regex:
/^https?:\/\/[^\/]+\/([^\/]+)\/([^\/]+)\/pulls(?:\/.*)?(?:\?.*)?$/,
},
// GitHub releases list
{
provider: "github",
regex:
/^https?:\/\/[^\/]+\/([^\/]+)\/([^\/]+)\/releases(?:\/.*)?(?:\?.*)?$/,
},
// GitHub issues list
{
provider: "github",
regex:
/^https?:\/\/[^\/]+\/([^\/]+)\/([^\/]+)\/issues(?:\/(?:created_by|assigned)\/[\w.\/-]+)?\/?(?:\?.*)?$/,
},
// GitHub repo
{
provider: "github",
regex:
/^https?:\/\/[^\/]+\/([a-zA-Z0-9\-_.]+)\/([a-zA-Z0-9\-_.]+)\/?$/,
},
// GitLab commit in MR diff (must be before generic MR pattern)
{
provider: "gitlab",
regex:
/^https?:\/\/[^\/]+\/(.+)\/-\/merge_requests\/(\d+)\/diffs\?.*commit_id=([a-f0-9]+)/,
},
// GitLab merge request
{
provider: "gitlab",
regex:
/^https?:\/\/[^\/]+\/(.+)\/-\/merge_requests\/(\d+)/,
},
// GitLab issue
{
provider: "gitlab",
regex:
/^https?:\/\/[^\/]+\/(.+)\/-\/issues\/(\d+)/,
},
// GitLab work item (new URL format for issues)
{
provider: "gitlab",
regex:
/^https?:\/\/[^\/]+\/(.+)\/-\/work_items\/(\d+)/,
},
// GitLab work item opened as a drawer over the list (?show=base64 payload)
{
provider: "gitlab",
regex:
/^https?:\/\/[^\/]+\/(.+)\/-\/work_items\/?\?(?:.*&)?show=/,
},
// GitLab commit
{
provider: "gitlab",
regex:
/^https?:\/\/[^\/]+\/(.+)\/-\/commits?\/([a-f0-9]+)/,
},
// GitLab issues list
{
provider: "gitlab",
regex:
/^https?:\/\/[^\/]+\/(.+)\/-\/issues\/?(?:\?.*)?$/,
},
// GitLab merge requests list
{
provider: "gitlab",
regex:
/^https?:\/\/[^\/]+\/(.+)\/-\/merge_requests\/?(?:\?.*)?$/,
},
// GitLab project
{
provider: "gitlab",
regex:
/^https?:\/\/[^\/]+\/([a-zA-Z0-9\-_.]+)\/([a-zA-Z0-9\-_]+)\/?$/,
},
// Google Docs
{
provider: "google_docs",
regex: /^https?:\/\/docs\.google\.com\/document\/d\/([\w-]+)/,
},
// Google Sheets
{
provider: "google_docs",
regex: /^https?:\/\/docs\.google\.com\/spreadsheets\/d\/([\w-]+)/,
},
// Google Slides
{
provider: "google_docs",
regex: /^https?:\/\/docs\.google\.com\/presentation\/d\/([\w-]+)/,
},
// Google Forms
{
provider: "google_docs",
regex: /^https?:\/\/docs\.google\.com\/forms\/d\/([\w-]+)/,
},
// Google Drive file
{
provider: "google_docs",
regex: /^https?:\/\/drive\.google\.com\/file\/d\/([\w-]+)/,
},
// Figma file (design, file, proto, board)
{
provider: "figma",
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",
regex: /^https?:\/\/linear\.app\/([^\/]+)\/issue\/([A-Z]+-\d+)/,
},
// Linear project: /team/project/:slug(/:tab)?
{
provider: "linear",
regex: /^https?:\/\/linear\.app\/([^\/]+)\/project\/([^\/]+)/,
},
// Linear initiative: /team/initiative/:slug(/:tab)?
{
provider: "linear",
regex: /^https?:\/\/linear\.app\/([^\/]+)\/initiative\/([^\/]+)/,
},
// Linear view: /team/view/:id(/:tab)?
{
provider: "linear",
regex: /^https?:\/\/linear\.app\/([^\/]+)\/view\/([^\/]+)/,
},
];
export function matchIntegrationLink(
url: string,
): { provider: string; match: RegExpMatchArray } | null {
for (const pattern of integrationLinkPatterns) {
const match = url.match(pattern.regex);
if (match) {
return { provider: pattern.provider, match };
}
}
return null;
}
@@ -0,0 +1,114 @@
import { Node, mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { sanitizeUrl } from "../utils";
export interface IntegrationLinkOptions {
HTMLAttributes: Record<string, any>;
view: any;
}
export interface IntegrationLinkAttributes {
url: string;
provider: string;
}
// Shared by IntegrationLink (block) and IntegrationMention (inline) so both
// serialize the same attrs and stay convertible into each other. Unfurl data
// is intentionally NOT an attribute: it is fetched per viewer at render time
// so third-party permissions apply on every view.
export function createIntegrationAttributes() {
return {
url: {
default: "",
parseHTML: (element: HTMLElement) => {
const url = element.getAttribute("data-url");
return sanitizeUrl(url);
},
renderHTML: (attributes: IntegrationLinkAttributes) => ({
"data-url": sanitizeUrl(attributes.url),
}),
},
provider: {
default: "",
parseHTML: (element: HTMLElement) => element.getAttribute("data-provider"),
renderHTML: (attributes: IntegrationLinkAttributes) => ({
"data-provider": attributes.provider,
}),
},
};
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
integrationLink: {
setIntegrationLink: (
attributes: Partial<IntegrationLinkAttributes>,
) => ReturnType;
};
}
}
export const IntegrationLink = Node.create<IntegrationLinkOptions>({
name: "integrationLink",
inline: false,
group: "block",
isolating: true,
atom: true,
defining: true,
draggable: true,
addOptions() {
return {
HTMLAttributes: {},
view: null,
};
},
addAttributes() {
return createIntegrationAttributes();
},
parseHTML() {
return [
{
tag: `div[data-type="${this.name}"]`,
},
];
},
renderHTML({ HTMLAttributes }) {
const url = HTMLAttributes["data-url"];
const safeUrl = sanitizeUrl(url);
return [
"div",
mergeAttributes(
{ "data-type": this.name },
this.options.HTMLAttributes,
HTMLAttributes,
),
["a", { href: safeUrl, target: "_blank", rel: "noopener" }, safeUrl],
];
},
addCommands() {
return {
setIntegrationLink:
(attrs) =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: {
...attrs,
url: sanitizeUrl(attrs.url),
},
});
},
};
},
addNodeView() {
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view);
},
});
@@ -0,0 +1,82 @@
import { Node, mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { sanitizeUrl } from "../utils";
import {
createIntegrationAttributes,
IntegrationLinkAttributes,
IntegrationLinkOptions,
} from "./integration-link";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
integrationMention: {
setIntegrationMention: (
attributes: Partial<IntegrationLinkAttributes>,
) => ReturnType;
};
}
}
// Inline counterpart of IntegrationLink: same attrs, flows with text.
export const IntegrationMention = Node.create<IntegrationLinkOptions>({
name: "integrationMention",
inline: true,
group: "inline",
atom: true,
selectable: true,
draggable: false,
addOptions() {
return {
HTMLAttributes: {},
view: null,
};
},
addAttributes() {
return createIntegrationAttributes();
},
parseHTML() {
return [
{
tag: `span[data-type="${this.name}"]`,
},
];
},
renderHTML({ HTMLAttributes }) {
const url = HTMLAttributes["data-url"];
const safeUrl = sanitizeUrl(url);
return [
"span",
mergeAttributes(
{ "data-type": this.name },
this.options.HTMLAttributes,
HTMLAttributes,
),
["a", { href: safeUrl, target: "_blank", rel: "noopener" }, safeUrl],
];
},
addCommands() {
return {
setIntegrationMention:
(attrs) =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: {
...attrs,
url: sanitizeUrl(attrs.url),
},
});
},
};
},
addNodeView() {
return ReactNodeViewRenderer(this.options.view);
},
});