From b7f0b063dc576015482e7aed20357ee014626afa Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:29:21 +0100 Subject: [PATCH 01/27] feat(ee): add enterprise ai chat controls (#2421) * feat(ee): add enterprise ai chat controls * feat: add comment listing tool to ai chat * fix: scope comment endpoints to workspace and skip trashed pages --- .../public/locales/de-DE/translation.json | 2 +- .../public/locales/en-US/translation.json | 10 ++- .../public/locales/es-ES/translation.json | 2 +- .../public/locales/fr-FR/translation.json | 2 +- .../public/locales/it-IT/translation.json | 2 +- .../public/locales/ja-JP/translation.json | 2 +- .../public/locales/ko-KR/translation.json | 2 +- .../public/locales/nl-NL/translation.json | 2 +- .../public/locales/pt-BR/translation.json | 2 +- .../public/locales/ru-RU/translation.json | 2 +- .../public/locales/uk-UA/translation.json | 2 +- .../public/locales/zh-CN/translation.json | 2 +- .../ai-chat/components/ai-chat-read-only.tsx | 74 +++++++++++++++++++ .../ai-chat-workspace-knowledge-only.tsx | 74 +++++++++++++++++++ .../ai-chat/components/chat-empty-state.tsx | 17 ++++- .../src/ee/ai-chat/components/chat-input.tsx | 35 ++++++++- apps/client/src/ee/ai/pages/ai-settings.tsx | 24 +++++- apps/client/src/ee/features.ts | 1 + .../workspace/types/workspace.types.ts | 4 + apps/server/src/common/features.ts | 1 + .../src/core/comment/comment.controller.ts | 17 +++-- .../workspace/dto/update-workspace.dto.ts | 8 ++ .../workspace/services/workspace.service.ts | 49 +++++++++++- apps/server/src/ee | 2 +- 24 files changed, 308 insertions(+), 30 deletions(-) create mode 100644 apps/client/src/ee/ai-chat/components/ai-chat-read-only.tsx create mode 100644 apps/client/src/ee/ai-chat/components/ai-chat-workspace-knowledge-only.tsx diff --git a/apps/client/public/locales/de-DE/translation.json b/apps/client/public/locales/de-DE/translation.json index f067ecc27..084c92ef5 100644 --- a/apps/client/public/locales/de-DE/translation.json +++ b/apps/client/public/locales/de-DE/translation.json @@ -696,7 +696,7 @@ "Upgrade your plan": "Upgrade Ihres Plans", "Available with a paid license": "Verfügbar mit einer kostenpflichtigen Lizenz", "Upgrade your license tier.": "Stufen Sie Ihre Lizenz hoch.", - "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "KI ist nur in der Docmost Enterprise-Edition verfügbar. Kontaktieren Sie sales@docmost.com.", + "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "KI ist in den kostenpflichtigen Docmost-Editionen verfügbar. Kontaktieren Sie sales@docmost.com.", "AI & MCP": "KI & MCP", "AI": "KI", "MCP": "MCP", diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 03e10c53e..e5768c7e4 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -698,7 +698,7 @@ "Upgrade your plan": "Upgrade your plan", "Available with a paid license": "Available with a paid license", "Upgrade your license tier.": "Upgrade your license tier.", - "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.", + "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "AI is available in the Docmost paid editions. Contact sales@docmost.com.", "AI & MCP": "AI & MCP", "AI": "AI", "MCP": "MCP", @@ -1302,5 +1302,11 @@ "Error loading attachments.": "Error loading attachments.", "No attachments on this page yet.": "No attachments on this page yet.", "Uploaded by {{name}}": "Uploaded by {{name}}", - "Download {{name}}": "Download {{name}}" + "Download {{name}}": "Download {{name}}", + "Workspace knowledge only": "Workspace knowledge only", + "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.", + "Toggle workspace knowledge only": "Toggle workspace knowledge only", + "Read-only mode": "Read-only mode", + "AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat can search and read workspace content, but cannot create or edit pages.", + "Toggle AI Chat read-only mode": "Toggle AI Chat read-only mode" } diff --git a/apps/client/public/locales/es-ES/translation.json b/apps/client/public/locales/es-ES/translation.json index fd834a2c6..fb5364725 100644 --- a/apps/client/public/locales/es-ES/translation.json +++ b/apps/client/public/locales/es-ES/translation.json @@ -696,7 +696,7 @@ "Upgrade your plan": "Mejora tu plan", "Available with a paid license": "Disponible con una licencia de pago", "Upgrade your license tier.": "Mejora el nivel de tu licencia.", - "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "La IA solo está disponible en la edición empresarial de Docmost. Contacte con sales@docmost.com.", + "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "La IA está disponible en las ediciones de pago de Docmost. Contacte con sales@docmost.com.", "AI & MCP": "IA y MCP", "AI": "IA", "MCP": "MCP", diff --git a/apps/client/public/locales/fr-FR/translation.json b/apps/client/public/locales/fr-FR/translation.json index 04ebbc6ad..e9fbeed06 100644 --- a/apps/client/public/locales/fr-FR/translation.json +++ b/apps/client/public/locales/fr-FR/translation.json @@ -696,7 +696,7 @@ "Upgrade your plan": "Mettez à niveau votre forfait", "Available with a paid license": "Disponible avec une licence payante", "Upgrade your license tier.": "Mettez à niveau votre niveau de licence.", - "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "L'IA n'est disponible que dans l'édition Entreprise de Docmost. Contactez sales@docmost.com.", + "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "L'IA est disponible dans les éditions payantes de Docmost. Contactez sales@docmost.com.", "AI & MCP": "IA & MCP", "AI": "IA", "MCP": "MCP", diff --git a/apps/client/public/locales/it-IT/translation.json b/apps/client/public/locales/it-IT/translation.json index e3aaa82ce..81eb1727f 100644 --- a/apps/client/public/locales/it-IT/translation.json +++ b/apps/client/public/locales/it-IT/translation.json @@ -696,7 +696,7 @@ "Upgrade your plan": "Aggiorna il tuo piano", "Available with a paid license": "Disponibile con una licenza a pagamento", "Upgrade your license tier.": "Aggiorna il livello della tua licenza.", - "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "L'IA è disponibile solo nell'edizione Enterprise di Docmost. Contatta sales@docmost.com.", + "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "L'IA è disponibile nelle edizioni a pagamento di Docmost. Contatta sales@docmost.com.", "AI & MCP": "IA e MCP", "AI": "IA", "MCP": "MCP", diff --git a/apps/client/public/locales/ja-JP/translation.json b/apps/client/public/locales/ja-JP/translation.json index e408bfda8..c1bfa1652 100644 --- a/apps/client/public/locales/ja-JP/translation.json +++ b/apps/client/public/locales/ja-JP/translation.json @@ -696,7 +696,7 @@ "Upgrade your plan": "プランをアップグレードする", "Available with a paid license": "有料ライセンスで利用可能", "Upgrade your license tier.": "ライセンスタイアをアップグレードしてください。", - "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI は Docmost のエンタープライズ版でのみ利用可能です。sales@docmost.com までお問い合わせください。", + "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "AI は Docmost の有料版で利用可能です。sales@docmost.com までお問い合わせください。", "AI & MCP": "AI と MCP", "AI": "AI", "MCP": "MCP", diff --git a/apps/client/public/locales/ko-KR/translation.json b/apps/client/public/locales/ko-KR/translation.json index fb8e9884a..a7d795454 100644 --- a/apps/client/public/locales/ko-KR/translation.json +++ b/apps/client/public/locales/ko-KR/translation.json @@ -696,7 +696,7 @@ "Upgrade your plan": "요금제를 업그레이드하세요", "Available with a paid license": "유료 라이선스에서만 사용 가능합니다", "Upgrade your license tier.": "라이선스 등급을 업그레이드하세요.", - "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI는 Docmost 엔터프라이즈 에디션에서만 제공됩니다. sales@docmost.com으로 문의하세요.", + "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "AI는 Docmost 유료 에디션에서 제공됩니다. sales@docmost.com으로 문의하세요.", "AI & MCP": "AI 및 MCP", "AI": "AI", "MCP": "MCP", diff --git a/apps/client/public/locales/nl-NL/translation.json b/apps/client/public/locales/nl-NL/translation.json index 59c1d7a73..1f1465ab1 100644 --- a/apps/client/public/locales/nl-NL/translation.json +++ b/apps/client/public/locales/nl-NL/translation.json @@ -696,7 +696,7 @@ "Upgrade your plan": "Upgrade je abonnement", "Available with a paid license": "Beschikbaar met een betaalde licentie", "Upgrade your license tier.": "Upgrade je licentieniveau.", - "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI is alleen beschikbaar in de Docmost Enterprise-editie. Neem contact op met sales@docmost.com.", + "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "AI is beschikbaar in de betaalde edities van Docmost. Neem contact op met sales@docmost.com.", "AI & MCP": "AI & MCP", "AI": "AI", "MCP": "MCP", diff --git a/apps/client/public/locales/pt-BR/translation.json b/apps/client/public/locales/pt-BR/translation.json index 2e4cd89ef..82f213703 100644 --- a/apps/client/public/locales/pt-BR/translation.json +++ b/apps/client/public/locales/pt-BR/translation.json @@ -696,7 +696,7 @@ "Upgrade your plan": "Faça upgrade do seu plano", "Available with a paid license": "Disponível com uma licença paga", "Upgrade your license tier.": "Faça upgrade do seu nível de licença.", - "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "A IA está disponível apenas na edição empresarial do Docmost. Contate sales@docmost.com.", + "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "A IA está disponível nas edições pagas do Docmost. Contate sales@docmost.com.", "AI & MCP": "IA e MCP", "AI": "IA", "MCP": "MCP", diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index bae44d133..9d4ddc8a6 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -696,7 +696,7 @@ "Upgrade your plan": "Обновите свой тарифный план", "Available with a paid license": "Доступно с платной лицензией", "Upgrade your license tier.": "Обновите уровень вашей лицензии.", - "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "ИИ доступен только в корпоративной версии Docmost. Свяжитесь по адресу sales@docmost.com.", + "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "ИИ доступен в платных версиях Docmost. Свяжитесь по адресу sales@docmost.com.", "AI & MCP": "ИИ и MCP", "AI": "ИИ", "MCP": "MCP", diff --git a/apps/client/public/locales/uk-UA/translation.json b/apps/client/public/locales/uk-UA/translation.json index d3dd687ea..be09a646f 100644 --- a/apps/client/public/locales/uk-UA/translation.json +++ b/apps/client/public/locales/uk-UA/translation.json @@ -696,7 +696,7 @@ "Upgrade your plan": "Оновіть свій тарифний план", "Available with a paid license": "Доступно за платною ліцензією", "Upgrade your license tier.": "Оновіть рівень своєї ліцензії.", - "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "ШІ доступний лише в корпоративній редакції Docmost. Зверніться до sales@docmost.com.", + "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "ШІ доступний у платних редакціях Docmost. Зверніться до sales@docmost.com.", "AI & MCP": "ШІ та MCP", "AI": "ШІ", "MCP": "MCP", diff --git a/apps/client/public/locales/zh-CN/translation.json b/apps/client/public/locales/zh-CN/translation.json index 97ff9ce85..1b273e71c 100644 --- a/apps/client/public/locales/zh-CN/translation.json +++ b/apps/client/public/locales/zh-CN/translation.json @@ -696,7 +696,7 @@ "Upgrade your plan": "升级您的方案", "Available with a paid license": "需付费许可才可用", "Upgrade your license tier.": "升级您的许可等级。", - "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI 仅在 Docmost 企业版中提供。请联系 sales@docmost.com。", + "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "AI 在 Docmost 付费版中提供。请联系 sales@docmost.com。", "AI & MCP": "AI 与 MCP", "AI": "AI", "MCP": "MCP", diff --git a/apps/client/src/ee/ai-chat/components/ai-chat-read-only.tsx b/apps/client/src/ee/ai-chat/components/ai-chat-read-only.tsx new file mode 100644 index 000000000..732800299 --- /dev/null +++ b/apps/client/src/ee/ai-chat/components/ai-chat-read-only.tsx @@ -0,0 +1,74 @@ +import { Badge, Group, Text, Switch, Tooltip } from "@mantine/core"; +import { useAtom } from "jotai"; +import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; +import { notifications } from "@mantine/notifications"; +import { useHasFeature } from "@/ee/hooks/use-feature"; +import { Feature } from "@/ee/features"; +import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label"; + +export default function AiChatReadOnly() { + const { t } = useTranslation(); + const hasAccess = useHasFeature(Feature.AI_CONTROLS); + + return ( + +
+ + {t("Read-only mode")} + {!hasAccess && ( + + {t("Enterprise")} + + )} + + + {t( + "AI Chat can search and read workspace content, but cannot create or edit pages.", + )} + +
+ + +
+ ); +} + +function AiChatReadOnlyToggle() { + const { t } = useTranslation(); + const [workspace, setWorkspace] = useAtom(workspaceAtom); + const [checked, setChecked] = useState( + workspace?.settings?.ai?.chatReadOnly, + ); + const hasAccess = useHasFeature(Feature.AI_CONTROLS); + const upgradeLabel = useUpgradeLabel(); + + const handleChange = async (event: React.ChangeEvent) => { + const value = event.currentTarget.checked; + try { + const updatedWorkspace = await updateWorkspace({ + aiChatReadOnly: value, + }); + setChecked(value); + setWorkspace(updatedWorkspace); + } catch (err: any) { + notifications.show({ + message: err?.response?.data?.message, + color: "red", + }); + } + }; + + return ( + + + + ); +} diff --git a/apps/client/src/ee/ai-chat/components/ai-chat-workspace-knowledge-only.tsx b/apps/client/src/ee/ai-chat/components/ai-chat-workspace-knowledge-only.tsx new file mode 100644 index 000000000..497a337b4 --- /dev/null +++ b/apps/client/src/ee/ai-chat/components/ai-chat-workspace-knowledge-only.tsx @@ -0,0 +1,74 @@ +import { Badge, Group, Text, Switch, Tooltip } from "@mantine/core"; +import { useAtom } from "jotai"; +import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; +import { notifications } from "@mantine/notifications"; +import { useHasFeature } from "@/ee/hooks/use-feature"; +import { Feature } from "@/ee/features"; +import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label"; + +export default function AiChatWorkspaceKnowledgeOnly() { + const { t } = useTranslation(); + const hasAccess = useHasFeature(Feature.AI_CONTROLS); + + return ( + +
+ + {t("Workspace knowledge only")} + {!hasAccess && ( + + {t("Enterprise")} + + )} + + + {t( + "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.", + )} + +
+ + +
+ ); +} + +function AiChatWorkspaceKnowledgeOnlyToggle() { + const { t } = useTranslation(); + const [workspace, setWorkspace] = useAtom(workspaceAtom); + const [checked, setChecked] = useState( + workspace?.settings?.ai?.chatWorkspaceKnowledgeOnly, + ); + const hasAccess = useHasFeature(Feature.AI_CONTROLS); + const upgradeLabel = useUpgradeLabel(); + + const handleChange = async (event: React.ChangeEvent) => { + const value = event.currentTarget.checked; + try { + const updatedWorkspace = await updateWorkspace({ + aiChatWorkspaceKnowledgeOnly: value, + }); + setChecked(value); + setWorkspace(updatedWorkspace); + } catch (err: any) { + notifications.show({ + message: err?.response?.data?.message, + color: "red", + }); + } + }; + + return ( + + + + ); +} diff --git a/apps/client/src/ee/ai-chat/components/chat-empty-state.tsx b/apps/client/src/ee/ai-chat/components/chat-empty-state.tsx index 9583a2f03..423557c53 100644 --- a/apps/client/src/ee/ai-chat/components/chat-empty-state.tsx +++ b/apps/client/src/ee/ai-chat/components/chat-empty-state.tsx @@ -6,7 +6,10 @@ import { IconFileText, } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; -import ChatInput from "./chat-input"; +import { useAtomValue } from "jotai"; +import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; +import { useRef } from "react"; +import ChatInput, { ChatInputHandle } from "./chat-input"; import type { ChatAttachment, PageMention } from "../types/ai-chat.types"; import classes from "../styles/ai-chat.module.css"; @@ -14,6 +17,7 @@ type Suggestion = { icon: React.ReactNode; text: string; prompt: string; + write?: boolean; }; const SUGGESTIONS: Suggestion[] = [ @@ -26,6 +30,7 @@ const SUGGESTIONS: Suggestion[] = [ icon: , text: "Create a new page", prompt: "Create a new page titled ", + write: true, }, { icon: , @@ -36,6 +41,7 @@ const SUGGESTIONS: Suggestion[] = [ icon: , text: "Update page content", prompt: "Update the page @", + write: true, }, ]; @@ -47,9 +53,13 @@ type Props = { export default function ChatEmptyState({ isStreaming, onSend, onStop }: Props) { const { t } = useTranslation(); + const workspace = useAtomValue(workspaceAtom); + const writesDisabled = workspace?.settings?.ai?.chatReadOnly === true; + + const inputRef = useRef(null); const handleSuggestionClick = (prompt: string) => { - onSend(prompt, [], []); + inputRef.current?.prefill(prompt); }; return ( @@ -62,6 +72,7 @@ export default function ChatEmptyState({ isStreaming, onSend, onStop }: Props) {

{t("Get started")}

- {SUGGESTIONS.map((s) => ( + {SUGGESTIONS.filter((s) => !writesDisabled || !s.write).map((s) => ( + )} + + {!isAiMode && + orderedVisibleFilters.map((filterKey) => { + if (filterKey === "creator") { + return ( + + setOpenedFilter(opened ? "creator" : null) + } + > + + + ); + } + + if (filterKey === "labels") { + return ( + + setOpenedFilter(opened ? "labels" : null) + } + > + + + ); + } + + return null; + })} + + {!isAiMode && addableFilters.length > 0 && ( + + + + + + {addableFilters.map((filter) => ( + } + onClick={() => revealFilter(filter.key)} + > + {filter.label} + + ))} + + + )}
); } diff --git a/apps/client/src/features/search/components/search-spotlight.tsx b/apps/client/src/features/search/components/search-spotlight.tsx index 3ada7ab1f..4e7977650 100644 --- a/apps/client/src/features/search/components/search-spotlight.tsx +++ b/apps/client/src/features/search/components/search-spotlight.tsx @@ -1,7 +1,7 @@ import { Spotlight } from "@mantine/spotlight"; import { IconSearch, IconSparkles } from "@tabler/icons-react"; -import { Group, Button, VisuallyHidden } from "@mantine/core"; -import React, { useState, useMemo, useEffect } from "react"; +import { Group, Button, VisuallyHidden, Text } from "@mantine/core"; +import React, { useState, useMemo, useEffect, useCallback } from "react"; import { useDebouncedValue } from "@mantine/hooks"; import { useTranslation } from "react-i18next"; import { notifications } from "@mantine/notifications"; @@ -31,6 +31,9 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { const [filters, setFilters] = useState<{ spaceId?: string | null; contentType?: string; + creatorId?: string | null; + labelIds?: string[]; + titleOnly?: boolean; }>({ contentType: "page", }); @@ -48,10 +51,25 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { params.spaceId = filters.spaceId; } + if (filters.creatorId) { + params.creatorId = filters.creatorId; + } + + if (filters.labelIds?.length) { + params.labelIds = filters.labelIds; + } + + if (filters.titleOnly) { + params.titleOnly = true; + } + return params; }, [debouncedSearchQuery, filters]); - const { data: searchResults, isLoading } = useUnifiedSearch( + const { + data: searchResults, + isFetching, + } = useUnifiedSearch( searchParams, !isAiMode // Disable regular search when in AI mode ); @@ -88,6 +106,11 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { } }, [aiSearchError, t]); + const isFilterBrowse = + (filters.labelIds?.length ?? 0) > 0 || !!filters.creatorId; + // while the debounce is pending the empty list is not a settled "no results" + const isQuerySettled = query === debouncedSearchQuery; + // Determine result type for rendering const isAttachmentSearch = filters.contentType === "attachment" && hasAttachmentIndexing; @@ -110,9 +133,9 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { } }; - const handleFiltersChange = (newFilters: any) => { + const handleFiltersChange = useCallback((newFilters: any) => { setFilters(newFilters); - }; + }, [setFilters]); const handleAskClick = () => { setIsAiMode(!isAiMode); @@ -182,7 +205,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { ? query.length > 0 && !isAiLoading && !aiSearchResult ? t("No answer available") : "" - : query.length > 0 && !isLoading + : (query.length > 0 || isFilterBrowse) && !isFetching ? resultItems.length === 0 ? t("No results found") : t("{{count}} results found", { count: resultItems.length }) @@ -209,15 +232,28 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { ) : ( <> - {query.length === 0 && resultItems.length === 0 && ( + {query.length === 0 && !isFilterBrowse && resultItems.length === 0 && ( {t("Start typing to search...")} )} - {query.length > 0 && !isLoading && resultItems.length === 0 && ( - {t("No results found...")} - )} + {(query.length > 0 || isFilterBrowse) && + !isFetching && + isQuerySettled && + resultItems.length === 0 && ( + {t("No results found...")} + )} {resultItems.length > 0 && <>{resultItems}} + + {(query.length > 0 || isFilterBrowse) && + isFetching && + resultItems.length === 0 && ( + + + {t("Searching...")} + + + )} )} diff --git a/apps/client/src/features/search/hooks/use-unified-search.ts b/apps/client/src/features/search/hooks/use-unified-search.ts index 5d294f4bd..9270477b9 100644 --- a/apps/client/src/features/search/hooks/use-unified-search.ts +++ b/apps/client/src/features/search/hooks/use-unified-search.ts @@ -39,6 +39,20 @@ export function useUnifiedSearch( return await searchPage(backendParams); } }, - enabled: !!params.query && enabled, + enabled: + (!!params.query || + (params.labelIds?.length ?? 0) > 0 || + !!params.creatorId) && + enabled, + // keep previous results only within the same search type; page results + // rendered as attachments (or vice versa) crash on missing fields + placeholderData: (previousData, previousQuery) => { + if (!params.query && !params.labelIds?.length && !params.creatorId) + return undefined; + if (previousQuery && previousQuery.queryKey[1] !== searchType) { + return undefined; + } + return previousData; + }, }); } diff --git a/apps/client/src/features/search/types/search.types.ts b/apps/client/src/features/search/types/search.types.ts index 9962b9ca2..60ae5d985 100644 --- a/apps/client/src/features/search/types/search.types.ts +++ b/apps/client/src/features/search/types/search.types.ts @@ -36,6 +36,9 @@ export interface IPageSearchParams { query: string; spaceId?: string; shareId?: string; + creatorId?: string; + labelIds?: string[]; + titleOnly?: boolean; } export interface IAttachmentSearch { diff --git a/apps/client/src/styles/a11y-overrides.css b/apps/client/src/styles/a11y-overrides.css index 25c1eafe7..1fb4264d9 100644 --- a/apps/client/src/styles/a11y-overrides.css +++ b/apps/client/src/styles/a11y-overrides.css @@ -33,3 +33,31 @@ color: var(--mantine-color-dimmed); -webkit-text-fill-color: var(--mantine-color-dimmed); } + +/* Spotlight's selected action ships as primary-filled blue with white text, + * but our custom action children (gray badge, dimmed snippet, gray icons) + * keep their light-surface colors on it and drop below WCAG AA 4.5:1 + * (WCAG 1.4.3). Use a gray selection one step above the gray-0/dark-6 + * hover instead - it matches the app's selection styling (we use no blue + * fills) and keeps every child at its already-passing resting contrast. + */ +.mantine-Spotlight-action[data-selected] { + background-color: light-dark( + var(--mantine-color-gray-1), + var(--mantine-color-dark-5) + ); + color: var(--mantine-color-text); + --action-description-color: var(--mantine-color-dimmed); + --action-description-opacity: 1; +} + +/* Result actions are tab stops (tabIndex restored via ref); give keyboard + * focus the same gray treatment as arrow-key selection so both navigation + * modes read identically (WCAG 2.4.7 focus visible). + */ +.mantine-Spotlight-action:focus-visible { + background-color: light-dark( + var(--mantine-color-gray-1), + var(--mantine-color-dark-5) + ); +} diff --git a/apps/server/src/core/attachment/services/attachment.service.ts b/apps/server/src/core/attachment/services/attachment.service.ts index 710f9d10b..2db13c817 100644 --- a/apps/server/src/core/attachment/services/attachment.service.ts +++ b/apps/server/src/core/attachment/services/attachment.service.ts @@ -116,8 +116,8 @@ export class AttachmentService { }); } - // Only index PDFs and DOCX files - if (['.pdf', '.docx'].includes(attachment.fileExt.toLowerCase())) { + // Only index PDF, DOCX and TXT files + if (['.pdf', '.docx', '.txt'].includes(attachment.fileExt.toLowerCase())) { await this.attachmentQueue.add( QueueJob.ATTACHMENT_INDEX_CONTENT, { diff --git a/apps/server/src/core/label/label.service.ts b/apps/server/src/core/label/label.service.ts index f0d63eba6..9ec0ab300 100644 --- a/apps/server/src/core/label/label.service.ts +++ b/apps/server/src/core/label/label.service.ts @@ -7,12 +7,15 @@ import { executeTx } from '@docmost/db/utils'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo'; import { normalizeLabelName } from './utils'; +import { EventEmitter2 } from "@nestjs/event-emitter"; +import { EventName } from "src/common/events/event.contants"; @Injectable() export class LabelService { constructor( private readonly labelRepo: LabelRepo, private readonly pagePermissionRepo: PagePermissionRepo, + private readonly eventEmitter: EventEmitter2, @InjectKysely() private readonly db: KyselyDB, ) {} @@ -34,6 +37,12 @@ export class LabelService { attached.push(label); } }); + + this.eventEmitter.emit(EventName.PAGE_UPDATED, { + pageIds: [pageId], + workspaceId: workspaceId, + }); + return attached; } @@ -64,6 +73,11 @@ export class LabelService { await this.labelRepo.deleteLabel(labelId, workspaceId, trx); } }); + + this.eventEmitter.emit(EventName.PAGE_UPDATED, { + pageIds: [pageId], + workspaceId: workspaceId, + }); } async getPageLabels(pageId: string, pagination: PaginationOptions) { diff --git a/apps/server/src/core/search/dto/search.dto.ts b/apps/server/src/core/search/dto/search.dto.ts index 8be6d338d..89fb1b289 100644 --- a/apps/server/src/core/search/dto/search.dto.ts +++ b/apps/server/src/core/search/dto/search.dto.ts @@ -1,4 +1,5 @@ import { + IsArray, IsBoolean, IsNotEmpty, IsNumber, @@ -8,9 +9,9 @@ import { } from 'class-validator'; export class SearchDTO { - @IsNotEmpty() + @IsOptional() @IsString() - query: string; + query?: string; @IsOptional() @IsUUID() @@ -24,6 +25,15 @@ export class SearchDTO { @IsUUID() creatorId?: string; + @IsOptional() + @IsArray() + @IsUUID('all', { each: true }) + labelIds?: string[]; + + @IsOptional() + @IsBoolean() + titleOnly?: boolean; + @IsOptional() @IsNumber() limit?: number; diff --git a/apps/server/src/core/search/search.service.ts b/apps/server/src/core/search/search.service.ts index 9883b2654..3db29b274 100644 --- a/apps/server/src/core/search/search.service.ts +++ b/apps/server/src/core/search/search.service.ts @@ -29,12 +29,36 @@ export class SearchService { workspaceId: string; }, ): Promise<{ items: SearchResponseDto[] }> { - const { query } = searchParams; + const query = searchParams.query?.trim() ?? ''; + const labelIds = [...new Set(searchParams.labelIds ?? [])]; + // selected filters (labels, creator) are browsable without a query + const browseByFilters = + query.length < 1 && + (labelIds.length > 0 || Boolean(searchParams.creatorId)); - if (query.length < 1) { + if (query.length < 1 && !browseByFilters) { return { items: [] }; } - const searchQuery = tsquery(query.trim() + '*'); + const searchQuery = tsquery(query + '*'); + const titleOnly = searchParams.titleOnly === true; + const titleQuery = query; + // escape LIKE wildcards; ranking keeps the raw query + const titleLikeQuery = query.replace(/[\\%_]/g, '\\$&'); + + const rankColumn = browseByFilters + ? sql`0`.as('rank') + : titleOnly + ? sql`word_similarity(lower(f_unaccent(${titleQuery})), lower(f_unaccent(pages.title)))`.as( + 'rank', + ) + : sql`ts_rank(tsv, to_tsquery('english', f_unaccent(${searchQuery})))`.as( + 'rank', + ); + const highlightColumn = browseByFilters || titleOnly + ? sql`''`.as('highlight') + : sql`ts_headline('english', text_content, to_tsquery('english', f_unaccent(${searchQuery})),'MinWords=9, MaxWords=10, MaxFragments=3')`.as( + 'highlight', + ); let queryResults = this.db .selectFrom('pages') @@ -47,23 +71,41 @@ export class SearchService { 'creatorId', 'createdAt', 'updatedAt', - sql`ts_rank(tsv, to_tsquery('english', f_unaccent(${searchQuery})))`.as( - 'rank', - ), - sql`ts_headline('english', text_content, to_tsquery('english', f_unaccent(${searchQuery})),'MinWords=9, MaxWords=10, MaxFragments=3')`.as( - 'highlight', - ), + rankColumn, + highlightColumn, ]) - .where( - 'tsv', - '@@', - sql`to_tsquery('english', f_unaccent(${searchQuery}))`, + .$if(!browseByFilters && !titleOnly, (qb) => + qb.where( + 'tsv', + '@@', + sql`to_tsquery('english', f_unaccent(${searchQuery}))`, + ), + ) + .$if(!browseByFilters && titleOnly, (qb) => + qb.where((eb) => + eb( + sql`lower(f_unaccent(pages.title))`, + 'like', + sql`lower(f_unaccent(${`%${titleLikeQuery}%`}))`, + ), + ), ) .$if(Boolean(searchParams.creatorId), (qb) => qb.where('creatorId', '=', searchParams.creatorId), ) + .$if(labelIds?.length > 0, (qb) => + qb.where( + 'id', + 'in', + this.db + .selectFrom('pageLabels') + .select('pageId') + .where('labelId', 'in', labelIds), + ), + ) .where('deletedAt', 'is', null) - .orderBy('rank', 'desc') + .$if(browseByFilters, (qb) => qb.orderBy('updatedAt', 'desc')) + .$if(!browseByFilters, (qb) => qb.orderBy('rank', 'desc')) .limit(searchParams.limit || 25) .offset(searchParams.offset || 0); @@ -71,8 +113,7 @@ export class SearchService { queryResults = queryResults.select((eb) => this.pageRepo.withSpace(eb)); } - if (searchParams.spaceId) { - // search by spaceId + if (searchParams.spaceId && opts.userId) { queryResults = queryResults.where('spaceId', '=', searchParams.spaceId); } else if (opts.userId && !searchParams.spaceId) { // only search spaces the user is a member of diff --git a/apps/server/src/core/share/share.service.ts b/apps/server/src/core/share/share.service.ts index 03ee31555..e567e6caa 100644 --- a/apps/server/src/core/share/share.service.ts +++ b/apps/server/src/core/share/share.service.ts @@ -46,8 +46,9 @@ export class ShareService { throw new NotFoundException('Share not found'); } - const isRestricted = - await this.pagePermissionRepo.hasRestrictedAncestor(share.pageId); + const isRestricted = await this.pagePermissionRepo.hasRestrictedAncestor( + share.pageId, + ); if (isRestricted) { throw new NotFoundException('Share not found'); } @@ -110,6 +111,9 @@ export class ShareService { } async getSharedPage(dto: ShareInfoDto, workspaceId: string) { + //TODO: we should resolve the page from the share id + if (!dto.pageId) throw new NotFoundException('Shared page not found'); + const share = await this.getShareForPage(dto.pageId, workspaceId); if (!share) { @@ -126,8 +130,9 @@ export class ShareService { } // Block access to restricted pages - const isRestricted = - await this.pagePermissionRepo.hasRestrictedAncestor(page.id); + const isRestricted = await this.pagePermissionRepo.hasRestrictedAncestor( + page.id, + ); if (isRestricted) { throw new NotFoundException('Shared page not found'); } diff --git a/apps/server/src/database/migrations/20260824T211732-page-title-trgm-index.ts b/apps/server/src/database/migrations/20260824T211732-page-title-trgm-index.ts new file mode 100644 index 000000000..3cadad07b --- /dev/null +++ b/apps/server/src/database/migrations/20260824T211732-page-title-trgm-index.ts @@ -0,0 +1,17 @@ +import { type Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE INDEX IF NOT EXISTS pages_title_trgm_idx ON pages USING gin (lower(f_unaccent(title)) gin_trgm_ops)`.execute( + db, + ); + + // separators normalized to spaces so space-typed queries match How_to_export.pdf + await sql`CREATE INDEX IF NOT EXISTS attachments_file_name_trgm_idx ON attachments USING gin (lower(f_unaccent(translate(file_name, '_.-', ' '))) gin_trgm_ops)`.execute( + db, + ); +} + +export async function down(db: Kysely): Promise { + await sql`DROP INDEX IF EXISTS attachments_file_name_trgm_idx`.execute(db); + await sql`DROP INDEX IF EXISTS pages_title_trgm_idx`.execute(db); +} diff --git a/apps/server/src/ee b/apps/server/src/ee index 62f745608..b51a46ef1 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 62f7456089d97ceedbcbad1ceabce09a4c7e1301 +Subproject commit b51a46ef104cb407a670462a36be79ef0da94f97 From 3a25336d3d6d4a7e9b5884fbdac1bf6c37ae4e2e Mon Sep 17 00:00:00 2001 From: Salihu <91833785+salihudickson@users.noreply.github.com> Date: Tue, 25 Aug 2026 01:39:55 +0100 Subject: [PATCH 04/27] fix: display subpages correctly on first load (#2423) --- .../editor/components/subpages/subpages-view.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/client/src/features/editor/components/subpages/subpages-view.tsx b/apps/client/src/features/editor/components/subpages/subpages-view.tsx index cfea74e10..4264a1021 100644 --- a/apps/client/src/features/editor/components/subpages/subpages-view.tsx +++ b/apps/client/src/features/editor/components/subpages/subpages-view.tsx @@ -13,14 +13,17 @@ import { import { useTranslation } from "react-i18next"; import { sortPositionKeys } from "@/features/page/tree/utils/utils"; import { useSharedPageSubpages } from "@/features/share/hooks/use-shared-page-subpages"; +import { extractPageSlugId } from "@/lib"; export default function SubpagesView(props: NodeViewProps) { const { editor } = props; - const { spaceSlug, shareId } = useParams(); + const { spaceSlug, shareId, pageSlug } = useParams(); const { t } = useTranslation(); - - //@ts-ignore - const currentPageId = editor.storage.pageId; + + // @ts-ignore + const storagePageId = editor.storage.pageId; + const routePageId = extractPageSlugId(pageSlug); + const currentPageId = storagePageId ?? routePageId; // Get subpages from shared tree if we're in a shared context const sharedSubpages = useSharedPageSubpages(currentPageId); From 7bd73f77a4a5e15f5f84eca4e2d4361908c95899 Mon Sep 17 00:00:00 2001 From: Salihu <91833785+salihudickson@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:43:19 +0100 Subject: [PATCH 05/27] feat: lightbox media display (#2420) * lightbox media display init * revert uninteded changes * load page media more accurately * restrict lock file changes to installed packages * revert changes in lock file * update lockfile * - support diagrams - support readonly mode - use softer backdrop - other enhancments --------- Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com> --- apps/client/package.json | 1 + .../public/locales/en-US/translation.json | 1 + .../src/features/editor/atoms/editor-atoms.ts | 8 + .../components/bubble-menu/bubble-menu.tsx | 31 ++- .../components/bubble-menu/color-selector.tsx | 161 ++++++++-------- .../components/bubble-menu/link-selector.tsx | 2 +- .../bubble-menu/text-alignment-selector.tsx | 7 +- .../components/common/lightbox-view.tsx | 176 ++++++++++++++++++ .../editor/components/drawio/drawio-menu.tsx | 21 +++ .../components/excalidraw/excalidraw-menu.tsx | 21 +++ .../editor/components/image/image-menu.tsx | 21 +++ .../components/subpages/subpages-view.tsx | 8 +- .../editor/components/video/video-menu.tsx | 21 +++ .../src/features/editor/page-editor.tsx | 31 +++ .../features/editor/readonly-page-editor.tsx | 48 ++++- apps/server/src/ee | 2 +- pnpm-lock.yaml | 25 +++ 17 files changed, 489 insertions(+), 96 deletions(-) create mode 100644 apps/client/src/features/editor/components/common/lightbox-view.tsx diff --git a/apps/client/package.json b/apps/client/package.json index cca0ef4be..d836d5db6 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -64,6 +64,7 @@ "react-router-dom": "7.18.2", "semver": "7.7.4", "socket.io-client": "4.8.3", + "yet-another-react-lightbox": "^3.32.2", "zod": "4.3.6" }, "devDependencies": { diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 14bb06d85..098fb45dd 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -294,6 +294,7 @@ "Export space": "Export space", "Export {{type}}": "Export {{type}}", "File exceeds the {{limit}} attachment limit": "File exceeds the {{limit}} attachment limit", + "Media": "Media", "Align left": "Align left", "Align right": "Align right", "Align center": "Align center", diff --git a/apps/client/src/features/editor/atoms/editor-atoms.ts b/apps/client/src/features/editor/atoms/editor-atoms.ts index 74692d916..2de76fe70 100644 --- a/apps/client/src/features/editor/atoms/editor-atoms.ts +++ b/apps/client/src/features/editor/atoms/editor-atoms.ts @@ -16,6 +16,14 @@ export const showAiMenuAtom = atom(false); export const showLinkMenuAtom = atom(false); +export type LightboxRequest = { + src: string; + type: "image" | "video"; +} | null; + +const initialLightboxRequest: LightboxRequest = null; +export const lightboxRequestAtom = atom(initialLightboxRequest); + // Current page's edit mode — initialized from the user's saved preference on // first load, can be toggled locally without persisting to the server. export const currentPageEditModeAtom = atom(PageEditMode.Edit); diff --git a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx index a1283e34a..02108292e 100644 --- a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx +++ b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx @@ -23,11 +23,21 @@ import { } from "@/features/comment/atoms/comment-atom"; import { useAtom, useAtomValue } from "jotai"; import { v7 as uuid7 } from "uuid"; -import { isCellSelection, isEditorReady, isTextSelected } from "@docmost/editor-ext"; +import { + isCellSelection, + isEditorReady, + isTextSelected, +} from "@docmost/editor-ext"; import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx"; import { useTranslation } from "react-i18next"; -import { showAiMenuAtom, showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms"; -import { userAtom, workspaceAtom } from "@/features/user/atoms/current-user-atom"; +import { + showAiMenuAtom, + showLinkMenuAtom, +} from "@/features/editor/atoms/editor-atoms"; +import { + userAtom, + workspaceAtom, +} from "@/features/user/atoms/current-user-atom"; export interface BubbleMenuItem { name: string; @@ -217,7 +227,12 @@ export const EditorBubbleMenu: FC = (props) => { {items.map((item, index) => ( - + = (props) => { aria-label={t(item.name)} className={clsx({ [classes.active]: item.isActive() })} style={{ border: "none" }} - onClick={() => isEditorReady(props.editor) && item.command()} + onClick={() => + isEditorReady(props.editor) && item.command() + } > @@ -256,7 +273,9 @@ export const EditorBubbleMenu: FC = (props) => { radius="6px" aria-label={t(commentItem.name)} style={{ border: "none" }} - onClick={() => isEditorReady(props.editor) && commentItem.command()} + onClick={() => + isEditorReady(props.editor) && commentItem.command() + } > diff --git a/apps/client/src/features/editor/components/bubble-menu/color-selector.tsx b/apps/client/src/features/editor/components/bubble-menu/color-selector.tsx index a228dc645..533bcda8a 100644 --- a/apps/client/src/features/editor/components/bubble-menu/color-selector.tsx +++ b/apps/client/src/features/editor/components/bubble-menu/color-selector.tsx @@ -129,8 +129,7 @@ function handleColorKeyNav( grid: "text" | "highlight", ) { const cols = COLOR_GRID_COLS; - const total = - grid === "text" ? TEXT_COLORS.length : HIGHLIGHT_COLORS.length; + const total = grid === "text" ? TEXT_COLORS.length : HIGHLIGHT_COLORS.length; const col = index % cols; if (e.key === "ArrowRight") { @@ -163,8 +162,7 @@ function handleColorKeyNav( if (prev >= 0) { focusSwatch(grid, prev); } else if (grid === "highlight") { - const lastRowStart = - Math.floor((TEXT_COLORS.length - 1) / cols) * cols; + const lastRowStart = Math.floor((TEXT_COLORS.length - 1) / cols) * cols; focusSwatch("text", Math.min(lastRowStart + col, TEXT_COLORS.length - 1)); } return; @@ -222,7 +220,7 @@ export const ColorSelector: FC = ({ withArrow > - + - + + ); diff --git a/apps/client/src/features/editor/components/bubble-menu/link-selector.tsx b/apps/client/src/features/editor/components/bubble-menu/link-selector.tsx index fdacf6a18..0b7992f18 100644 --- a/apps/client/src/features/editor/components/bubble-menu/link-selector.tsx +++ b/apps/client/src/features/editor/components/bubble-menu/link-selector.tsx @@ -10,7 +10,7 @@ export const LinkSelector: FC = () => { const setShowLinkMenu = useSetAtom(showLinkMenuAtom); return ( - + = ({ onChange={setIsOpen} > - + + {requiresTest && ( + + {t("Test the connection before saving.")} + + )} + + + + + + + + + + ); +} diff --git a/apps/client/src/ee/siem/components/destination-status-badge.tsx b/apps/client/src/ee/siem/components/destination-status-badge.tsx new file mode 100644 index 000000000..17618aab8 --- /dev/null +++ b/apps/client/src/ee/siem/components/destination-status-badge.tsx @@ -0,0 +1,15 @@ +import { Badge } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { ISiemDestination } from "@/ee/siem/types/siem.types"; + +export function DestinationStatusBadge({ destination }: { destination: ISiemDestination }) { + const { t } = useTranslation(); + + if (!destination.enabled) { + return {t("Disabled")}; + } + if (destination.status === "failing") { + return {t("Failing")}; + } + return {t("Healthy")}; +} diff --git a/apps/client/src/ee/siem/components/destination-table.tsx b/apps/client/src/ee/siem/components/destination-table.tsx new file mode 100644 index 000000000..c2d540a43 --- /dev/null +++ b/apps/client/src/ee/siem/components/destination-table.tsx @@ -0,0 +1,155 @@ +import { ActionIcon, Menu, Switch, Table, Text, Tooltip } from "@mantine/core"; +import { + IconDots, + IconEdit, + IconPlugConnected, + IconRefresh, + IconTrash, +} from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { formattedDate, timeAgo } from "@/lib/time.ts"; +import { ISiemDestination, SiemDestinationType } from "@/ee/siem/types/siem.types"; +import { DestinationStatusBadge } from "./destination-status-badge"; + +export const DESTINATION_TYPE_LABELS: Record = { + splunk_hec: "Splunk HEC", + datadog: "Datadog", + http: "Generic HTTP", +}; + +interface DestinationTableProps { + destinations?: ISiemDestination[]; + isLoading?: boolean; + onEdit: (destination: ISiemDestination) => void; + onTest: (destination: ISiemDestination) => void; + onRetry: (destination: ISiemDestination) => void; + onDelete: (destination: ISiemDestination) => void; + onToggle: (destination: ISiemDestination, enabled: boolean) => void; +} + +export function DestinationTable({ + destinations, + isLoading, + onEdit, + onTest, + onRetry, + onDelete, + onToggle, +}: DestinationTableProps) { + const { t } = useTranslation(); + + return ( + + + + + {t("Name")} + {t("Type")} + {t("Enabled")} + {t("Status")} + {t("Last delivered")} + {t("Last error")} + + + + + {destinations && destinations.length > 0 ? ( + destinations.map((destination) => ( + + + {destination.name} + + + {DESTINATION_TYPE_LABELS[destination.type]} + + + onToggle(destination, event.currentTarget.checked)} + aria-label={t("Enabled")} + /> + + + + {destination.failingSince && + (destination.status === "failing" || + !destination.enabled) && ( + + {t("Failing since {{time}}", { + time: formattedDate( + new Date(destination.failingSince), + ), + })} + + )} + + + + {destination.lastDeliveredAt + ? timeAgo(new Date(destination.lastDeliveredAt)) + : t("Never")} + + + + {destination.lastError ? ( + + + {destination.lastError} + + + ) : ( + + )} + + + + + + + + + + } onClick={() => onEdit(destination)}> + {t("Edit")} + + } onClick={() => onTest(destination)}> + {t("Send test event")} + + } + onClick={() => onRetry(destination)} + disabled={!destination.nextAttemptAt} + > + {t("Retry now")} + + + } onClick={() => onDelete(destination)}> + {t("Delete")} + + + + + + )) + ) : ( + !isLoading && ( + + + + {t("No destinations yet")} + + + + ) + )} + +
+
+ ); +} diff --git a/apps/client/src/ee/siem/components/siem-streaming-panel.tsx b/apps/client/src/ee/siem/components/siem-streaming-panel.tsx new file mode 100644 index 000000000..e54230de3 --- /dev/null +++ b/apps/client/src/ee/siem/components/siem-streaming-panel.tsx @@ -0,0 +1,129 @@ +import { useState } from "react"; +import { Alert, Button, Group, Tooltip } from "@mantine/core"; +import { notifications } from "@mantine/notifications"; +import { IconAlertCircle, IconInfoCircle } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import useUserRole from "@/hooks/use-user-role"; +import { useHasFeature } from "@/ee/hooks/use-feature"; +import { Feature } from "@/ee/features"; +import { + ISiemDestination, + SIEM_MAX_DESTINATIONS_PER_WORKSPACE, +} from "@/ee/siem/types/siem.types"; +import { + useRetrySiemDestinationMutation, + useSiemDestinationsQuery, + extractErrorMessage, + useTestSiemDestinationMutation, + useUpdateSiemDestinationMutation, +} from "@/ee/siem/queries/siem-query"; +import { DestinationTable } from "@/ee/siem/components/destination-table"; +import { DestinationFormModal } from "@/ee/siem/components/destination-form-modal"; +import { DeleteDestinationModal } from "@/ee/siem/components/delete-destination-modal"; + +export default function SiemStreamingPanel() { + const { t } = useTranslation(); + const { isOwner } = useUserRole(); + const hasFeature = useHasFeature(Feature.SIEM); + const { data, isLoading, isError, error } = useSiemDestinationsQuery(hasFeature); + const updateMutation = useUpdateSiemDestinationMutation(); + const retryMutation = useRetrySiemDestinationMutation(); + const testMutation = useTestSiemDestinationMutation(); + const [formOpened, setFormOpened] = useState(false); + const [deleteOpened, setDeleteOpened] = useState(false); + const [selected, setSelected] = useState(null); + + if (!isOwner) { + return null; + } + + const atDestinationLimit = + (data?.length ?? 0) >= SIEM_MAX_DESTINATIONS_PER_WORKSPACE; + + const handleTest = async (destination: ISiemDestination) => { + const result = await testMutation + .mutateAsync({ + type: destination.type, + config: destination.config as unknown as Record, + destinationId: destination.id, + }) + .catch(() => null); + if (!result) return; + notifications.show({ + message: result.delivered + ? t("Test event delivered to {{name}}", { name: destination.name }) + : result.error, + color: result.delivered ? "green" : "red", + }); + }; + + return ( + <> + {!hasFeature && ( + } color="yellow" mb="md"> + {t("SIEM streaming requires an Enterprise license.")} + + )} + + + + + + + + + + {isError && ( + } color="red" mb="md"> + {t("Could not load SIEM destinations: {{message}}", { + message: extractErrorMessage(error), + })} + + )} + + {hasFeature && !isError && ( + { + setSelected(destination); + setFormOpened(true); + }} + onTest={handleTest} + onRetry={(destination) => retryMutation.mutate({ destinationId: destination.id })} + onDelete={(destination) => { + setSelected(destination); + setDeleteOpened(true); + }} + onToggle={(destination, enabled) => + updateMutation.mutate({ destinationId: destination.id, enabled }) + } + /> + )} + + setFormOpened(false)} + destination={selected} + /> + setDeleteOpened(false)} + destination={selected} + /> + + ); +} diff --git a/apps/client/src/ee/siem/lib/destination-form.ts b/apps/client/src/ee/siem/lib/destination-form.ts new file mode 100644 index 000000000..4a52cc4f6 --- /dev/null +++ b/apps/client/src/ee/siem/lib/destination-form.ts @@ -0,0 +1,179 @@ +import { + DATADOG_SITES, + ISiemDestination, + ISiemDestinationInput, + SiemDestinationType, +} from "@/ee/siem/types/siem.types"; + +export type DestinationFormValues = { + name: string; + type: SiemDestinationType; + url: string; + token: string; + apiKey: string; + authHeaderName: string; + authHeaderPrefix: string; + format: "json" | "ndjson"; + index: string; + source: string; + sourcetype: string; + host: string; + site: string; + service: string; + tags: string; + rejectUnauthorized: boolean; + enabled: boolean; +}; + +export const DEFAULT_FORM_VALUES: DestinationFormValues = { + name: "", + type: "splunk_hec", + url: "", + token: "", + apiKey: "", + authHeaderName: "Authorization", + authHeaderPrefix: "Bearer ", + format: "json", + index: "", + source: "docmost", + sourcetype: "docmost:audit", + host: "", + site: DATADOG_SITES[0], + service: "docmost", + tags: "", + rejectUnauthorized: true, + enabled: true, +}; + +const HEADER_NAME_RE = /^[A-Za-z0-9-]+$/; +export const SECRET_MASK = "********"; + +export function initialValues( + destination?: ISiemDestination | null, +): DestinationFormValues { + if (!destination) return { ...DEFAULT_FORM_VALUES }; + const config = destination.config as Record; + const tls = config.tls ?? {}; + return { + ...DEFAULT_FORM_VALUES, + name: destination.name, + type: destination.type, + enabled: destination.enabled, + token: destination.hasSecrets?.token ? SECRET_MASK : "", + apiKey: destination.hasSecrets?.apiKey ? SECRET_MASK : "", + url: config.url ?? "", + authHeaderName: config.authHeaderName ?? DEFAULT_FORM_VALUES.authHeaderName, + authHeaderPrefix: config.authHeaderPrefix ?? DEFAULT_FORM_VALUES.authHeaderPrefix, + format: config.format ?? "json", + index: config.index ?? "", + source: config.source ?? DEFAULT_FORM_VALUES.source, + sourcetype: config.sourcetype ?? DEFAULT_FORM_VALUES.sourcetype, + host: config.host ?? "", + site: config.site ?? DEFAULT_FORM_VALUES.site, + service: config.service ?? DEFAULT_FORM_VALUES.service, + tags: config.tags ?? "", + rejectUnauthorized: tls.rejectUnauthorized ?? true, + }; +} + +function isValidUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +} + +export function validateForm( + values: DestinationFormValues, + hasSecrets: Record = {}, +): Partial> { + const errors: Partial> = {}; + + if (!values.name.trim()) errors.name = "Name is required"; + + if (values.type !== "datadog" && !isValidUrl(values.url.trim())) { + errors.url = "Enter a valid http(s) URL"; + } + + if (values.type === "splunk_hec") { + if (!values.token && !hasSecrets.token) { + errors.token = "HEC token is required"; + } + try { + const url = new URL(values.url.trim()); + const path = url.pathname.replace(/\/+$/, ""); + if (path !== "" && path !== "/services/collector" && path !== "/services/collector/event") { + errors.url = "Enter the HEC base URL or the /services/collector/event endpoint"; + } + } catch {} + } + + if (values.type === "datadog") { + if (!(DATADOG_SITES as readonly string[]).includes(values.site)) { + errors.site = "Select a Datadog site"; + } + if (!values.apiKey && !hasSecrets.apiKey) errors.apiKey = "API key is required"; + } + + if (values.type === "http") { + if (!HEADER_NAME_RE.test(values.authHeaderName.trim())) { + errors.authHeaderName = "Use letters, digits and hyphens only"; + } + } + + + return errors; +} + +function enteredSecret(key: string, value: string): Record { + const trimmed = value.trim(); + return trimmed && trimmed !== SECRET_MASK ? { [key]: trimmed } : {}; +} + +export function toPayload(values: DestinationFormValues): ISiemDestinationInput { + const tls = { rejectUnauthorized: values.rejectUnauthorized }; + + let config: Record; + let secrets: Record; + + switch (values.type) { + case "splunk_hec": + config = { + url: values.url.trim(), + index: values.index.trim(), + source: values.source.trim() || "docmost", + sourcetype: values.sourcetype.trim() || "docmost:audit", + host: values.host.trim(), + tls, + }; + secrets = enteredSecret("token", values.token); + break; + case "datadog": + config = { + site: values.site, + service: values.service.trim() || "docmost", + tags: values.tags.trim(), + }; + secrets = enteredSecret("apiKey", values.apiKey); + break; + default: + config = { + url: values.url.trim(), + authHeaderName: values.authHeaderName.trim(), + authHeaderPrefix: values.authHeaderPrefix, + format: values.format, + tls, + }; + secrets = enteredSecret("token", values.token); + } + + return { + name: values.name.trim(), + type: values.type, + config, + secrets: Object.fromEntries(Object.entries(secrets).filter(([, v]) => v !== "")), + enabled: values.enabled, + }; +} diff --git a/apps/client/src/ee/siem/queries/siem-query.ts b/apps/client/src/ee/siem/queries/siem-query.ts new file mode 100644 index 000000000..e3f814c04 --- /dev/null +++ b/apps/client/src/ee/siem/queries/siem-query.ts @@ -0,0 +1,115 @@ +import { + useMutation, + useQuery, + useQueryClient, + UseQueryResult, +} from "@tanstack/react-query"; +import { notifications } from "@mantine/notifications"; +import { useTranslation } from "react-i18next"; +import { + createSiemDestination, + deleteSiemDestination, + getSiemDestinations, + retrySiemDestination, + testSiemDestination, + updateSiemDestination, +} from "@/ee/siem/services/siem-service"; +import { + ISiemDestination, + ISiemDestinationInput, + ISiemTestResult, + ITestSiemDestinationInput, + IUpdateSiemDestinationInput, +} from "@/ee/siem/types/siem.types"; + +export const SIEM_DESTINATIONS_KEY = ["siem-destinations"]; + +export function extractErrorMessage(error: Error): string { + const data = (error as any)?.response?.data; + const message = data?.message ?? error.message; + return Array.isArray(message) ? message.join(", ") : String(message); +} + +function showError(error: Error) { + notifications.show({ message: extractErrorMessage(error), color: "red" }); +} + +function isForbidden(error: unknown): boolean { + return (error as { response?: { status?: number } })?.response?.status === 403; +} + +export function useSiemDestinationsQuery( + enabled = true, +): UseQueryResult { + return useQuery({ + queryKey: SIEM_DESTINATIONS_KEY, + queryFn: getSiemDestinations, + enabled, + retry: (failureCount, error) => !isForbidden(error) && failureCount < 2, + refetchInterval: (query) => (query.state.status === "error" ? false : 15_000), + }); +} + +function useInvalidateDestinations() { + const queryClient = useQueryClient(); + return () => queryClient.invalidateQueries({ queryKey: SIEM_DESTINATIONS_KEY }); +} + +export function useCreateSiemDestinationMutation() { + const { t } = useTranslation(); + const invalidate = useInvalidateDestinations(); + return useMutation({ + mutationFn: createSiemDestination, + onSuccess: () => { + notifications.show({ message: t("Destination created") }); + invalidate(); + }, + onError: showError, + }); +} + +export function useUpdateSiemDestinationMutation() { + const { t } = useTranslation(); + const invalidate = useInvalidateDestinations(); + return useMutation({ + mutationFn: updateSiemDestination, + onSuccess: () => { + notifications.show({ message: t("Destination updated") }); + invalidate(); + }, + onError: showError, + }); +} + +export function useDeleteSiemDestinationMutation() { + const { t } = useTranslation(); + const invalidate = useInvalidateDestinations(); + return useMutation({ + mutationFn: deleteSiemDestination, + onSuccess: () => { + notifications.show({ message: t("Destination deleted") }); + invalidate(); + }, + onError: showError, + }); +} + +export function useRetrySiemDestinationMutation() { + const { t } = useTranslation(); + const invalidate = useInvalidateDestinations(); + return useMutation({ + mutationFn: retrySiemDestination, + onSuccess: () => { + notifications.show({ message: t("Retry scheduled") }); + invalidate(); + }, + onError: showError, + }); +} + +export function useTestSiemDestinationMutation() { + return useMutation({ + mutationFn: testSiemDestination, + onError: showError, + }); +} diff --git a/apps/client/src/ee/siem/services/siem-service.ts b/apps/client/src/ee/siem/services/siem-service.ts new file mode 100644 index 000000000..0c394ecf3 --- /dev/null +++ b/apps/client/src/ee/siem/services/siem-service.ts @@ -0,0 +1,46 @@ +import api from "@/lib/api-client"; +import { + ISiemDestination, + ISiemDestinationInput, + ISiemTestResult, + ITestSiemDestinationInput, + IUpdateSiemDestinationInput, +} from "@/ee/siem/types/siem.types"; + +export async function getSiemDestinations(): Promise { + const req = await api.post("/siem/destinations"); + return req.data; +} + +export async function createSiemDestination( + data: ISiemDestinationInput, +): Promise { + const req = await api.post("/siem/destinations/create", data); + return req.data; +} + +export async function updateSiemDestination( + data: IUpdateSiemDestinationInput, +): Promise { + const req = await api.post("/siem/destinations/update", data); + return req.data; +} + +export async function deleteSiemDestination(data: { + destinationId: string; +}): Promise { + await api.post("/siem/destinations/delete", data); +} + +export async function testSiemDestination( + data: ITestSiemDestinationInput, +): Promise { + const req = await api.post("/siem/destinations/test", data); + return req.data; +} + +export async function retrySiemDestination(data: { + destinationId: string; +}): Promise { + await api.post("/siem/destinations/retry", data); +} diff --git a/apps/client/src/ee/siem/types/siem.types.ts b/apps/client/src/ee/siem/types/siem.types.ts new file mode 100644 index 000000000..7e8a17232 --- /dev/null +++ b/apps/client/src/ee/siem/types/siem.types.ts @@ -0,0 +1,91 @@ +export const SIEM_MAX_DESTINATIONS_PER_WORKSPACE = 2; + +export type SiemDestinationType = "http" | "splunk_hec" | "datadog"; +export type SiemDestinationStatus = "healthy" | "failing"; + +export const DATADOG_SITES = [ + "datadoghq.com", + "datadoghq.eu", + "us3.datadoghq.com", + "us5.datadoghq.com", + "ap1.datadoghq.com", + "ddog-gov.com", +] as const; + +export interface ITlsOptions { + rejectUnauthorized: boolean; +} + +export interface IHttpConfig { + url: string; + authHeaderName: string; + authHeaderPrefix: string; + format: "json" | "ndjson"; + tls?: ITlsOptions; +} + +export interface ISplunkHecConfig { + url: string; + index?: string; + source: string; + sourcetype: string; + host?: string; + channelId: string; + tls?: ITlsOptions; +} + +export interface IDatadogConfig { + site: string; + service: string; + tags?: string; +} + +export type ISiemConfig = IHttpConfig | ISplunkHecConfig | IDatadogConfig; + +export interface ISiemDestination { + id: string; + name: string; + type: SiemDestinationType; + enabled: boolean; + status: SiemDestinationStatus; + config: ISiemConfig; + hasSecrets: Record; + cursorCreatedAt: string; + lastDeliveredAt: string | null; + lastError: string | null; + lastErrorAt: string | null; + consecutiveFailures: number; + nextAttemptAt: string | null; + failingSince: string | null; + createdAt: string; + updatedAt: string; +} + +export interface ISiemDestinationInput { + name: string; + type: SiemDestinationType; + config: Record; + secrets?: Record; + enabled?: boolean; +} + +export interface IUpdateSiemDestinationInput { + destinationId: string; + name?: string; + config?: Record; + secrets?: Record; + enabled?: boolean; +} + +export interface ITestSiemDestinationInput { + type: SiemDestinationType; + config: Record; + secrets?: Record; + destinationId?: string; +} + +export interface ISiemTestResult { + delivered: boolean; + error?: string; + statusCode?: number; +} diff --git a/apps/client/src/features/notification/components/notification-item.tsx b/apps/client/src/features/notification/components/notification-item.tsx index 418647375..12b9f2428 100644 --- a/apps/client/src/features/notification/components/notification-item.tsx +++ b/apps/client/src/features/notification/components/notification-item.tsx @@ -63,6 +63,12 @@ export function NotificationItem({ return "Page verification expires soon"; case "page.verification_expired": return "Page verification has expired"; + case "siem_destination.failing": + return "SIEM destination {{name}} is failing"; + case "siem_destination.disabled": + return "SIEM destination {{name}} was disabled after 24 hours of failures"; + case "siem_destination.recovered": + return "SIEM destination {{name}} recovered"; default: return ""; } @@ -77,6 +83,19 @@ export function NotificationItem({ ) : undefined; + const isSiemDestination = notification.type.startsWith("siem_destination."); + const destinationName = + typeof notification.data?.destinationName === "string" + ? notification.data.destinationName + : ""; + const lastError = + (notification.type === "siem_destination.failing" || + notification.type === "siem_destination.disabled") && + typeof notification.data?.lastError === "string" + ? notification.data.lastError + : null; + const linkUrl = isSiemDestination ? "/settings/audit/siem" : pageUrl; + const markReadIfNeeded = () => { if (isUnread) { markRead.mutate([notification.id]); @@ -97,7 +116,7 @@ export function NotificationItem({ return ( }} /> + {lastError && ( + + {lastError} + + )} + {notification.page && ( {notification.page.icon ? ( diff --git a/apps/client/src/features/notification/types/notification.types.ts b/apps/client/src/features/notification/types/notification.types.ts index 266b9a6e4..e5eb6f658 100644 --- a/apps/client/src/features/notification/types/notification.types.ts +++ b/apps/client/src/features/notification/types/notification.types.ts @@ -9,7 +9,10 @@ export type NotificationType = | "page.verification_expired" | "page.verified" | "page.approval_requested" - | "page.approval_rejected"; + | "page.approval_rejected" + | "siem_destination.failing" + | "siem_destination.disabled" + | "siem_destination.recovered"; export type INotification = { id: string; diff --git a/apps/server/package.json b/apps/server/package.json index c0a9ec88d..bc5f6543d 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -161,7 +161,8 @@ "moduleFileExtensions": [ "js", "json", - "ts" + "ts", + "tsx" ], "rootDir": "src", "testRegex": ".*\\.spec\\.ts$", @@ -181,7 +182,7 @@ ] } ], - "^.+\\.(t|j)s$": "ts-jest" + "^.+\\.(t|j)sx?$": "ts-jest" }, "transformIgnorePatterns": [ "/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom)(@|/))" diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index 2de94a662..5002ca8a7 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -28,6 +28,7 @@ import { LoggerModule } from './common/logger/logger.module'; import { ClsModule } from 'nestjs-cls'; import { NoopAuditModule } from './integrations/audit/audit.module'; import { ThrottleModule } from './integrations/throttle/throttle.module'; +import { OutboundModule } from './integrations/outbound/outbound.module'; import { EncryptionModule } from './integrations/encryption/encryption.module'; const enterpriseModules = []; @@ -51,7 +52,7 @@ try { middleware: { mount: true }, }), LoggerModule, - NoopAuditModule, + ...(enterpriseModules.length > 0 ? [] : [NoopAuditModule]), CoreModule, DatabaseModule, EnvironmentModule, @@ -98,6 +99,7 @@ try { SecurityModule, TelemetryModule, ThrottleModule, + OutboundModule, ...enterpriseModules, ], controllers: [AppController], diff --git a/apps/server/src/common/events/audit-events.ts b/apps/server/src/common/events/audit-events.ts index 24ca2af5d..7d088791b 100644 --- a/apps/server/src/common/events/audit-events.ts +++ b/apps/server/src/common/events/audit-events.ts @@ -14,6 +14,7 @@ export const AuditEvent = { USER_ROLE_CHANGED: 'user.role_changed', USER_PASSWORD_CHANGED: 'user.password_changed', USER_PASSWORD_RESET: 'user.password_reset', + USER_PASSWORD_RESET_REQUESTED: 'user.password_reset_requested', USER_UPDATED: 'user.updated', USER_DEACTIVATED: 'user.deactivated', USER_ACTIVATED: 'user.activated', @@ -69,6 +70,7 @@ export const AuditEvent = { PAGE_RESTRICTION_REMOVED: 'page.restriction_removed', PAGE_PERMISSION_ADDED: 'page.permission_added', PAGE_PERMISSION_REMOVED: 'page.permission_removed', + PAGE_PERMISSION_ROLE_CHANGED: 'page.permission_role_changed', // Page verification PAGE_VERIFICATION_CREATED: 'page.verification_created', PAGE_VERIFICATION_UPDATED: 'page.verification_updated', @@ -104,6 +106,16 @@ export const AuditEvent = { // Attachment ATTACHMENT_UPLOADED: 'attachment.uploaded', // ATTACHMENT_DELETED: 'attachment.deleted', + + // SIEM streaming + SIEM_DESTINATION_CREATED: 'siem_destination.created', + SIEM_DESTINATION_UPDATED: 'siem_destination.updated', + SIEM_DESTINATION_DELETED: 'siem_destination.deleted', + SIEM_DESTINATION_TEST: 'siem_destination.test', + + // Template + TEMPLATE_CREATED: 'template.created', + TEMPLATE_DELETED: 'template.deleted', } as const; export type AuditEventType = (typeof AuditEvent)[keyof typeof AuditEvent]; @@ -116,7 +128,8 @@ export const EXCLUDED_AUDIT_EVENTS: Set = new Set([ AuditEvent.COMMENT_UPDATED, AuditEvent.COMMENT_RESOLVED, AuditEvent.COMMENT_REOPENED, - AuditEvent.ATTACHMENT_UPLOADED + AuditEvent.ATTACHMENT_UPLOADED, + AuditEvent.SIEM_DESTINATION_TEST, ]); export const AuditResource = { @@ -136,6 +149,8 @@ export const AuditResource = { WORKSPACE_INVITATION: 'workspace_invitation', ATTACHMENT: 'attachment', LICENSE: 'license', + SIEM_DESTINATION: 'siem_destination', + TEMPLATE: 'template', } as const; export type AuditResourceType = diff --git a/apps/server/src/common/features.ts b/apps/server/src/common/features.ts index fcb21d35a..0afb3ca61 100644 --- a/apps/server/src/common/features.ts +++ b/apps/server/src/common/features.ts @@ -26,6 +26,7 @@ export const Feature = { OAUTH: 'oauth', AI_CONTROLS: 'ai:controls', MCP_CONTROLS: 'mcp:controls', + SIEM: 'siem', } as const; export type FeatureKey = (typeof Feature)[keyof typeof Feature]; diff --git a/apps/server/src/common/helpers/cache-keys.ts b/apps/server/src/common/helpers/cache-keys.ts index 38b24d20e..394ab173f 100644 --- a/apps/server/src/common/helpers/cache-keys.ts +++ b/apps/server/src/common/helpers/cache-keys.ts @@ -4,6 +4,7 @@ export const CacheKey = { `perm:space-roles:${userId}:${spaceId}`, PAGE_CAN_EDIT: (userId: string, pageId: string) => `perm:can-edit:${userId}:${pageId}`, + SIEM_LICENSED: (workspaceId: string) => `siem:licensed:${workspaceId}`, }; // Permission caches dedupe repeated checks within and across short request bursts. diff --git a/apps/server/src/core/auth/services/auth.service.ts b/apps/server/src/core/auth/services/auth.service.ts index 45148931e..303245045 100644 --- a/apps/server/src/core/auth/services/auth.service.ts +++ b/apps/server/src/core/auth/services/auth.service.ts @@ -219,6 +219,13 @@ export class AuthService { subject: 'Reset your password', template: emailTemplate, }); + + this.auditService.log({ + event: AuditEvent.USER_PASSWORD_RESET_REQUESTED, + resourceType: AuditResource.USER, + resourceId: user.id, + metadata: { source: 'forgot_password' }, + }); } async passwordReset( diff --git a/apps/server/src/core/notification/notification.constants.ts b/apps/server/src/core/notification/notification.constants.ts index fc42bc64d..894b98e4c 100644 --- a/apps/server/src/core/notification/notification.constants.ts +++ b/apps/server/src/core/notification/notification.constants.ts @@ -10,6 +10,9 @@ export const NotificationType = { PAGE_VERIFIED: 'page.verified', PAGE_APPROVAL_REQUESTED: 'page.approval_requested', PAGE_APPROVAL_REJECTED: 'page.approval_rejected', + SIEM_DESTINATION_FAILING: 'siem_destination.failing', + SIEM_DESTINATION_DISABLED: 'siem_destination.disabled', + SIEM_DESTINATION_RECOVERED: 'siem_destination.recovered', } as const; export type NotificationType = @@ -40,6 +43,9 @@ export const DIRECT_NOTIFICATION_TYPES: NotificationType[] = [ NotificationType.COMMENT_RESOLVED, NotificationType.PAGE_USER_MENTION, NotificationType.PAGE_PERMISSION_GRANTED, + NotificationType.SIEM_DESTINATION_FAILING, + NotificationType.SIEM_DESTINATION_DISABLED, + NotificationType.SIEM_DESTINATION_RECOVERED, ]; export const UPDATES_NOTIFICATION_TYPES: NotificationType[] = [ diff --git a/apps/server/src/database/migrations/20260902T121326-siem-destinations.ts b/apps/server/src/database/migrations/20260902T121326-siem-destinations.ts new file mode 100644 index 000000000..7b76cacea --- /dev/null +++ b/apps/server/src/database/migrations/20260902T121326-siem-destinations.ts @@ -0,0 +1,66 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('siem_destinations') + .ifNotExists() + .addColumn('id', 'uuid', (col) => + col.primaryKey().defaultTo(sql`gen_uuid_v7()`), + ) + .addColumn('workspace_id', 'uuid', (col) => + col.notNull().references('workspaces.id').onDelete('cascade'), + ) + .addColumn('name', 'varchar', (col) => col.notNull()) + .addColumn('type', 'varchar', (col) => col.notNull()) + .addColumn('enabled', 'boolean', (col) => col.notNull().defaultTo(true)) + .addColumn('config', 'jsonb', (col) => col.notNull()) + .addColumn('secrets', 'text', (col) => col.notNull()) + .addColumn('cursor_created_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addColumn('cursor_id', 'uuid', (col) => + col.notNull().defaultTo(sql`gen_uuid_v7()`), + ) + .addColumn('cursor_snapshot', 'text') + // Fences cursor writes from stale jobs after configuration changes. + .addColumn('version', 'integer', (col) => col.notNull().defaultTo(0)) + .addColumn('status', 'varchar', (col) => col.notNull().defaultTo('healthy')) + .addColumn('consecutive_failures', 'integer', (col) => + col.notNull().defaultTo(0), + ) + .addColumn('next_attempt_at', 'timestamptz') + .addColumn('last_delivered_at', 'timestamptz') + .addColumn('last_error', 'text') + .addColumn('last_error_at', 'timestamptz') + .addColumn('failing_since', 'timestamptz') + .addColumn('creator_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()`), + ) + .execute(); + + await db.schema + .createIndex('idx_siem_destinations_workspace_id') + .ifNotExists() + .on('siem_destinations') + .columns(['workspace_id']) + .execute(); + + await sql` + CREATE INDEX IF NOT EXISTS idx_siem_destinations_due + ON siem_destinations (next_attempt_at) + WHERE enabled = true + `.execute(db); + + await db.schema.alterTable('audit').addColumn('user_agent', 'text').execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable('audit').dropColumn('user_agent').execute(); + await db.schema.dropTable('siem_destinations').ifExists().execute(); +} diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index 25060c71a..373eadbf5 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -74,6 +74,7 @@ export interface Audit { resourceId: string | null; resourceType: string; spaceId: string | null; + userAgent: string | null; workspaceId: string; } @@ -361,6 +362,30 @@ export interface SpaceMembers { userId: string | null; } +export interface SiemDestinations { + config: Json; + consecutiveFailures: Generated; + createdAt: Generated; + creatorId: string | null; + cursorCreatedAt: Generated; + cursorId: Generated; + cursorSnapshot: string | null; + enabled: Generated; + failingSince: Timestamp | null; + id: Generated; + lastDeliveredAt: Timestamp | null; + lastError: string | null; + lastErrorAt: Timestamp | null; + name: string; + nextAttemptAt: Timestamp | null; + secrets: string; + status: Generated; + type: string; + updatedAt: Generated; + version: Generated; + workspaceId: string; +} + export interface Spaces { createdAt: Generated; creatorId: string | null; @@ -724,6 +749,7 @@ export interface DB { pages: Pages; scimTokens: ScimTokens; shares: Shares; + siemDestinations: SiemDestinations; spaceMembers: SpaceMembers; spaces: Spaces; templates: Templates; diff --git a/apps/server/src/database/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts index c7d2af0a0..6fd09a963 100644 --- a/apps/server/src/database/types/entity.types.ts +++ b/apps/server/src/database/types/entity.types.ts @@ -37,6 +37,7 @@ import { UserSessions, ApiKeys, ScimTokens, + SiemDestinations, Watchers, Audit as _Audit, Templates, @@ -267,3 +268,8 @@ export type UpdatableBaseRow = Updateable>; export type BaseView = Selectable; export type InsertableBaseView = Insertable; export type UpdatableBaseView = Updateable>; + +// SIEM destinations +export type SiemDestination = Selectable; +export type InsertableSiemDestination = Insertable; +export type UpdatableSiemDestination = Updateable>; diff --git a/apps/server/src/ee b/apps/server/src/ee index cbf35d363..c8ca1467d 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit cbf35d363a1745bc09e7e8c72ca1fd63cf1b7b5c +Subproject commit c8ca1467dfdde0499b6a6fc21c3425d648ba6a2e diff --git a/apps/server/src/integrations/environment/environment.service.ts b/apps/server/src/integrations/environment/environment.service.ts index c483df626..bb11eafe2 100644 --- a/apps/server/src/integrations/environment/environment.service.ts +++ b/apps/server/src/integrations/environment/environment.service.ts @@ -385,4 +385,8 @@ export class EnvironmentService { .map((o) => o.trim()) .filter(Boolean); } + + getAllowedPrivateNetworks(): string { + return this.configService.get('ALLOWED_PRIVATE_NETWORKS', 'none'); + } } diff --git a/apps/server/src/integrations/outbound/outbound-agent.factory.spec.ts b/apps/server/src/integrations/outbound/outbound-agent.factory.spec.ts new file mode 100644 index 000000000..6efcd645e --- /dev/null +++ b/apps/server/src/integrations/outbound/outbound-agent.factory.spec.ts @@ -0,0 +1,23 @@ +import { Agent } from 'undici'; +import { OutboundAgentFactory } from './outbound-agent.factory'; +import { OutboundUrlError } from './outbound-url.guard'; + +describe('OutboundAgentFactory', () => { + it('validates the URL through the guard and returns a releasable undici Agent', async () => { + const validate = jest.fn().mockResolvedValue({ hostname: 'siem.example.com', address: '203.0.113.5', family: 4 }); + const factory = new OutboundAgentFactory({ validate } as any); + + const lease = await factory.lease('https://siem.example.com/ingest', { caCert: undefined, rejectUnauthorized: true }); + + expect(validate).toHaveBeenCalledWith('https://siem.example.com/ingest'); + expect(lease.dispatcher).toBeInstanceOf(Agent); + await expect(lease.release()).resolves.toBeUndefined(); + }); + + it('propagates guard rejections', async () => { + const validate = jest.fn().mockRejectedValue(new OutboundUrlError('Destination URL must use https')); + const factory = new OutboundAgentFactory({ validate } as any); + + await expect(factory.lease('http://siem.example.com')).rejects.toThrow(OutboundUrlError); + }); +}); diff --git a/apps/server/src/integrations/outbound/outbound-agent.factory.ts b/apps/server/src/integrations/outbound/outbound-agent.factory.ts new file mode 100644 index 000000000..d4ac5b471 --- /dev/null +++ b/apps/server/src/integrations/outbound/outbound-agent.factory.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@nestjs/common'; +import { Agent, Dispatcher } from 'undici'; +import { OutboundUrlGuard } from './outbound-url.guard'; + +export const OUTBOUND_REQUEST_TIMEOUT_MS = 10_000; + +export type OutboundTlsOptions = { + caCert?: string; // PEM encoded + rejectUnauthorized?: boolean; // Defaults to true; self-hosted only when false. +}; + +export type AgentLease = { + dispatcher: Dispatcher; + release: () => Promise; +}; + +export type IOutboundAgentFactory = { + lease(url: string, tls?: OutboundTlsOptions): Promise; +}; + +/** Creates a per-request agent pinned to the address validated by the SSRF guard. */ +@Injectable() +export class OutboundAgentFactory implements IOutboundAgentFactory { + constructor(private readonly urlGuard: OutboundUrlGuard) {} + + async lease(url: string, tls?: OutboundTlsOptions): Promise { + const pinned = await this.urlGuard.validate(url); + + const lookup = (_hostname: string, options: any, callback: any) => { + if (options?.all) { + callback(null, [{ address: pinned.address, family: pinned.family }]); + } else { + callback(null, pinned.address, pinned.family); + } + }; + + const agent = new Agent({ + connect: { + ca: tls?.caCert || undefined, + rejectUnauthorized: tls?.rejectUnauthorized ?? true, + lookup: lookup as any, + timeout: OUTBOUND_REQUEST_TIMEOUT_MS, + }, + headersTimeout: OUTBOUND_REQUEST_TIMEOUT_MS, + bodyTimeout: OUTBOUND_REQUEST_TIMEOUT_MS, + }); + + return { + dispatcher: agent, + release: async () => { + await agent.close(); + }, + }; + } +} diff --git a/apps/server/src/integrations/outbound/outbound-network-policy.spec.ts b/apps/server/src/integrations/outbound/outbound-network-policy.spec.ts new file mode 100644 index 000000000..b6f8399c8 --- /dev/null +++ b/apps/server/src/integrations/outbound/outbound-network-policy.spec.ts @@ -0,0 +1,143 @@ +import { + parseOutboundNetworkPolicy, + policyNamesAddress, +} from './outbound-network-policy'; + +describe('parseOutboundNetworkPolicy', () => { + it('parses a bare mode', () => { + expect(parseOutboundNetworkPolicy('all')).toMatchObject({ + mode: 'all', + entries: [], + invalid: false, + }); + expect(parseOutboundNetworkPolicy('none')).toMatchObject({ + mode: 'none', + entries: [], + invalid: false, + }); + }); + + it('treats an empty value as none with no entries', () => { + for (const raw of ['', ' ', ',,']) { + expect(parseOutboundNetworkPolicy(raw)).toMatchObject({ + mode: 'none', + entries: [], + invalid: false, + }); + } + }); + + it('ignores case and surrounding whitespace on the mode', () => { + expect(parseOutboundNetworkPolicy(' ALL ')).toMatchObject({ + mode: 'all', + invalid: false, + }); + }); + + it('parses a mode followed by entries', () => { + const policy = parseOutboundNetworkPolicy('all,127.0.0.0/8,::1/128'); + + expect(policy.mode).toBe('all'); + expect(policy.entries).toHaveLength(2); + expect(policyNamesAddress(policy, '127.0.0.1', 80)).toBe(true); + expect(policyNamesAddress(policy, '127.0.0.1', 8088)).toBe(true); + expect(policyNamesAddress(policy, '::1', 443)).toBe(true); + expect(policyNamesAddress(policy, '10.1.2.3', 443)).toBe(false); + }); + + it('parses an entry with a port and matches only that port', () => { + const policy = parseOutboundNetworkPolicy('none,192.168.1.20/32:8088'); + + expect(policy.mode).toBe('none'); + expect(policyNamesAddress(policy, '192.168.1.20', 8088)).toBe(true); + expect(policyNamesAddress(policy, '192.168.1.20', 443)).toBe(false); + expect(policyNamesAddress(policy, '192.168.1.21', 8088)).toBe(false); + }); + + it('parses a bracketed IPv6 entry with a port', () => { + const policy = parseOutboundNetworkPolicy('[::1/128]:8088'); + + expect(policy.mode).toBe('none'); + expect(policyNamesAddress(policy, '::1', 8088)).toBe(true); + expect(policyNamesAddress(policy, '::1', 80)).toBe(false); + }); + + it('treats entries without a mode as none plus those entries', () => { + const policy = parseOutboundNetworkPolicy('10.0.0.0/8'); + + expect(policy.mode).toBe('none'); + expect(policy.invalid).toBe(false); + expect(policyNamesAddress(policy, '10.1.2.3', 443)).toBe(true); + expect(policyNamesAddress(policy, '192.168.1.1', 443)).toBe(false); + }); + + it.each([ + 'not-a-cidr', + 'all,not-a-cidr', + 'all,10.0.0.0/8,nonsense', + '10.0.0.0/33', + '10.0.0.0', + '::1/129', + '::1/128:8088', + '10.0.0.0/8:0', + '10.0.0.0/8:70000', + '[::1/128]:notaport', + '0.0.0.0/0', + '::/0', + 'all,0.0.0.0/0', + '[::/0]:8088', + '192.168.1.20/24', + '10.1.0.0/8', + '172.16.0.1/12', + 'fc00::1/7', + '[::1/127]', + '2001:db8::1/32:8088', + ])('fails closed on %s', (raw) => { + expect(parseOutboundNetworkPolicy(raw)).toMatchObject({ + mode: 'none', + entries: [], + invalid: true, + }); + }); + + it.each([ + '10.0.0.0/8', + '172.16.0.0/12', + '100.64.0.0/10', + '192.168.1.20/32', + 'fc00::/7', + 'fe80::/10', + '::1/128', + ])('accepts %s, whose address sits on its prefix boundary', (raw) => { + expect(parseOutboundNetworkPolicy(raw)).toMatchObject({ + entries: [expect.anything()], + invalid: false, + }); + }); + + it('accepts a bracketed IPv6 entry without a port as the unbracketed form', () => { + const bracketed = parseOutboundNetworkPolicy('[::1/128]'); + const bare = parseOutboundNetworkPolicy('::1/128'); + + expect(bracketed).toMatchObject({ mode: 'none', invalid: false }); + for (const port of [80, 443, 8088]) { + expect(policyNamesAddress(bracketed, '::1', port)).toBe( + policyNamesAddress(bare, '::1', port), + ); + expect(policyNamesAddress(bracketed, '::1', port)).toBe(true); + } + }); + + it('never names an address when the value is unparseable or the address is not an IP', () => { + const policy = parseOutboundNetworkPolicy('all,10.0.0.0/8'); + + expect(policyNamesAddress(policy, 'siem.internal', 443)).toBe(false); + expect(policyNamesAddress(parseOutboundNetworkPolicy('garbage'), '10.1.2.3', 443)).toBe(false); + }); + + it('matches an IPv4-mapped IPv6 address against an IPv4 entry', () => { + const policy = parseOutboundNetworkPolicy('127.0.0.0/8'); + + expect(policyNamesAddress(policy, '::ffff:127.0.0.1', 80)).toBe(true); + }); +}); diff --git a/apps/server/src/integrations/outbound/outbound-network-policy.ts b/apps/server/src/integrations/outbound/outbound-network-policy.ts new file mode 100644 index 000000000..11b816530 --- /dev/null +++ b/apps/server/src/integrations/outbound/outbound-network-policy.ts @@ -0,0 +1,110 @@ +import { BlockList, isIPv4, isIPv6 } from 'node:net'; + +export type OutboundPolicyMode = 'all' | 'none'; + +export type OutboundPolicyEntry = { list: BlockList; port?: number }; + +/** An invalid policy denies all private destinations. */ +export type OutboundNetworkPolicy = { + mode: OutboundPolicyMode; + entries: OutboundPolicyEntry[]; + invalid: boolean; +}; + +function toBytes(address: string, family: 'ipv4' | 'ipv6'): number[] { + if (family === 'ipv4') return address.split('.').map(Number); + + const bytesOf = (part: string): number[] => + part + ? part.split(':').flatMap((group) => { + if (group.includes('.')) return group.split('.').map(Number); + const value = parseInt(group, 16); + return [value >> 8, value & 0xff]; + }) + : []; + + const [head, tail] = address.split('::'); + const headBytes = bytesOf(head); + const tailBytes = address.includes('::') ? bytesOf(tail) : []; + const zeros = new Array(16 - headBytes.length - tailBytes.length).fill(0); + return [...headBytes, ...zeros, ...tailBytes]; +} + +function hasHostBits(bytes: number[], prefix: number): boolean { + return bytes.some((byte, index) => { + const bitsBefore = index * 8; + if (bitsBefore >= prefix) return byte !== 0; + return (byte & (0xff >> Math.min(8, prefix - bitsBefore))) !== 0; + }); +} + +/** Prefix zero is reserved for the explicit `all` mode. */ +function parseCidr( + raw: string, +): { address: string; prefix: number; family: 'ipv4' | 'ipv6' } | null { + const [address, prefixRaw] = raw.split('/'); + if (!prefixRaw) return null; + const prefix = Number(prefixRaw); + if (!Number.isInteger(prefix) || prefix < 1) return null; + const family = isIPv4(address) ? 'ipv4' : isIPv6(address) ? 'ipv6' : null; + if (!family) return null; + if (prefix > (family === 'ipv4' ? 32 : 128)) return null; + if (hasHostBits(toBytes(address, family), prefix)) return null; + return { address, prefix, family }; +} + +/** Parses optional ports without treating IPv6 colons as separators. */ +function splitPort(token: string): { cidr: string; port?: number } { + const bracketed = /^\[(.+)\](?::(\d+))?$/.exec(token); + if (bracketed) { + const [, cidr, port] = bracketed; + return port === undefined ? { cidr } : { cidr, port: Number(port) }; + } + const withPort = /^([^:]+):(\d+)$/.exec(token); + if (withPort) return { cidr: withPort[1], port: Number(withPort[2]) }; + return { cidr: token }; +} + +function parseEntry(token: string): OutboundPolicyEntry | null { + const { cidr: raw, port } = splitPort(token); + if (port !== undefined && (port < 1 || port > 65535)) return null; + const cidr = parseCidr(raw); + if (!cidr) return null; + const list = new BlockList(); + list.addSubnet(cidr.address, cidr.prefix, cidr.family); + return { list, port }; +} + +/** Parses `[all|none,]CIDR[:port],...` and fails closed on invalid input. */ +export function parseOutboundNetworkPolicy(raw: string): OutboundNetworkPolicy { + const tokens = (raw ?? '') + .split(',') + .map((token) => token.trim()) + .filter(Boolean); + if (tokens.length === 0) return { mode: 'none', entries: [], invalid: false }; + + const first = tokens[0].toLowerCase(); + const hasMode = first === 'all' || first === 'none'; + const mode: OutboundPolicyMode = hasMode ? first : 'none'; + + const entries: OutboundPolicyEntry[] = []; + for (const token of hasMode ? tokens.slice(1) : tokens) { + const entry = parseEntry(token); + if (!entry) return { mode: 'none', entries: [], invalid: true }; + entries.push(entry); + } + return { mode, entries, invalid: false }; +} + +export function policyNamesAddress( + policy: OutboundNetworkPolicy, + ip: string, + port: number, +): boolean { + const family = isIPv4(ip) ? 'ipv4' : isIPv6(ip) ? 'ipv6' : null; + if (!family) return false; + return policy.entries.some( + (entry) => + (entry.port === undefined || entry.port === port) && entry.list.check(ip, family), + ); +} diff --git a/apps/server/src/integrations/outbound/outbound-url.guard.spec.ts b/apps/server/src/integrations/outbound/outbound-url.guard.spec.ts new file mode 100644 index 000000000..e480a0928 --- /dev/null +++ b/apps/server/src/integrations/outbound/outbound-url.guard.spec.ts @@ -0,0 +1,412 @@ +import { Logger } from '@nestjs/common'; +import { + isAlwaysBlockedAddress, + isHardBlockedAddress, + isPrivateAddress, + isPrivateNetworkAddress, + OutboundUrlError, + OutboundUrlGuard, +} from './outbound-url.guard'; + +function guard( + isCloud: boolean, + addresses: Array<{ address: string; family: number }>, + privateNetworks: string = 'none', +) { + return new OutboundUrlGuard( + { + isCloud: () => isCloud, + getAllowedPrivateNetworks: () => privateNetworks, + } as any, + async () => addresses, + ); +} + +function family(ip: string): number { + return ip.includes(':') ? 6 : 4; +} + +describe('isPrivateAddress', () => { + it.each([ + '127.0.0.1', '10.0.0.5', '172.16.0.1', '172.31.255.255', '192.168.1.1', + '169.254.169.254', '100.64.0.1', '0.0.0.0', '224.0.0.1', + '::1', '::', 'fe80::1', 'fc00::1', 'fd12::1', 'ff02::1', '::ffff:10.0.0.1', + '0:0:0:0:0:0:0:1', '::ffff:a00:1', '::ffff:7f00:1', '0000:0000:0000:0000:0000:0000:0000:0000', + '192.0.0.1', '192.0.2.1', '192.88.99.1', '198.18.0.1', '198.51.100.7', '203.0.113.5', + '::a00:1', '64:ff9b::a00:1', '64:ff9b:1::a00:1', '100::1', '2001::1', '2001:0:a00:1::1', '2001:db8::1', '2002:a00:1::1', 'fec0::1', + ])('flags %s as private or reserved', (ip) => { + expect(isPrivateAddress(ip)).toBe(true); + }); + + it.each(['8.8.8.8', '172.32.0.1', '2606:4700::1111', '::ffff:8.8.8.8', '::ffff:5db8:d822', '::ffff:8.8.8.8', '2001:4860:4860::8888', '100.128.0.1', '198.17.255.255'])( + 'allows public %s', + (ip) => { + expect(isPrivateAddress(ip)).toBe(false); + }, + ); +}); + +describe('isAlwaysBlockedAddress / isPrivateNetworkAddress', () => { + it.each([ + '0.0.0.0', '127.0.0.1', '169.254.169.254', '192.0.0.1', '192.0.2.1', + '192.88.99.1', '198.18.0.1', '198.51.100.7', '203.0.113.5', '224.0.0.1', + '::1', '::', '::ffff:127.0.0.1', '::ffff:0:7f00:1', '64:ff9b::a00:1', '64:ff9b:1::a00:1', + '100::1', '2001::1', '2001:db8::1', '2002:a00:1::1', 'fe80::1', 'fec0::1', + 'ff02::1', + ])('flags %s as always-blocked but not a private network', (ip) => { + expect(isAlwaysBlockedAddress(ip)).toBe(true); + expect(isPrivateNetworkAddress(ip)).toBe(false); + }); + + it.each([ + '10.0.0.5', '172.16.0.1', '172.31.255.255', '192.168.1.1', '100.64.0.1', + 'fc00::1', 'fd12::1', + ])('flags %s as a private network but not always-blocked', (ip) => { + expect(isPrivateNetworkAddress(ip)).toBe(true); + expect(isAlwaysBlockedAddress(ip)).toBe(false); + }); + + it.each(['8.8.8.8', '172.32.0.1', '2606:4700::1111', '100.128.0.1'])( + 'allows public %s in both', + (ip) => { + expect(isAlwaysBlockedAddress(ip)).toBe(false); + expect(isPrivateNetworkAddress(ip)).toBe(false); + }, + ); + + it('the two lists together are exactly isPrivateAddress', () => { + for (const ip of ['10.0.0.5', '127.0.0.1', '8.8.8.8', 'fe80::1', 'fc00::1']) { + expect(isAlwaysBlockedAddress(ip) || isPrivateNetworkAddress(ip)).toBe( + isPrivateAddress(ip), + ); + } + }); +}); + +describe('OutboundUrlGuard.validate', () => { + const publicV4 = { address: '93.184.216.34', family: 4 }; + + it('rejects http on cloud', async () => { + await expect(guard(true, [publicV4]).validate('http://siem.example.com/x')) + .rejects.toThrow(OutboundUrlError); + }); + + it('rejects hosts that resolve to a private range on cloud', async () => { + await expect( + guard(true, [publicV4, { address: '10.0.0.5', family: 4 }]).validate('https://siem.example.com'), + ).rejects.toThrow(/private or reserved/); + }); + + it('rejects the cloud metadata address literal', async () => { + await expect(guard(true, []).validate('https://169.254.169.254/latest')) + .rejects.toThrow(/private or reserved/); + }); + + it('allows LAN hosts and http on self-hosted when private networks are allowed', async () => { + const pinned = await guard(false, [{ address: '10.0.5.20', family: 4 }], 'all') + .validate('http://splunk.internal:8088/services/collector/event'); + expect(pinned).toEqual({ hostname: 'splunk.internal', address: '10.0.5.20', family: 4 }); + }); + + it('pins the first resolved address and keeps the hostname for SNI', async () => { + const pinned = await guard(true, [{ address: '2606:4700::1111', family: 6 }, publicV4]) + .validate('https://siem.example.com'); + expect(pinned).toEqual({ hostname: 'siem.example.com', address: '2606:4700::1111', family: 6 }); + }); + + it('rejects credentials in the URL and unresolvable hosts', async () => { + await expect(guard(false, [publicV4]).validate('https://user:pw@siem.example.com')) + .rejects.toThrow(/credentials/); + await expect(guard(false, []).validate('https://nope.example.com')) + .rejects.toThrow(/Could not resolve/); + }); + + it('marks resolution failures retryable and configuration failures not', async () => { + const throwing = new OutboundUrlGuard( + { isCloud: () => false } as any, + async () => { + throw new Error('EAI_AGAIN'); + }, + ); + + const dnsError = await throwing + .validate('https://siem.example.com') + .catch((e) => e); + expect(dnsError).toBeInstanceOf(OutboundUrlError); + expect(dnsError.retryable).toBe(true); + + const emptyError = await guard(false, []) + .validate('https://nope.example.com') + .catch((e) => e); + expect(emptyError.retryable).toBe(true); + + for (const url of [ + 'not-a-url', + 'ftp://siem.example.com', + 'https://user:pw@siem.example.com', + ]) { + const err = await guard(false, [publicV4]) + .validate(url) + .catch((e) => e); + expect(err).toBeInstanceOf(OutboundUrlError); + expect(err.retryable).toBe(false); + } + + const privateError = await guard(true, [{ address: '10.0.0.5', family: 4 }]) + .validate('https://siem.example.com') + .catch((e) => e); + expect(privateError.retryable).toBe(false); + }); + + const hardBlocked = ['169.254.169.254', '0.0.0.0', 'fe80::1', 'ff02::1']; + const loopbackOrReserved = ['127.0.0.1', '::1', '::ffff:127.0.0.1', '192.0.2.1']; + + it.each(hardBlocked)( + 'self-hosted refuses %s under every ALLOWED_PRIVATE_NETWORKS value', + async (ip) => { + for (const value of ['all', 'none', '169.254.0.0/16', 'all,169.254.0.0/16', 'all,fe80::/10']) { + await expect( + guard(false, [{ address: ip, family: family(ip) }], value).validate( + 'http://siem.internal', + ), + ).rejects.toThrow(/link-local, metadata or reserved address .* which is never allowed/); + } + }, + ); + + it.each(loopbackOrReserved)( + 'self-hosted refuses %s unless an entry names it', + async (ip) => { + for (const value of ['all', 'none']) { + await expect( + guard(false, [{ address: ip, family: family(ip) }], value).validate( + 'http://siem.internal', + ), + ).rejects.toThrow( + /resolves to a loopback or reserved address .* Set ALLOWED_PRIVATE_NETWORKS on the server to allow it/, + ); + } + }, + ); + + it.each(['10.1.2.3', '192.168.1.10'])( + 'self-hosted refuses private network %s by default', + async (ip) => { + await expect( + guard(false, [{ address: ip, family: 4 }]).validate('http://siem.internal'), + ).rejects.toThrow( + /resolves to a private address .* Set ALLOWED_PRIVATE_NETWORKS on the server to allow it/, + ); + }, + ); + + it.each(['10.1.2.3', '192.168.1.10', 'fc00::1', '100.64.0.1'])( + 'all accepts private network %s', + async (ip) => { + const pinned = await guard( + false, + [{ address: ip, family: family(ip) }], + 'all', + ).validate('http://siem.internal'); + expect(pinned.address).toBe(ip); + }, + ); + + it('all still refuses loopback, and a loopback entry opts it back in', async () => { + await expect( + guard(false, [{ address: '127.0.0.1', family: 4 }], 'all').validate( + 'http://siem.internal', + ), + ).rejects.toThrow(/loopback or reserved/); + + const allowed = await guard( + false, + [{ address: '127.0.0.1', family: 4 }], + 'all,127.0.0.0/8', + ).validate('http://siem.internal'); + expect(allowed.address).toBe('127.0.0.1'); + + const lan = await guard( + false, + [{ address: '10.1.2.3', family: 4 }], + 'all,127.0.0.0/8', + ).validate('http://siem.internal'); + expect(lan.address).toBe('10.1.2.3'); + + await expect( + guard(false, [{ address: '::1', family: 6 }], 'all,127.0.0.0/8').validate( + 'http://siem.internal', + ), + ).rejects.toThrow(/loopback or reserved/); + }); + + it('an entry with a port matches only that port', async () => { + const policy = 'none,192.168.1.20/32:8088'; + + const allowed = await guard( + false, + [{ address: '192.168.1.20', family: 4 }], + policy, + ).validate('https://192.168.1.20:8088/services/collector/event'); + expect(allowed.address).toBe('192.168.1.20'); + + await expect( + guard(false, [{ address: '192.168.1.20', family: 4 }], policy).validate( + 'https://192.168.1.20', + ), + ).rejects.toThrow(/private address/); + + await expect( + guard(false, [{ address: '192.168.1.21', family: 4 }], policy).validate( + 'https://192.168.1.21:8088', + ), + ).rejects.toThrow(/private address/); + }); + + it('a bracketed IPv6 entry with a port accepts only that port', async () => { + const policy = '[::1/128]:8088'; + + const allowed = await guard( + false, + [{ address: '::1', family: 6 }], + policy, + ).validate('http://[::1]:8088/ingest'); + expect(allowed).toEqual({ hostname: '::1', address: '::1', family: 6 }); + + await expect( + guard(false, [{ address: '::1', family: 6 }], policy).validate('http://[::1]/ingest'), + ).rejects.toThrow(/loopback or reserved/); + }); + + it('an entry without a port matches every port', async () => { + for (const url of ['http://127.0.0.1:8088', 'https://127.0.0.1', 'http://127.0.0.1']) { + const allowed = await guard( + false, + [{ address: '127.0.0.1', family: 4 }], + '127.0.0.0/8', + ).validate(url); + expect(allowed.address).toBe('127.0.0.1'); + } + }); + + it('entries without a mode none every private network not named', async () => { + const allowed = await guard( + false, + [{ address: '192.168.1.10', family: 4 }], + '192.168.1.0/24', + ).validate('http://siem.internal'); + expect(allowed.address).toBe('192.168.1.10'); + + await expect( + guard(false, [{ address: '10.1.2.3', family: 4 }], '192.168.1.0/24').validate( + 'http://siem.internal', + ), + ).rejects.toThrow(/private address/); + }); + + it('an unparseable value denies everything private or reserved and logs once per process', async () => { + const errorSpy = jest + .spyOn(Logger.prototype, 'error') + .mockImplementation(() => undefined); + const g = guard(false, [{ address: '10.1.2.3', family: 4 }], 'all,10.0.0.0/8, not-a-cidr'); + + await expect(g.validate('http://siem.internal')).rejects.toThrow(/private address/); + await expect(g.validate('http://siem.internal')).rejects.toThrow(/private address/); + + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy.mock.calls[0][0]).toMatch(/ALLOWED_PRIVATE_NETWORKS/); + errorSpy.mockRestore(); + }); + + it('cloud ignores ALLOWED_PRIVATE_NETWORKS and always refuses private and reserved ranges', async () => { + for (const ip of [...hardBlocked, ...loopbackOrReserved, '10.1.2.3', '192.168.1.10']) { + for (const value of ['all', 'none', '127.0.0.0/8', 'all,10.0.0.0/8']) { + await expect( + guard(true, [{ address: ip, family: family(ip) }], value).validate( + 'https://siem.example.com', + ), + ).rejects.toThrow(/private or reserved/); + } + } + }); + + it('refuses a resolved address that is not an IP address', async () => { + await expect( + guard(false, [{ address: 'not-an-ip', family: 4 }], 'all').validate( + 'http://siem.internal', + ), + ).rejects.toThrow(/is not an IP address/); + }); + + it('refuses the whole host when any one of its addresses is refused', async () => { + await expect( + guard( + false, + [publicV4, { address: '10.1.2.3', family: 4 }], + 'none', + ).validate('http://siem.internal'), + ).rejects.toThrow(/private address/); + + await expect( + guard( + false, + [publicV4, { address: '127.0.0.1', family: 4 }], + 'all', + ).validate('http://siem.internal'), + ).rejects.toThrow(/loopback or reserved/); + + await expect( + guard( + false, + [publicV4, { address: '169.254.169.254', family: 4 }], + 'all', + ).validate('http://siem.internal'), + ).rejects.toThrow(/never allowed/); + }); + + it('refuses an IPv4-translated loopback address even when private networks are allowed', async () => { + await expect( + guard(false, [{ address: '::ffff:0:7f00:1', family: 6 }], 'all').validate( + 'http://siem.internal', + ), + ).rejects.toThrow(/loopback or reserved/); + }); + + it('a public address is allowed in every mode', async () => { + const errorSpy = jest + .spyOn(Logger.prototype, 'error') + .mockImplementation(() => undefined); + + for (const value of ['all', 'none', '', '192.168.1.0/24', 'garbage']) { + await expect( + guard(false, [publicV4], value).validate('http://siem.example.com'), + ).resolves.toMatchObject({ address: publicV4.address }); + } + await expect( + guard(true, [publicV4], 'all').validate('https://siem.example.com'), + ).resolves.toMatchObject({ address: publicV4.address }); + errorSpy.mockRestore(); + }); +}); + +describe('isHardBlockedAddress', () => { + it.each([ + '0.0.0.0', '169.254.169.254', '224.0.0.1', '255.255.255.255', + '::', 'fe80::1', 'ff02::1', + ])('flags %s as hard-blocked', (ip) => { + expect(isHardBlockedAddress(ip)).toBe(true); + }); + + it.each(['127.0.0.1', '::1', '192.0.2.1', 'fec0::1', '8.8.8.8'])( + 'does not flag %s as hard-blocked (it may still be always-blocked)', + (ip) => { + expect(isHardBlockedAddress(ip)).toBe(false); + }, + ); + + it('is a subset of isAlwaysBlockedAddress', () => { + for (const ip of ['0.0.0.0', '169.254.169.254', 'fe80::1', 'ff02::1']) { + expect(isAlwaysBlockedAddress(ip)).toBe(true); + } + }); +}); diff --git a/apps/server/src/integrations/outbound/outbound-url.guard.ts b/apps/server/src/integrations/outbound/outbound-url.guard.ts new file mode 100644 index 000000000..1c9e4a872 --- /dev/null +++ b/apps/server/src/integrations/outbound/outbound-url.guard.ts @@ -0,0 +1,231 @@ +import { Inject, Injectable, Logger, Optional } from '@nestjs/common'; +import { promises as dns } from 'node:dns'; +import { BlockList, isIPv4, isIPv6 } from 'node:net'; +import { EnvironmentService } from '../environment/environment.service'; +import { + OutboundNetworkPolicy, + parseOutboundNetworkPolicy, + policyNamesAddress, +} from './outbound-network-policy'; + +export const OUTBOUND_LOOKUP = 'OUTBOUND_LOOKUP'; + +export type ResolvedAddress = { address: string; family: number }; +export type LookupFn = (hostname: string) => Promise; +export type PinnedAddress = { hostname: string; address: string; family: 4 | 6 }; + +/** A rejected URL. Only transient resolution failures are retryable. */ +export class OutboundUrlError extends Error { + constructor( + message: string, + readonly retryable: boolean = false, + ) { + super(message); + this.name = 'OutboundUrlError'; + } +} + +export const defaultLookup: LookupFn = async (hostname) => { + const results = await dns.lookup(hostname, { all: true }); + return results.map((r) => ({ address: r.address, family: r.family })); +}; + +// Reserved ranges blocked unless explicitly allowed on self-hosted deployments. +const ALWAYS_BLOCKED = new BlockList(); +ALWAYS_BLOCKED.addSubnet('0.0.0.0', 8, 'ipv4'); // "this" network / unspecified +ALWAYS_BLOCKED.addSubnet('127.0.0.0', 8, 'ipv4'); +ALWAYS_BLOCKED.addSubnet('169.254.0.0', 16, 'ipv4'); // link-local / cloud metadata +ALWAYS_BLOCKED.addSubnet('192.0.0.0', 24, 'ipv4'); // IETF protocol assignments +ALWAYS_BLOCKED.addSubnet('192.0.2.0', 24, 'ipv4'); // TEST-NET-1 +ALWAYS_BLOCKED.addSubnet('192.88.99.0', 24, 'ipv4'); // deprecated 6to4 relay anycast +ALWAYS_BLOCKED.addSubnet('198.18.0.0', 15, 'ipv4'); // benchmarking +ALWAYS_BLOCKED.addSubnet('198.51.100.0', 24, 'ipv4'); // TEST-NET-2 +ALWAYS_BLOCKED.addSubnet('203.0.113.0', 24, 'ipv4'); // TEST-NET-3 +ALWAYS_BLOCKED.addRange('224.0.0.0', '255.255.255.255', 'ipv4'); // multicast + reserved +ALWAYS_BLOCKED.addSubnet('::', 96, 'ipv6'); // deprecated IPv4-compatible +ALWAYS_BLOCKED.addSubnet('::ffff:0:0:0', 96, 'ipv6'); // IPv4-translated (SIIT): ::ffff:0:7f00:1 is 127.0.0.1 +ALWAYS_BLOCKED.addSubnet('::', 128, 'ipv6'); // unspecified +ALWAYS_BLOCKED.addSubnet('::1', 128, 'ipv6'); // loopback +ALWAYS_BLOCKED.addSubnet('64:ff9b::', 96, 'ipv6'); // NAT64 well-known prefix +ALWAYS_BLOCKED.addSubnet('64:ff9b:1::', 48, 'ipv6'); // NAT64 local-use +ALWAYS_BLOCKED.addSubnet('100::', 64, 'ipv6'); // discard-only +ALWAYS_BLOCKED.addSubnet('2001::', 32, 'ipv6'); // Teredo +ALWAYS_BLOCKED.addSubnet('2001:db8::', 32, 'ipv6'); // documentation +ALWAYS_BLOCKED.addSubnet('2002::', 16, 'ipv6'); // 6to4 +ALWAYS_BLOCKED.addSubnet('fe80::', 10, 'ipv6'); // link-local +ALWAYS_BLOCKED.addSubnet('fec0::', 10, 'ipv6'); // deprecated site-local +ALWAYS_BLOCKED.addSubnet('ff00::', 8, 'ipv6'); // multicast + +// Private ranges that self-hosted deployments can allow. +const PRIVATE_NETWORKS = new BlockList(); +PRIVATE_NETWORKS.addSubnet('10.0.0.0', 8, 'ipv4'); +PRIVATE_NETWORKS.addSubnet('100.64.0.0', 10, 'ipv4'); // CGNAT +PRIVATE_NETWORKS.addSubnet('172.16.0.0', 12, 'ipv4'); +PRIVATE_NETWORKS.addSubnet('192.168.0.0', 16, 'ipv4'); +PRIVATE_NETWORKS.addSubnet('fc00::', 7, 'ipv6'); // unique-local + +// These ranges cannot be allowed by policy. +const HARD_BLOCKED = new BlockList(); +HARD_BLOCKED.addSubnet('0.0.0.0', 8, 'ipv4'); +HARD_BLOCKED.addSubnet('169.254.0.0', 16, 'ipv4'); +HARD_BLOCKED.addRange('224.0.0.0', '255.255.255.255', 'ipv4'); +HARD_BLOCKED.addSubnet('::', 128, 'ipv6'); +HARD_BLOCKED.addSubnet('fe80::', 10, 'ipv6'); +HARD_BLOCKED.addSubnet('ff00::', 8, 'ipv6'); + +/** Returns true for reserved or transition ranges. Invalid input is blocked. */ +export function isAlwaysBlockedAddress(ip: string): boolean { + if (isIPv4(ip)) return ALWAYS_BLOCKED.check(ip, 'ipv4'); + if (isIPv6(ip)) return ALWAYS_BLOCKED.check(ip, 'ipv6'); + return true; +} + +/** Returns true for ranges that policy cannot allow. Invalid input is blocked. */ +export function isHardBlockedAddress(ip: string): boolean { + if (isIPv4(ip)) return HARD_BLOCKED.check(ip, 'ipv4'); + if (isIPv6(ip)) return HARD_BLOCKED.check(ip, 'ipv6'); + return true; +} + +/** Returns true for private network ranges. Invalid input is blocked. */ +export function isPrivateNetworkAddress(ip: string): boolean { + if (isIPv4(ip)) return PRIVATE_NETWORKS.check(ip, 'ipv4'); + if (isIPv6(ip)) return PRIVATE_NETWORKS.check(ip, 'ipv6'); + return true; +} + +/** Returns true for addresses blocked by cloud deployments. */ +export function isPrivateAddress(ip: string): boolean { + return isAlwaysBlockedAddress(ip) || isPrivateNetworkAddress(ip); +} + +type Refusal = { + address: string; + kind: 'not-an-ip' | 'hard-blocked' | 'private' | 'reserved'; +}; + +function findRefusal( + resolved: ResolvedAddress[], + port: number, + policy: OutboundNetworkPolicy, +): Refusal | undefined { + for (const { address } of resolved) { + // Reject invalid resolver output before policy checks. + if (!isIPv4(address) && !isIPv6(address)) return { address, kind: 'not-an-ip' }; + if (isHardBlockedAddress(address)) return { address, kind: 'hard-blocked' }; + if (policyNamesAddress(policy, address, port)) continue; + if (isPrivateNetworkAddress(address)) { + if (policy.mode === 'all') continue; + return { address, kind: 'private' }; + } + if (isAlwaysBlockedAddress(address)) return { address, kind: 'reserved' }; + } + return undefined; +} + +function describeRefusal(hostname: string, { address, kind }: Refusal): string { + if (kind === 'not-an-ip') { + return `Destination host "${hostname}" resolved to "${address}", which is not an IP address`; + } + if (kind === 'hard-blocked') { + return `Destination host "${hostname}" resolves to a link-local, metadata or reserved address (${address}), which is never allowed`; + } + const description = + kind === 'private' ? 'a private address' : 'a loopback or reserved address'; + return `Destination host "${hostname}" resolves to ${description} (${address}). Set ALLOWED_PRIVATE_NETWORKS on the server to allow it`; +} + +function effectivePort(url: URL): number { + if (url.port) return Number(url.port); + return url.protocol === 'https:' ? 443 : 80; +} + +@Injectable() +export class OutboundUrlGuard { + private readonly logger = new Logger(OutboundUrlGuard.name); + private readonly lookup: LookupFn; + private cachedPolicy?: { raw: string; policy: OutboundNetworkPolicy }; + + constructor( + private readonly environmentService: EnvironmentService, + @Optional() @Inject(OUTBOUND_LOOKUP) lookup?: LookupFn, + ) { + this.lookup = lookup ?? defaultLookup; + } + + /** Caches the parsed policy and logs each invalid value once. */ + private resolvePolicy(): OutboundNetworkPolicy { + const raw = this.environmentService.getAllowedPrivateNetworks(); + if (this.cachedPolicy?.raw !== raw) { + const policy = parseOutboundNetworkPolicy(raw); + if (policy.invalid) { + this.logger.error( + `Invalid ALLOWED_PRIVATE_NETWORKS value "${raw}"; refusing every private and reserved destination`, + ); + } + this.cachedPolicy = { raw, policy }; + } + return this.cachedPolicy.policy; + } + + /** Validates the URL and returns the address used to pin the connection. */ + async validate(rawUrl: string): Promise { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + throw new OutboundUrlError('Destination URL is not a valid URL'); + } + + const isCloud = this.environmentService.isCloud(); + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + throw new OutboundUrlError('Destination URL must use http or https'); + } + if (isCloud && url.protocol !== 'https:') { + throw new OutboundUrlError('Destination URL must use https'); + } + if (url.username || url.password) { + throw new OutboundUrlError('Destination URL must not contain credentials'); + } + + const hostname = url.hostname.replace(/^\[|\]$/g, ''); + let resolved: ResolvedAddress[]; + if (isIPv4(hostname) || isIPv6(hostname)) { + resolved = [{ address: hostname, family: isIPv4(hostname) ? 4 : 6 }]; + } else { + try { + resolved = await this.lookup(hostname); + } catch { + throw new OutboundUrlError( + `Could not resolve destination host "${hostname}"`, + true, + ); + } + } + if (resolved.length === 0) { + throw new OutboundUrlError( + `Could not resolve destination host "${hostname}"`, + true, + ); + } + + if (isCloud) { + const blocked = resolved.find((r) => isPrivateAddress(r.address)); + if (blocked) { + throw new OutboundUrlError( + `Destination host "${hostname}" resolves to a private or reserved address (${blocked.address}), which is not allowed`, + ); + } + } else { + const refusal = findRefusal( + resolved, + effectivePort(url), + this.resolvePolicy(), + ); + if (refusal) throw new OutboundUrlError(describeRefusal(hostname, refusal)); + } + + const pick = resolved[0]; + return { hostname, address: pick.address, family: pick.family === 6 ? 6 : 4 }; + } +} diff --git a/apps/server/src/integrations/outbound/outbound.module.ts b/apps/server/src/integrations/outbound/outbound.module.ts new file mode 100644 index 000000000..0c0400dff --- /dev/null +++ b/apps/server/src/integrations/outbound/outbound.module.ts @@ -0,0 +1,10 @@ +import { Global, Module } from '@nestjs/common'; +import { OutboundAgentFactory } from './outbound-agent.factory'; +import { OutboundUrlGuard } from './outbound-url.guard'; + +@Global() +@Module({ + providers: [OutboundUrlGuard, OutboundAgentFactory], + exports: [OutboundUrlGuard, OutboundAgentFactory], +}) +export class OutboundModule {} diff --git a/apps/server/src/integrations/queue/constants/queue.constants.ts b/apps/server/src/integrations/queue/constants/queue.constants.ts index 8b7c03a1f..a03c492d4 100644 --- a/apps/server/src/integrations/queue/constants/queue.constants.ts +++ b/apps/server/src/integrations/queue/constants/queue.constants.ts @@ -10,6 +10,7 @@ export enum QueueName { NOTIFICATION_QUEUE = '{notification-queue}', AUDIT_QUEUE = '{audit-queue}', BASE_QUEUE = '{base-queue}', + SIEM_QUEUE = '{siem-queue}', } export enum QueueJob { @@ -83,6 +84,9 @@ export enum QueueJob { AUDIT_LOG = 'audit-log', AUDIT_CLEANUP = 'audit-cleanup', + SIEM_SWEEP = 'siem-sweep', + SIEM_DELIVER = 'siem-deliver', + PDF_EXPORT_TASK = 'pdf-export-task', PDF_EXPORT_CLEANUP = 'pdf-export-cleanup', diff --git a/apps/server/src/integrations/queue/queue.module.ts b/apps/server/src/integrations/queue/queue.module.ts index 0c2c3c908..77bdf2b41 100644 --- a/apps/server/src/integrations/queue/queue.module.ts +++ b/apps/server/src/integrations/queue/queue.module.ts @@ -94,6 +94,14 @@ import { GeneralQueueProcessor } from './processors/general-queue.processor'; attempts: 3, }, }), + BullModule.registerQueue({ + name: QueueName.SIEM_QUEUE, + defaultJobOptions: { + removeOnComplete: true, + removeOnFail: true, + attempts: 1, + }, + }), BullModule.registerQueue({ name: QueueName.BASE_QUEUE, defaultJobOptions: { diff --git a/apps/server/src/integrations/throttle/throttle.module.ts b/apps/server/src/integrations/throttle/throttle.module.ts index 4c9537526..9fa3cb6be 100644 --- a/apps/server/src/integrations/throttle/throttle.module.ts +++ b/apps/server/src/integrations/throttle/throttle.module.ts @@ -10,6 +10,7 @@ import { OAUTH_REGISTER_THROTTLER, OAUTH_TOKEN_THROTTLER, OAUTH_AUTHORIZE_THROTTLER, + SIEM_TEST_THROTTLER, } from './throttler-names'; import Redis from 'ioredis'; @@ -27,6 +28,7 @@ import Redis from 'ioredis'; { name: OAUTH_REGISTER_THROTTLER, ttl: 3_600_000, limit: 10 }, { name: OAUTH_TOKEN_THROTTLER, ttl: 60_000, limit: 60 }, { name: OAUTH_AUTHORIZE_THROTTLER, ttl: 60_000, limit: 30 }, + { name: SIEM_TEST_THROTTLER, ttl: 60_000, limit: 10 }, ], errorMessage: 'Too many requests', storage: new ThrottlerStorageRedisService( diff --git a/apps/server/src/integrations/throttle/throttler-names.ts b/apps/server/src/integrations/throttle/throttler-names.ts index 898982976..e0c3b5afa 100644 --- a/apps/server/src/integrations/throttle/throttler-names.ts +++ b/apps/server/src/integrations/throttle/throttler-names.ts @@ -3,6 +3,7 @@ export const AI_CHAT_THROTTLER = 'ai-chat'; export const OAUTH_REGISTER_THROTTLER = 'oauth-register'; export const OAUTH_TOKEN_THROTTLER = 'oauth-token'; export const OAUTH_AUTHORIZE_THROTTLER = 'oauth-authorize'; +export const SIEM_TEST_THROTTLER = 'siem-test'; // Every named throttler must appear here; spread it in @SkipThrottle and re-enable per name with false. export const ALL_NAMED_THROTTLERS_SKIPPED: Record = { @@ -11,4 +12,5 @@ export const ALL_NAMED_THROTTLERS_SKIPPED: Record = { [OAUTH_REGISTER_THROTTLER]: true, [OAUTH_TOKEN_THROTTLER]: true, [OAUTH_AUTHORIZE_THROTTLER]: true, + [SIEM_TEST_THROTTLER]: true, }; diff --git a/apps/server/src/integrations/transactional/emails/siem-destination-disabled-email.tsx b/apps/server/src/integrations/transactional/emails/siem-destination-disabled-email.tsx new file mode 100644 index 000000000..8f2fd9834 --- /dev/null +++ b/apps/server/src/integrations/transactional/emails/siem-destination-disabled-email.tsx @@ -0,0 +1,41 @@ +import { Section, Text } from 'react-email'; +import * as React from 'react'; +import { content, paragraph } from '../css/styles'; +import { EmailButton, MailBody } from '../partials/partials'; + +type Props = { + destinationName: string; + destinationType: string; + lastError: string; + failingSince: string; + settingsLink: string; +}; + +export const SiemDestinationDisabledEmail = ({ + destinationName, + destinationType, + lastError, + failingSince, + settingsLink, +}: Props) => { + return ( + +
+ Hi there, + + Your SIEM destination {destinationName} ( + {destinationType}) has been failing since {failingSince} and was + disabled after 24 hours of failed deliveries. + + Last error: {lastError} + + Your events are kept and delivery resumes from where it stopped when + you re-enable it. + +
+ View destination +
+ ); +}; + +export default SiemDestinationDisabledEmail; diff --git a/apps/server/src/integrations/transactional/emails/siem-destination-failing-email.tsx b/apps/server/src/integrations/transactional/emails/siem-destination-failing-email.tsx new file mode 100644 index 000000000..ada3a131d --- /dev/null +++ b/apps/server/src/integrations/transactional/emails/siem-destination-failing-email.tsx @@ -0,0 +1,41 @@ +import { Section, Text } from 'react-email'; +import * as React from 'react'; +import { content, paragraph } from '../css/styles'; +import { EmailButton, MailBody } from '../partials/partials'; + +type Props = { + destinationName: string; + destinationType: string; + lastError: string; + failingSince: string; + settingsLink: string; +}; + +export const SiemDestinationFailingEmail = ({ + destinationName, + destinationType, + lastError, + failingSince, + settingsLink, +}: Props) => { + return ( + +
+ Hi there, + + Docmost cannot deliver audit events to your SIEM destination{' '} + {destinationName} ({destinationType}). + + Last error: {lastError} + Failing since {failingSince}. + + Docmost keeps retrying every 30 minutes. If the destination is still + failing 24 hours after it started, it is disabled automatically. + +
+ View destination +
+ ); +}; + +export default SiemDestinationFailingEmail; diff --git a/apps/server/src/integrations/transactional/emails/siem-destination-recovered-email.tsx b/apps/server/src/integrations/transactional/emails/siem-destination-recovered-email.tsx new file mode 100644 index 000000000..8da53ece0 --- /dev/null +++ b/apps/server/src/integrations/transactional/emails/siem-destination-recovered-email.tsx @@ -0,0 +1,35 @@ +import { Section, Text } from 'react-email'; +import * as React from 'react'; +import { content, paragraph } from '../css/styles'; +import { EmailButton, MailBody } from '../partials/partials'; + +type Props = { + destinationName: string; + destinationType: string; + settingsLink: string; +}; + +export const SiemDestinationRecoveredEmail = ({ + destinationName, + destinationType, + settingsLink, +}: Props) => { + return ( + +
+ Hi there, + + Your SIEM destination {destinationName} ( + {destinationType}) is delivering audit events again. + + + Events buffered during the outage were delivered from where the stream + stopped. + +
+ View destination +
+ ); +}; + +export default SiemDestinationRecoveredEmail; From f4796c982e322d5eb7155e90d621fe954328380c Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:56:08 +0100 Subject: [PATCH 24/27] sync --- apps/server/src/ee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/ee b/apps/server/src/ee index c8ca1467d..32f166454 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit c8ca1467dfdde0499b6a6fc21c3425d648ba6a2e +Subproject commit 32f16645436afd60ac92e8e39cd341eda2557f9f From 876f3da1b26d3edf178e809a1b7ef1d79463df0e Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:52:10 +0100 Subject: [PATCH 25/27] feat(beta): public spaces (#2473) --- apps/client/package.json | 1 + .../public/locales/en-US/translation.json | 77 ++ apps/client/src/App.tsx | 18 + apps/client/src/ee/features.ts | 1 + .../space-public-sharing-toggle.tsx | 1 + .../space-viewer-comments-toggle.tsx | 1 + .../editor/components/audio/audio-menu.tsx | 3 + .../components/callout/callout-menu.tsx | 4 +- .../components/columns/columns-menu.tsx | 3 + .../editor/components/drawio/drawio-menu.tsx | 3 + .../editor/components/image/image-menu.tsx | 3 + .../editor/components/link/link-view.tsx | 85 +- .../components/mention/mention-view.tsx | 75 +- .../editor/components/pdf/pdf-menu.tsx | 3 + .../components/subpages/subpages-menu.tsx | 3 + .../components/subpages/subpages-view.tsx | 56 +- .../table-of-contents/table-of-contents.tsx | 2 +- .../transclusion-lookup-context.tsx | 17 +- .../editor/components/video/video-menu.tsx | 3 + .../features/editor/readonly-page-editor.tsx | 28 +- .../page/components/header/page-header.tsx | 51 +- apps/client/src/features/page/page.utils.ts | 13 + .../page/tree/components/doc-tree-row.tsx | 5 +- .../page/tree/components/doc-tree.tsx | 43 +- .../public-space/atoms/public-space-atoms.ts | 17 + .../components/appearance-settings.module.css | 51 + .../components/appearance-settings.tsx | 165 +++ .../components/docs/docs-breadcrumbs.tsx | 114 ++ .../components/docs/docs-copy-page.tsx | 45 + .../components/docs/docs-edit-page.tsx | 31 + .../components/docs/docs-footer-branding.tsx | 23 + .../components/docs/docs-hub.module.css | 485 ++++++++ .../components/docs/docs-page-nav.tsx | 70 ++ .../components/docs/docs-search-button.tsx | 22 + .../components/docs/docs-shell.tsx | 182 +++ .../components/docs/docs-sidebar-tree.tsx | 198 +++ .../components/docs/docs-surface-context.tsx | 27 + .../components/docs/docs-theme-toggle.tsx | 35 + .../public-space/components/docs/docs-toc.tsx | 116 ++ .../components/docs/docs.module.css | 902 ++++++++++++++ .../components/public-space-layout.tsx | 69 ++ .../components/publish-space-settings.tsx | 283 +++++ .../components/published-spaces-list.tsx | 210 ++++ .../components/space-public-notice.tsx | 47 + .../hooks/use-authenticated-user.ts | 14 + .../hooks/use-docs-current-page.ts | 23 + .../queries/public-space-query.ts | 109 ++ .../services/public-space-service.ts | 73 ++ .../features/public-space/theme/docs-theme.ts | 91 ++ .../public-space/types/public-space.types.ts | 105 ++ .../features/public-space/utils/docs-tree.ts | 39 + .../public-space/utils/public-space-access.ts | 8 + .../public-space-search-spotlight.tsx | 112 ++ apps/client/src/features/search/constants.ts | 3 + .../features/search/queries/search-query.ts | 11 + .../search/services/search-service.ts | 10 + .../atoms/open-shared-tree-nodes-atom.ts | 3 - .../features/share/atoms/shared-page-atom.ts | 13 +- .../src/features/share/atoms/sidebar-atom.ts | 9 - .../share/components/share-branding.tsx | 16 - .../share/components/share-layout.tsx | 65 +- .../features/share/components/share-shell.tsx | 272 ---- .../share/components/share.module.css | 36 - .../features/share/components/shared-tree.tsx | 220 ---- .../share/hooks/use-shared-page-subpages.ts | 26 +- .../features/share/hooks/use-toggle-toc.ts | 8 - apps/client/src/features/share/utils.ts | 21 + .../space/components/settings-modal.tsx | 31 +- .../components/sidebar/space-sidebar.tsx | 3 +- .../space/components/sidebar/switch-space.tsx | 22 +- .../src/features/space/types/space.types.ts | 1 + .../transclusion/services/transclusion-api.ts | 8 + .../components/allow-public-spaces.tsx | 129 ++ .../workspace/types/workspace.types.ts | 8 + apps/client/src/lib/api-client.ts | 7 + apps/client/src/lib/config.ts | 4 + .../public-space-directory-page.tsx | 235 ++++ .../pages/public-space/public-space-page.tsx | 125 ++ .../src/pages/settings/shares/shares.tsx | 48 +- .../settings/workspace/workspace-settings.tsx | 10 +- apps/client/src/pages/share/shared-page.tsx | 47 +- apps/client/src/pages/space/space-home.tsx | 2 + apps/client/src/styles/a11y-overrides.css | 10 + apps/client/src/styles/public-typography.css | 39 + apps/client/vite.config.ts | 2 + apps/server/src/common/features.ts | 1 + apps/server/src/core/core.module.ts | 2 + .../core/public-space/dto/public-space.dto.ts | 79 ++ .../public-space-seo.controller.ts | 173 +++ .../public-space/public-space.controller.ts | 249 ++++ .../core/public-space/public-space.module.ts | 14 + .../public-space/public-space.service.spec.ts | 1103 +++++++++++++++++ .../core/public-space/public-space.service.ts | 397 ++++++ apps/server/src/core/search/dto/search.dto.ts | 6 + .../src/core/search/search.controller.spec.ts | 75 ++ .../src/core/search/search.controller.ts | 45 +- apps/server/src/core/search/search.module.ts | 2 + apps/server/src/core/search/search.service.ts | 12 +- .../src/core/share/share-seo.controller.ts | 4 +- apps/server/src/core/share/share.service.ts | 64 +- .../workspace/dto/update-workspace.dto.ts | 8 + .../services/workspace.service.spec.ts | 18 - .../workspace/services/workspace.service.ts | 45 + apps/server/src/database/database.module.ts | 3 + .../20260904T171920-public-spaces.ts | 38 + .../src/database/repos/page/page.repo.ts | 79 ++ .../repos/public-space/public-space.repo.ts | 196 +++ .../src/database/repos/space/space.repo.ts | 15 + .../repos/workspace/workspace.repo.ts | 20 + apps/server/src/database/types/db.d.ts | 13 + .../server/src/database/types/entity.types.ts | 6 + apps/server/src/ee | 2 +- .../environment/environment.service.ts | 7 + .../src/integrations/static/static.module.ts | 1 + apps/server/src/main.ts | 6 +- .../src/lib/markdown/utils/marked.utils.ts | 7 +- pnpm-lock.yaml | 8 + 117 files changed, 7592 insertions(+), 715 deletions(-) create mode 100644 apps/client/src/features/public-space/atoms/public-space-atoms.ts create mode 100644 apps/client/src/features/public-space/components/appearance-settings.module.css create mode 100644 apps/client/src/features/public-space/components/appearance-settings.tsx create mode 100644 apps/client/src/features/public-space/components/docs/docs-breadcrumbs.tsx create mode 100644 apps/client/src/features/public-space/components/docs/docs-copy-page.tsx create mode 100644 apps/client/src/features/public-space/components/docs/docs-edit-page.tsx create mode 100644 apps/client/src/features/public-space/components/docs/docs-footer-branding.tsx create mode 100644 apps/client/src/features/public-space/components/docs/docs-hub.module.css create mode 100644 apps/client/src/features/public-space/components/docs/docs-page-nav.tsx create mode 100644 apps/client/src/features/public-space/components/docs/docs-search-button.tsx create mode 100644 apps/client/src/features/public-space/components/docs/docs-shell.tsx create mode 100644 apps/client/src/features/public-space/components/docs/docs-sidebar-tree.tsx create mode 100644 apps/client/src/features/public-space/components/docs/docs-surface-context.tsx create mode 100644 apps/client/src/features/public-space/components/docs/docs-theme-toggle.tsx create mode 100644 apps/client/src/features/public-space/components/docs/docs-toc.tsx create mode 100644 apps/client/src/features/public-space/components/docs/docs.module.css create mode 100644 apps/client/src/features/public-space/components/public-space-layout.tsx create mode 100644 apps/client/src/features/public-space/components/publish-space-settings.tsx create mode 100644 apps/client/src/features/public-space/components/published-spaces-list.tsx create mode 100644 apps/client/src/features/public-space/components/space-public-notice.tsx create mode 100644 apps/client/src/features/public-space/hooks/use-authenticated-user.ts create mode 100644 apps/client/src/features/public-space/hooks/use-docs-current-page.ts create mode 100644 apps/client/src/features/public-space/queries/public-space-query.ts create mode 100644 apps/client/src/features/public-space/services/public-space-service.ts create mode 100644 apps/client/src/features/public-space/theme/docs-theme.ts create mode 100644 apps/client/src/features/public-space/types/public-space.types.ts create mode 100644 apps/client/src/features/public-space/utils/docs-tree.ts create mode 100644 apps/client/src/features/public-space/utils/public-space-access.ts create mode 100644 apps/client/src/features/search/components/public-space-search-spotlight.tsx delete mode 100644 apps/client/src/features/share/atoms/open-shared-tree-nodes-atom.ts delete mode 100644 apps/client/src/features/share/atoms/sidebar-atom.ts delete mode 100644 apps/client/src/features/share/components/share-branding.tsx delete mode 100644 apps/client/src/features/share/components/share-shell.tsx delete mode 100644 apps/client/src/features/share/components/shared-tree.tsx delete mode 100644 apps/client/src/features/share/hooks/use-toggle-toc.ts create mode 100644 apps/client/src/features/workspace/components/settings/components/allow-public-spaces.tsx create mode 100644 apps/client/src/pages/public-space/public-space-directory-page.tsx create mode 100644 apps/client/src/pages/public-space/public-space-page.tsx create mode 100644 apps/client/src/styles/public-typography.css create mode 100644 apps/server/src/core/public-space/dto/public-space.dto.ts create mode 100644 apps/server/src/core/public-space/public-space-seo.controller.ts create mode 100644 apps/server/src/core/public-space/public-space.controller.ts create mode 100644 apps/server/src/core/public-space/public-space.module.ts create mode 100644 apps/server/src/core/public-space/public-space.service.spec.ts create mode 100644 apps/server/src/core/public-space/public-space.service.ts delete mode 100644 apps/server/src/core/workspace/services/workspace.service.spec.ts create mode 100644 apps/server/src/database/migrations/20260904T171920-public-spaces.ts create mode 100644 apps/server/src/database/repos/public-space/public-space.repo.ts diff --git a/apps/client/package.json b/apps/client/package.json index d836d5db6..188aabde3 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -21,6 +21,7 @@ "@docmost/base-formula": "workspace:*", "@docmost/editor-ext": "workspace:*", "@excalidraw/excalidraw": "0.18.0-3a5ef40", + "@fontsource-variable/inter": "5.3.0", "@mantine/core": "9.3.2", "@mantine/dates": "9.3.2", "@mantine/form": "9.3.2", diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index ef7db5d20..3dc11c1b3 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1345,6 +1345,83 @@ "Toggle AI Chat read-only mode": "Toggle AI Chat read-only mode", "Title only": "Title only", "you": "you", + "Allow public spaces": "Allow public spaces", + "Space admins can publish their spaces to the web.": "Space admins can publish their spaces to the web.", + "Toggle allow public spaces": "Toggle allow public spaces", + "Publish space to the web": "Publish space to the web", + "Anyone on the internet will be able to read every page in this space, except restricted pages. Are you sure?": "Anyone on the internet will be able to read every page in this space, except restricted pages. Are you sure?", + "Make this space publicly readable by anyone on the internet.": "Make this space publicly readable by anyone on the internet.", + "Toggle publish space to the web": "Toggle publish space to the web", + "Allow search engines to index": "Allow search engines to index", + "Let public pages in this space appear in search engine results.": "Let public pages in this space appear in search engine results.", + "Toggle search engine indexing": "Toggle search engine indexing", + "Public space link": "Public space link", + "Copy public space link": "Copy public space link", + "Renaming the space slug will break public links.": "Renaming the space slug will break public links.", + "Failed to update space": "Failed to update space", + "This space is public": "This space is public", + "Anyone on the internet can read the pages in this space, except restricted pages.": "Anyone on the internet can read the pages in this space, except restricted pages.", + "Open public site": "Open public site", + "Public": "Public", + "This space has no public pages yet.": "This space has no public pages yet.", + "On this page": "On this page", + "Previous": "Previous", + "Next": "Next", + "Show hidden pages": "Show hidden pages", + "Page navigation": "Page navigation", + "Toggle sidebar": "Toggle sidebar", + "Toggle table of contents": "Toggle table of contents", + "Appearance": "Appearance", + "Forest": "Forest", + "Violet": "Violet", + "Light mode color": "Light mode color", + "Dark mode color": "Dark mode color", + "Choose the primary color of the public docs site.": "Choose the primary color of the public docs site.", + "Show page author": "Show page author", + "Display the page creator's name on public pages.": "Display the page creator's name on public pages.", + "Toggle show page author": "Toggle show page author", + "Show last updated": "Show last updated", + "Display when each page was last updated.": "Display when each page was last updated.", + "Toggle show last updated": "Toggle show last updated", + "Open public page": "Open public page", + "Toggle color scheme": "Toggle color scheme", + "Ember": "Ember", + "Rose": "Rose", + "Documentation": "Documentation", + "All published spaces.": "All published spaces.", + "No public spaces yet.": "No public spaces yet.", + "Show public directory": "Show public directory", + "List published spaces at /docs for anyone to browse.": "List published spaces at /docs for anyone to browse.", + "Toggle show public directory": "Toggle show public directory", + "Show in public directory": "Show in public directory", + "List this space in the public directory at /docs.": "List this space in the public directory at /docs.", + "Toggle show in public directory": "Toggle show in public directory", + "Open public space link": "Open public space link", + "Disable public spaces": "Disable public spaces", + "Space admins will be able to make their spaces publicly readable by anyone on the internet. Are you sure?": "Space admins will be able to make their spaces publicly readable by anyone on the internet. Are you sure?", + "This will immediately unpublish every published space. Re-enabling later will not republish them. Are you sure?": "This will immediately unpublish every published space. Re-enabling later will not republish them. Are you sure?", + "Allow": "Allow", + "Shared pages": "Shared pages", + "Published spaces": "Published spaces", + "Spaces published to the web will appear here": "Spaces published to the web will appear here", + "No published spaces": "No published spaces", + "Published by": "Published by", + "Published at": "Published at", + "Open space": "Open space", + "Unpublish": "Unpublish", + "Unpublish space": "Unpublish space", + "This space will no longer be publicly accessible. Are you sure?": "This space will no longer be publicly accessible. Are you sure?", + "More options for {{name}}": "More options for {{name}}", + "Edit page": "Edit page", + "Sign in": "Sign in", + "Open app": "Open app", + "No spaces match your search.": "No spaces match your search.", + "Welcome to our documentation": "Welcome to our documentation", + "Guides, references and answers across all our published spaces.": "Guides, references and answers across all our published spaces.", + "Guides, references and answers across all our spaces.": "Guides, references and answers across all our spaces.", + "Search documentation...": "Search documentation...", + "1 published space": "1 published space", + "{{count}} published spaces": "{{count}} published spaces", "Actions": "Actions", "Add destination": "Add destination", "Are you sure you want to delete the destination": "Are you sure you want to delete the destination", diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index 1f7967c2c..e4238a0a6 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -44,6 +44,15 @@ const ShareLayout = lazy( () => import("@/features/share/components/share-layout.tsx"), ); const ShareRedirect = lazy(() => import("@/pages/share/share-redirect.tsx")); +const PublicSpacePage = lazy( + () => import("@/pages/public-space/public-space-page.tsx"), +); +const PublicSpaceLayout = lazy( + () => import("@/features/public-space/components/public-space-layout.tsx"), +); +const PublicSpaceDirectoryPage = lazy( + () => import("@/pages/public-space/public-space-directory-page.tsx"), +); const SpacesPage = lazy(() => import("@/pages/spaces/spaces.tsx")); const MfaChallengePage = lazy(() => import("@/ee/mfa/pages/mfa-challenge-page").then((m) => ({ @@ -119,6 +128,15 @@ export default function App() { } /> + } /> + }> + } /> + } + /> + + } /> } /> } /> diff --git a/apps/client/src/ee/features.ts b/apps/client/src/ee/features.ts index a62462459..87bb559d5 100644 --- a/apps/client/src/ee/features.ts +++ b/apps/client/src/ee/features.ts @@ -25,5 +25,6 @@ export const Feature = { OAUTH: 'oauth', AI_CONTROLS: 'ai:controls', MCP_CONTROLS: 'mcp:controls', + PUBLIC_SPACE_APPEARANCE: 'public-space:appearance', SIEM: 'siem', } as const; diff --git a/apps/client/src/ee/security/components/space-public-sharing-toggle.tsx b/apps/client/src/ee/security/components/space-public-sharing-toggle.tsx index 2b8b008fc..dc2b779d3 100644 --- a/apps/client/src/ee/security/components/space-public-sharing-toggle.tsx +++ b/apps/client/src/ee/security/components/space-public-sharing-toggle.tsx @@ -82,6 +82,7 @@ export default function SpacePublicSharingToggle({ checked={checked} onChange={handleChange} disabled={isDisabled} + size={"xs"} aria-label={t("Toggle space public sharing")} />
diff --git a/apps/client/src/ee/security/components/space-viewer-comments-toggle.tsx b/apps/client/src/ee/security/components/space-viewer-comments-toggle.tsx index 88fac67fa..6699eafa0 100644 --- a/apps/client/src/ee/security/components/space-viewer-comments-toggle.tsx +++ b/apps/client/src/ee/security/components/space-viewer-comments-toggle.tsx @@ -53,6 +53,7 @@ export default function SpaceViewerCommentsToggle({ checked={checked} onChange={handleChange} disabled={isDisabled} + size={"xs"} aria-label={t("Toggle viewer comments")} />
diff --git a/apps/client/src/features/editor/components/audio/audio-menu.tsx b/apps/client/src/features/editor/components/audio/audio-menu.tsx index eadc1afe5..382bc9afd 100644 --- a/apps/client/src/features/editor/components/audio/audio-menu.tsx +++ b/apps/client/src/features/editor/components/audio/audio-menu.tsx @@ -85,6 +85,9 @@ export function AudioMenu({ editor }: EditorMenuProps) { { + if (element) element.style.zIndex = "99"; + }} updateDelay={0} getReferencedVirtualElement={getReferencedVirtualElement} options={{ diff --git a/apps/client/src/features/editor/components/callout/callout-menu.tsx b/apps/client/src/features/editor/components/callout/callout-menu.tsx index 3ce022dae..df64950b8 100644 --- a/apps/client/src/features/editor/components/callout/callout-menu.tsx +++ b/apps/client/src/features/editor/components/callout/callout-menu.tsx @@ -121,12 +121,14 @@ export function CalloutMenu({ editor }: EditorMenuProps) { { + if (element) element.style.zIndex = "99"; + }} updateDelay={0} getReferencedVirtualElement={getReferencedVirtualElement} options={{ placement: "bottom", // offset: 233, // // offset: [0, 10], - // zIndex: 99, flip: false, }} shouldShow={shouldShow} diff --git a/apps/client/src/features/editor/components/columns/columns-menu.tsx b/apps/client/src/features/editor/components/columns/columns-menu.tsx index ce01324fd..d88d9f6ae 100644 --- a/apps/client/src/features/editor/components/columns/columns-menu.tsx +++ b/apps/client/src/features/editor/components/columns/columns-menu.tsx @@ -257,6 +257,9 @@ export function ColumnsMenu({ editor }: EditorMenuProps) { { + if (element) element.style.zIndex = "99"; + }} updateDelay={0} getReferencedVirtualElement={getReferencedVirtualElement} options={{ diff --git a/apps/client/src/features/editor/components/drawio/drawio-menu.tsx b/apps/client/src/features/editor/components/drawio/drawio-menu.tsx index 418bd4de1..0872116af 100644 --- a/apps/client/src/features/editor/components/drawio/drawio-menu.tsx +++ b/apps/client/src/features/editor/components/drawio/drawio-menu.tsx @@ -273,6 +273,9 @@ export function DrawioMenu({ editor }: EditorMenuProps) { { + if (element) element.style.zIndex = "99"; + }} updateDelay={0} getReferencedVirtualElement={getReferencedVirtualElement} options={{ diff --git a/apps/client/src/features/editor/components/image/image-menu.tsx b/apps/client/src/features/editor/components/image/image-menu.tsx index d6d9c9925..252e506f0 100644 --- a/apps/client/src/features/editor/components/image/image-menu.tsx +++ b/apps/client/src/features/editor/components/image/image-menu.tsx @@ -156,6 +156,9 @@ export function ImageMenu({ editor }: EditorMenuProps) { { + if (element) element.style.zIndex = "99"; + }} updateDelay={0} getReferencedVirtualElement={getReferencedVirtualElement} options={{ diff --git a/apps/client/src/features/editor/components/link/link-view.tsx b/apps/client/src/features/editor/components/link/link-view.tsx index daa5bb5de..39de1d667 100644 --- a/apps/client/src/features/editor/components/link/link-view.tsx +++ b/apps/client/src/features/editor/components/link/link-view.tsx @@ -26,7 +26,12 @@ import { INTERNAL_LINK_REGEX } from "@/lib/constants"; import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx"; import { usePageQuery } from "@/features/page/queries/page-query.ts"; import { useSharePageQuery } from "@/features/share/queries/share-query.ts"; -import { buildSharedPageUrl } from "@/features/page/page.utils.ts"; +import { usePublicSpacePageQuery } from "@/features/public-space/queries/public-space-query.ts"; +import { + buildPageUrl, + buildPublicSpaceUrl, + buildSharedPageUrl, +} from "@/features/page/page.utils.ts"; import { extractPageSlugId } from "@/lib"; import { sanitizeUrl, copyToClipboard, isEditorReady } from "@docmost/editor-ext"; import { normalizeUrl } from "@/lib/utils"; @@ -60,9 +65,10 @@ export default function LinkView(props: MarkViewProps) { const href = mark.attrs.href as string; const navigate = useNavigate(); const location = useLocation(); - const { shareId, pageSlug } = useParams(); + const { shareId, spaceSlug, pageSlug } = useParams(); const { t } = useTranslation(); const isShareRoute = location.pathname.startsWith("/share"); + const isPublicSpaceRoute = location.pathname.startsWith("/docs/"); const [popoverState, setPopoverState] = useState< "closed" | "preview" | "edit" @@ -84,16 +90,33 @@ export default function LinkView(props: MarkViewProps) { const activeView = isPopoverVisible ? popoverState : lastOpenState.current; const { data: linkedPage } = usePageQuery({ - pageId: isPopoverVisible && slugId && !isShareRoute ? slugId : null, + pageId: + isPopoverVisible && slugId && !isShareRoute && !isPublicSpaceRoute + ? slugId + : null, }); const { data: sharedPageData } = useSharePageQuery({ pageId: isPopoverVisible && slugId && isShareRoute ? slugId : null, }); - const pageTitle = isShareRoute - ? sharedPageData?.page?.title - : linkedPage?.title; + // Resolved eagerly (not gated on the popover): an unresolvable target must + // render as inert text rather than a link that dead-ends at /login. + const { data: publicSpacePageData } = usePublicSpacePageQuery({ + spaceSlug: isPublicSpaceRoute && slugId ? spaceSlug : undefined, + pageSlugId: slugId, + contentless: true, + }); + + const isUnresolvedPublicLink = + isPublicSpaceRoute && isInternal && !publicSpacePageData?.page && !slugId; + + let pageTitle = linkedPage?.title; + if (isShareRoute) { + pageTitle = sharedPageData?.page?.title; + } else if (isPublicSpaceRoute) { + pageTitle = publicSpacePageData?.page?.title; + } const pendingTitleRef = useRef(null); const titleInputRef = useRef(null); @@ -260,6 +283,27 @@ export default function LinkView(props: MarkViewProps) { anchorId: anchor || undefined, }); navigate(sharedUrl); + } else if (isPublicSpaceRoute) { + if (slugId && publicSpacePageData?.page) { + navigate( + buildPublicSpaceUrl({ + // cross-space targets resolve to their own space's public URL + spaceSlug: publicSpacePageData.space?.slug ?? spaceSlug, + pageSlugId: slugId, + pageTitle: pageTitle, + anchorId: anchor || undefined, + }), + ); + } else if (slugId) { + // no public URL: the /p/ resolver redirects members straight to the + // page and funnels anonymous visitors through login first; a new tab + // keeps the docs tab's history intact through that redirect chain + window.open( + buildPageUrl(undefined, slugId, pageTitle, anchor || undefined), + "_blank", + "noopener,noreferrer", + ); + } } else { navigate(anchor ? `${targetPath}#${anchor}` : targetPath); } @@ -276,8 +320,11 @@ export default function LinkView(props: MarkViewProps) { location.pathname, isInternal, isShareRoute, + isPublicSpaceRoute, slugId, shareId, + spaceSlug, + publicSpacePageData, pageTitle, pageSlug, ]); @@ -319,12 +366,18 @@ export default function LinkView(props: MarkViewProps) { setPopoverState("closed"); }, [editor]); + const internalHref = () => { + if (isShareRoute && slugId) { + return buildSharedPageUrl({ shareId, pageSlugId: slugId, pageTitle }); + } + if (isPublicSpaceRoute && slugId && publicSpacePageData?.page) { + return buildPublicSpaceUrl({ spaceSlug, pageSlugId: slugId, pageTitle }); + } + return href; + }; + const displayHref = sanitizeUrl( - isInternal - ? isShareRoute && slugId - ? buildSharedPageUrl({ shareId, pageSlugId: slugId, pageTitle }) - : href - : normalizeUrl(href), + isInternal ? internalHref() : normalizeUrl(href), ); const linkTitleInput = ( @@ -374,6 +427,16 @@ export default function LinkView(props: MarkViewProps) { ); + // Targets outside the published space have no public URL, so the label is + // rendered as inert text instead of a link that dead-ends at /login. + if (isUnresolvedPublicLink) { + return ( + + + + ); + } + return ( )} - {isPageMention && !isShareRoute && isError && ( + {isPageMention && isPublicSpaceRoute && publicPageData?.page && ( + + + + + + {publicPageData.page.title || label} + + + )} + + {/* No public URL: the /p/ resolver redirects members to the page and + funnels anonymous visitors through login first. New tab, so the + redirect chain never rewrites the docs tab's history. */} + {isPageMention && isPublicSpaceRoute && !publicPageData?.page && ( + + + + + {label} + + )} + + {isPageMention && !isShareRoute && !isPublicSpaceRoute && isError && ( )} - {isPageMention && !isShareRoute && !isError && ( + {isPageMention && !isShareRoute && !isPublicSpaceRoute && !isError && ( { + if (element) element.style.zIndex = "99"; + }} updateDelay={0} getReferencedVirtualElement={getReferencedVirtualElement} options={{ diff --git a/apps/client/src/features/editor/components/subpages/subpages-menu.tsx b/apps/client/src/features/editor/components/subpages/subpages-menu.tsx index 776568037..190893607 100644 --- a/apps/client/src/features/editor/components/subpages/subpages-menu.tsx +++ b/apps/client/src/features/editor/components/subpages/subpages-menu.tsx @@ -61,6 +61,9 @@ export const SubpagesMenu = React.memo( { + if (element) element.style.zIndex = "99"; + }} updateDelay={0} shouldShow={shouldShow} > diff --git a/apps/client/src/features/editor/components/subpages/subpages-view.tsx b/apps/client/src/features/editor/components/subpages/subpages-view.tsx index a50207aa0..0683a22e1 100644 --- a/apps/client/src/features/editor/components/subpages/subpages-view.tsx +++ b/apps/client/src/features/editor/components/subpages/subpages-view.tsx @@ -3,22 +3,30 @@ import { Stack, Text, Anchor, ActionIcon } from "@mantine/core"; import { IconFileDescription } from "@tabler/icons-react"; import { useGetSidebarPagesQuery } from "@/features/page/queries/page-query"; import { useMemo } from "react"; -import { Link, useParams } from "react-router-dom"; +import { Link, useLocation, useParams } from "react-router-dom"; import classes from "./subpages.module.css"; import styles from "../mention/mention.module.css"; import { buildPageUrl, + buildPublicSpaceUrl, buildSharedPageUrl, } from "@/features/page/page.utils.ts"; import { useTranslation } from "react-i18next"; import { sortPositionKeys } from "@/features/page/tree/utils/utils"; import { useSharedPageSubpages } from "@/features/share/hooks/use-shared-page-subpages"; +import { useAtomValue } from "jotai"; +import { publicSpaceTreeDataAtom } from "@/features/public-space/atoms/public-space-atoms.ts"; +import { findSubpagesInTree } from "@/features/share/utils"; import { extractPageSlugId } from "@/lib"; export default function SubpagesView(props: NodeViewProps) { const { editor } = props; const { spaceSlug, shareId, pageSlug } = useParams(); const { t } = useTranslation(); + const location = useLocation(); + const isPublicSpaceRoute = location.pathname.startsWith("/docs/"); + + const publicSpaceTreeData = useAtomValue(publicSpaceTreeDataAtom); // @ts-ignore const storagePageId = editor.storage.pageId; @@ -29,11 +37,25 @@ export default function SubpagesView(props: NodeViewProps) { currentPageId = routePageId; } + // Public docs must resolve the page from the route, not editor storage: + // storage.pageId is set after this view's first render and is not reactive, + // which froze the list at "No subpages" until something re-rendered it. The + // space home renders at the bare URL, so it falls back to the first root. + if (isPublicSpaceRoute) { + currentPageId = routePageId ?? publicSpaceTreeData?.[0]?.slugId; + } + // Get subpages from shared tree if we're in a shared context const sharedSubpages = useSharedPageSubpages(currentPageId); + const publicSpaceSubpages = useMemo( + () => findSubpagesInTree(publicSpaceTreeData, currentPageId), + [publicSpaceTreeData, currentPageId], + ); + + const isPublicView = Boolean(shareId) || isPublicSpaceRoute; const { data, isLoading, error } = useGetSidebarPagesQuery( - shareId ? null : { pageId: currentPageId }, + isPublicView ? null : { pageId: currentPageId }, ); const subpages = useMemo(() => { @@ -48,17 +70,33 @@ export default function SubpagesView(props: NodeViewProps) { })); } + if (isPublicSpaceRoute) { + return publicSpaceSubpages.map((node) => ({ + id: node.value, + slugId: node.slugId, + title: node.name, + icon: node.icon, + position: node.position, + })); + } + // Otherwise use the API data if (!data?.pages) return []; const allPages = data.pages.flatMap((page) => page.items); return sortPositionKeys(allPages); - }, [data, shareId, sharedSubpages]); + }, [ + data, + shareId, + sharedSubpages, + isPublicSpaceRoute, + publicSpaceSubpages, + ]); - if (isLoading && !shareId) { + if (isLoading && !isPublicView) { return null; } - if (error && !shareId) { + if (error && !isPublicView) { return ( @@ -96,7 +134,13 @@ export default function SubpagesView(props: NodeViewProps) { pageSlugId: page.slugId, pageTitle: page.title, }) - : buildPageUrl(spaceSlug, page.slugId, page.title) + : isPublicSpaceRoute + ? buildPublicSpaceUrl({ + spaceSlug, + pageSlugId: page.slugId, + pageTitle: page.title, + }) + : buildPageUrl(spaceSlug, page.slugId, page.title) } underline="never" className={styles.pageMentionLink} diff --git a/apps/client/src/features/editor/components/table-of-contents/table-of-contents.tsx b/apps/client/src/features/editor/components/table-of-contents/table-of-contents.tsx index ba2bed40e..c4a082db0 100644 --- a/apps/client/src/features/editor/components/table-of-contents/table-of-contents.tsx +++ b/apps/client/src/features/editor/components/table-of-contents/table-of-contents.tsx @@ -18,7 +18,7 @@ export type HeadingLink = { position: number; }; -const recalculateLinks = (nodePos: NodePos[]) => { +export const recalculateLinks = (nodePos: NodePos[]) => { const nodes: HTMLElement[] = []; const links: HeadingLink[] = Array.from(nodePos).reduce( diff --git a/apps/client/src/features/editor/components/transclusion/transclusion-lookup-context.tsx b/apps/client/src/features/editor/components/transclusion/transclusion-lookup-context.tsx index ec44a5f20..6e3a3c708 100644 --- a/apps/client/src/features/editor/components/transclusion/transclusion-lookup-context.tsx +++ b/apps/client/src/features/editor/components/transclusion/transclusion-lookup-context.tsx @@ -9,6 +9,7 @@ import React, { } from "react"; import { lookupTransclusion, + lookupTransclusionForPublicSpace, lookupTransclusionForShare, } from "@/features/transclusion/services/transclusion-api"; import type { TransclusionLookup } from "@/features/transclusion/types/transclusion.types"; @@ -38,6 +39,7 @@ const TransclusionLookupContext = createContext(null); export function TransclusionLookupProvider({ children, shareId, + spaceSlug, }: { children: React.ReactNode; /** @@ -47,6 +49,11 @@ export function TransclusionLookupProvider({ * app, where personal permissions gate access. */ shareId?: string; + /** + * When set, lookups go through the public-space endpoint and are gated by + * the published space. Used by the public docs viewer. + */ + spaceSlug?: string; }) { const subscribersRef = useRef(new Map()); const queueRef = useRef(new Set()); @@ -55,6 +62,8 @@ export function TransclusionLookupProvider({ // memoized callbacks (and thus doesn't re-render every consumer). const shareIdRef = useRef(shareId); shareIdRef.current = shareId; + const spaceSlugRef = useRef(spaceSlug); + spaceSlugRef.current = spaceSlug; // Last looked-up value for each key. Re-subscribers (e.g. when the editor // remounts after switching from static to live) get this immediately // instead of triggering a duplicate fetch. @@ -91,12 +100,18 @@ export function TransclusionLookupProvider({ try { const activeShareId = shareIdRef.current; + const activeSpaceSlug = spaceSlugRef.current; const { items } = activeShareId ? await lookupTransclusionForShare({ shareId: activeShareId, references, }) - : await lookupTransclusion({ references }); + : activeSpaceSlug + ? await lookupTransclusionForPublicSpace({ + spaceSlug: activeSpaceSlug, + references, + }) + : await lookupTransclusion({ references }); for (const r of items) { const key = `${r.sourcePageId}::${r.transclusionId}`; resultCacheRef.current.set(key, r); diff --git a/apps/client/src/features/editor/components/video/video-menu.tsx b/apps/client/src/features/editor/components/video/video-menu.tsx index 0e01fe8ba..9cf8e3fd0 100644 --- a/apps/client/src/features/editor/components/video/video-menu.tsx +++ b/apps/client/src/features/editor/components/video/video-menu.tsx @@ -132,6 +132,9 @@ export function VideoMenu({ editor }: EditorMenuProps) { { + if (element) element.style.zIndex = "99"; + }} updateDelay={0} getReferencedVirtualElement={getReferencedVirtualElement} options={{ diff --git a/apps/client/src/features/editor/readonly-page-editor.tsx b/apps/client/src/features/editor/readonly-page-editor.tsx index df2f6e470..f99bfc04a 100644 --- a/apps/client/src/features/editor/readonly-page-editor.tsx +++ b/apps/client/src/features/editor/readonly-page-editor.tsx @@ -35,6 +35,16 @@ interface PageEditorProps { * that isn't itself shared. */ shareId?: string; + /** + * When rendering inside a public space, pass the space slug. Transclusion + * lookups then resolve against the published space instead of the viewer's + * personal permissions. + */ + spaceSlug?: string; + /** Rendered between the title and the content. */ + byline?: React.ReactNode; + /** Set false when the consumer renders its own end matter (e.g. prev/next). */ + trailingSpace?: boolean; } export default function ReadonlyPageEditor({ @@ -43,12 +53,16 @@ export default function ReadonlyPageEditor({ pageId, printMode = false, shareId, + spaceSlug, + byline, + trailingSpace = true, }: PageEditorProps) { const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom); const [lightboxRequest, setLightboxRequest] = useAtom(lightboxRequestAtom); const [contentEditor, setContentEditor] = useState(null); const isComponentMounted = useRef(false); const editorCreated = useRef(false); + const isPublicView = Boolean(shareId || spaceSlug); const canScroll = useCallback( () => isComponentMounted.current && editorCreated.current, @@ -64,9 +78,9 @@ export default function ReadonlyPageEditor({ }, []); useEffect(() => { - if (!shareId) return; + if (!isPublicView) return; setLightboxRequest(null); - }, [pageId, shareId]); + }, [pageId, isPublicView]); const extensions = useMemo(() => { const excludedExtensions = new Set([ @@ -99,7 +113,7 @@ export default function ReadonlyPageEditor({ ]; return ( - +
+ {byline} + { const request = getLightboxClickRequest(node); @@ -144,7 +160,7 @@ export default function ReadonlyPageEditor({ } }} > - {shareId && contentEditor && ( + {isPublicView && contentEditor && ( setLightboxRequest(null)} /> )} -
+ {trailingSpace &&
}
); } diff --git a/apps/client/src/features/page/components/header/page-header.tsx b/apps/client/src/features/page/components/header/page-header.tsx index 0614cf0bd..aec7038a8 100644 --- a/apps/client/src/features/page/components/header/page-header.tsx +++ b/apps/client/src/features/page/components/header/page-header.tsx @@ -1,16 +1,63 @@ import classes from "./page-header.module.css"; import PageHeaderMenu from "@/features/page/components/header/page-header-menu.tsx"; -import { Group } from "@mantine/core"; +import { Badge, Group, Tooltip } from "@mantine/core"; +import { IconExternalLink, IconWorld } from "@tabler/icons-react"; import Breadcrumb from "@/features/page/components/breadcrumbs/breadcrumb.tsx"; +import { useParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts"; +import { usePageQuery } from "@/features/page/queries/page-query.ts"; +import { extractPageSlugId } from "@/lib"; +import { buildPublicSpaceUrl } from "@/features/page/page.utils.ts"; +import { isBetaPublicSpaces } from "@/lib/config.ts"; interface Props { readOnly?: boolean; } export default function PageHeader({ readOnly }: Props) { + const { t } = useTranslation(); + const { spaceSlug, pageSlug } = useParams(); + const { data: space } = useGetSpaceBySlugQuery(spaceSlug); + const { data: page } = usePageQuery({ + pageId: extractPageSlugId(pageSlug), + }); + + // Restricted pages are never publicly reachable, so the chip only shows on + // pages the public site actually serves. + const showPublicBadge = + isBetaPublicSpaces() && + space?.isPublished && + page && + page.permissions?.hasRestriction !== true; + return (
- + + + + {showPublicBadge && ( + + } + rightSection={} + style={{ flexShrink: 0, cursor: "pointer" }} + > + {t("Public")} + + + )} + diff --git a/apps/client/src/features/page/page.utils.ts b/apps/client/src/features/page/page.utils.ts index 8b0111a28..638ba506a 100644 --- a/apps/client/src/features/page/page.utils.ts +++ b/apps/client/src/features/page/page.utils.ts @@ -55,3 +55,16 @@ export const buildSharedPageUrl = (opts: { } return anchorId ? `${url}#${anchorId}` : url; }; + +export const buildPublicSpaceUrl = (opts: { + spaceSlug: string; + pageSlugId?: string; + pageTitle?: string; + anchorId?: string; +}): string => { + const { spaceSlug, pageSlugId, pageTitle, anchorId } = opts; + const url = pageSlugId + ? `/docs/${spaceSlug}/${buildPageSlug(pageSlugId, pageTitle)}` + : `/docs/${spaceSlug}`; + return anchorId ? `${url}#${anchorId}` : url; +}; diff --git a/apps/client/src/features/page/tree/components/doc-tree-row.tsx b/apps/client/src/features/page/tree/components/doc-tree-row.tsx index e3aebe9e9..7c9df04b9 100644 --- a/apps/client/src/features/page/tree/components/doc-tree-row.tsx +++ b/apps/client/src/features/page/tree/components/doc-tree-row.tsx @@ -41,6 +41,7 @@ type Props = { activeId?: string; renderRow: (props: RenderRowProps) => ReactNode; indentPerLevel: number; + rowClassName?: string; onMove: (sourceId: string, op: DropOp) => void | Promise; onToggle: (id: string, isOpen: boolean) => void; readOnly: boolean; @@ -68,6 +69,7 @@ function DocTreeRowInner(props: Props) { activeId, renderRow, indentPerLevel, + rowClassName, onMove, onToggle, readOnly, @@ -323,7 +325,7 @@ function DocTreeRowInner(props: Props) { style={{ paddingLeft: level * indentPerLevel }} >
( if (prev.readOnly !== next.readOnly) return false; if (prev.contextId !== next.contextId) return false; if (prev.indentPerLevel !== next.indentPerLevel) return false; + if (prev.rowClassName !== next.rowClassName) return false; if (prev.renderRow !== next.renderRow) return false; if (prev.onMove !== next.onMove) return false; if (prev.onToggle !== next.onToggle) return false; diff --git a/apps/client/src/features/page/tree/components/doc-tree.tsx b/apps/client/src/features/page/tree/components/doc-tree.tsx index 3dbbfda16..299797333 100644 --- a/apps/client/src/features/page/tree/components/doc-tree.tsx +++ b/apps/client/src/features/page/tree/components/doc-tree.tsx @@ -56,6 +56,18 @@ export type DocTreeProps = { indentPerLevel?: number; rowHeight?: number; emptyState?: ReactNode; + // Extra class for the engine's row wrapper (the div carrying data-selected / + // data-dragging), letting consumers restyle hover/selected states. + rowClassName?: string; + // Extra vertical space above a row (e.g. to separate top-level groups). + // Added to the row's virtualized slot and applied as padding above it. + rowGap?: (node: TreeNode, level: number, index: number) => number; + // Measure real row heights so rows may wrap to multiple lines; rowHeight + // then only seeds the estimate. Off by default (fixed-slot fast path). + dynamicRowHeight?: boolean; + // Rendered inside the scroll container after the last row, so it flows + // with the tree content instead of pinning to the container edge. + footer?: ReactNode; onMove: (sourceId: string, op: DropOp) => void | Promise; onToggle: (id: string, isOpen: boolean) => void; @@ -123,6 +135,9 @@ function DocTreeInner( renderRow, indentPerLevel = 16, rowHeight = 32, + rowClassName, + rowGap, + dynamicRowHeight = false, onMove, onToggle, onSelect, @@ -132,6 +147,7 @@ function DocTreeInner( getDragLabel, uniqueContextId, emptyState, + footer, 'aria-label': ariaLabel, } = props; @@ -197,10 +213,20 @@ function DocTreeInner( return flat[0]?.node.id; }, [activeId, selectedId, flatIds, flat]); + // Keyed by node id so measured sizes follow their rows across + // expand/collapse instead of sticking to positions. Never reset the + // measurement cache wholesale: ResizeObserver only re-reports elements + // whose size changed, so unchanged mounted rows would be left positioned + // by the estimate and overlap their neighbors. const virtualizer = useVirtualizer({ count: flat.length, getScrollElement: () => scrollRef.current, - estimateSize: () => rowHeight, + getItemKey: (index) => flat[index].node.id, + estimateSize: (index) => { + const row = flat[index]; + const gap = row && rowGap ? rowGap(row.node, row.level, index) : 0; + return rowHeight + gap; + }, overscan: 10, }); @@ -471,7 +497,12 @@ function DocTreeInner( ); if (data.length === 0 && emptyState) { - return
{emptyState}
; + return ( +
+ {emptyState} + {footer} +
+ ); } const virtualItems = virtualizer.getVirtualItems(); @@ -494,6 +525,9 @@ function DocTreeInner( > {virtualItems.map((virtualItem) => { const row = flat[virtualItem.index]; + const gap = rowGap + ? rowGap(row.node, row.level, virtualItem.index) + : 0; return (
  • ( // (the row's ), so screen readers announce "treeitem" on // navigation. The
  • is just layout glue. role="none" + ref={dynamicRowHeight ? virtualizer.measureElement : undefined} + data-index={dynamicRowHeight ? virtualItem.index : undefined} style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${virtualItem.start}px)`, + ...(gap > 0 ? { paddingTop: gap } : {}), }} > ( activeId={effectiveActiveId} renderRow={renderRow} indentPerLevel={indentPerLevel} + rowClassName={rowClassName} onMove={onMove} onToggle={onToggle} readOnly={readOnly} @@ -532,6 +570,7 @@ function DocTreeInner( ); })} + {footer}
  • ); } diff --git a/apps/client/src/features/public-space/atoms/public-space-atoms.ts b/apps/client/src/features/public-space/atoms/public-space-atoms.ts new file mode 100644 index 000000000..8388d7c2b --- /dev/null +++ b/apps/client/src/features/public-space/atoms/public-space-atoms.ts @@ -0,0 +1,17 @@ +import { atom } from "jotai"; +import { IPublicSpaceTree } from "@/features/public-space/types/public-space.types.ts"; +import { SharedPageTreeNode } from "@/features/share/utils.ts"; + +export const publicSpaceTreeAtom = atom( + null as IPublicSpaceTree | null, +); + +export const publicSpaceTreeDataAtom = atom( + null as SharedPageTreeNode[] | null, +); + +export const openPublicSpaceTreeNodesAtom = atom>({}); + +export const docsMobileSidebarAtom = atom(false); + +export const docsMobileTocAtom = atom(false); diff --git a/apps/client/src/features/public-space/components/appearance-settings.module.css b/apps/client/src/features/public-space/components/appearance-settings.module.css new file mode 100644 index 000000000..c37de65ab --- /dev/null +++ b/apps/client/src/features/public-space/components/appearance-settings.module.css @@ -0,0 +1,51 @@ +.presetRow { + display: flex; + flex-wrap: wrap; + gap: rem(8px); +} + +.presetCard { + display: flex; + flex-direction: column; + align-items: center; + gap: rem(6px); + min-width: rem(72px); + padding: rem(10px) rem(12px); + border: 1px solid var(--mantine-color-default-border); + border-radius: rem(8px); + + @media (hover: hover) { + &:hover { + background-color: var(--mantine-color-default-hover); + } + } + + &:focus-visible { + outline: 2px solid var(--mantine-primary-color-filled); + outline-offset: 1px; + } +} + +.presetCard[data-selected="true"] { + border-color: var(--mantine-primary-color-filled); + background-color: var(--mantine-primary-color-light); +} + +.presetSwatch { + width: rem(22px); + height: rem(22px); + border-radius: 50%; + border: 1px solid var(--mantine-color-default-border); + flex-shrink: 0; +} + +.customSwatch { + display: inline-flex; + align-items: center; + justify-content: center; + width: rem(22px); + height: rem(22px); + border-radius: 50%; + border: 1px dashed var(--mantine-color-default-border); + color: var(--mantine-color-dimmed); +} diff --git a/apps/client/src/features/public-space/components/appearance-settings.tsx b/apps/client/src/features/public-space/components/appearance-settings.tsx new file mode 100644 index 000000000..8bf4ea604 --- /dev/null +++ b/apps/client/src/features/public-space/components/appearance-settings.tsx @@ -0,0 +1,165 @@ +import { + ColorInput, + Group, + Text, + Tooltip, + UnstyledButton, +} from "@mantine/core"; +import { IconColorPicker } from "@tabler/icons-react"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + DEFAULT_DOCS_PRESET, + DOCS_THEME_PRESETS, + isValidDocsColor, + matchDocsPreset, +} from "@/features/public-space/theme/docs-theme.ts"; +import { IPublicSpaceAppearance } from "@/features/public-space/types/public-space.types.ts"; +import { usePublishSpaceMutation } from "@/features/public-space/queries/public-space-query.ts"; +import { useHasFeature } from "@/ee/hooks/use-feature.ts"; +import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts"; +import { Feature } from "@/ee/features.ts"; +import classes from "./appearance-settings.module.css"; + +type AppearanceSettingsProps = { + spaceId: string; + appearance?: IPublicSpaceAppearance; +}; + +export default function AppearanceSettings({ + spaceId, + appearance, +}: AppearanceSettingsProps) { + const { t } = useTranslation(); + const publishMutation = usePublishSpaceMutation(); + const hasAppearance = useHasFeature(Feature.PUBLIC_SPACE_APPEARANCE); + const upgradeLabel = useUpgradeLabel(); + + const matchedPreset = matchDocsPreset(appearance); + const [customOpen, setCustomOpen] = useState(matchedPreset === null); + const [customLight, setCustomLight] = useState( + appearance?.primaryColorLight ?? DEFAULT_DOCS_PRESET.light, + ); + const [customDark, setCustomDark] = useState( + appearance?.primaryColorDark ?? DEFAULT_DOCS_PRESET.dark, + ); + + useEffect(() => { + setCustomOpen(matchDocsPreset(appearance) === null); + setCustomLight(appearance?.primaryColorLight ?? DEFAULT_DOCS_PRESET.light); + setCustomDark(appearance?.primaryColorDark ?? DEFAULT_DOCS_PRESET.dark); + }, [appearance?.primaryColorLight, appearance?.primaryColorDark]); + + const saveAppearance = (payload: { + primaryColorLight: string | null; + primaryColorDark: string | null; + }) => { + if (!hasAppearance) return; + publishMutation.mutate({ spaceId, enabled: true, appearance: payload }); + }; + + const selectPreset = (presetId: string) => { + setCustomOpen(false); + const preset = DOCS_THEME_PRESETS.find((item) => item.id === presetId); + if (!preset) return; + if (preset.id === DEFAULT_DOCS_PRESET.id) { + saveAppearance({ primaryColorLight: null, primaryColorDark: null }); + return; + } + saveAppearance({ + primaryColorLight: preset.light, + primaryColorDark: preset.dark, + }); + }; + + const commitCustom = (light: string, dark: string) => { + if (!isValidDocsColor(light) || !isValidDocsColor(dark)) return; + saveAppearance({ primaryColorLight: light, primaryColorDark: dark }); + }; + + const swatches = DOCS_THEME_PRESETS.flatMap((preset) => [ + preset.light, + preset.dark, + ]); + + return ( +
    + + {t("Appearance")} + + + {t("Choose the primary color of the public docs site.")} + + + +
    + {DOCS_THEME_PRESETS.map((preset) => { + const selected = !customOpen && matchedPreset?.id === preset.id; + return ( + selectPreset(preset.id)} + > + + {t(preset.nameKey)} + + ); + })} + + setCustomOpen(true)} + > + + + + {t("Custom")} + +
    +
    + + {customOpen && hasAppearance && ( + + { + setCustomLight(value); + commitCustom(value, customDark); + }} + /> + { + setCustomDark(value); + commitCustom(customLight, value); + }} + /> + + )} +
    + ); +} diff --git a/apps/client/src/features/public-space/components/docs/docs-breadcrumbs.tsx b/apps/client/src/features/public-space/components/docs/docs-breadcrumbs.tsx new file mode 100644 index 000000000..d2d194a40 --- /dev/null +++ b/apps/client/src/features/public-space/components/docs/docs-breadcrumbs.tsx @@ -0,0 +1,114 @@ +import { Menu } from "@mantine/core"; +import { useMediaQuery } from "@mantine/hooks"; +import { Link, useParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { Fragment, useMemo, type ReactNode } from "react"; +import { useDocsSurface } from "@/features/public-space/components/docs/docs-surface-context.tsx"; +import { findAncestorTrail } from "@/features/public-space/utils/docs-tree.ts"; +import { extractPageSlugId } from "@/lib"; +import styles from "./docs.module.css"; + +type Crumb = { + key: string; + name: string; + url: string; +}; + +export default function DocsBreadcrumbs() { + const { t } = useTranslation(); + const { pageSlug } = useParams(); + const { treeData, siteName, homeUrl, getNodeUrl } = useDocsSurface(); + const isMobile = useMediaQuery("(max-width: 48em)"); + + const crumbs = useMemo(() => { + if (!treeData?.length) return null; + + const currentSlugId = pageSlug + ? extractPageSlugId(pageSlug) + : treeData[0]?.slugId; + if (!currentSlugId) return null; + + const trail = findAncestorTrail(treeData, currentSlugId); + if (trail === null) return null; + + const siteCrumbs: Crumb[] = + siteName && homeUrl + ? [{ key: "site", name: siteName, url: homeUrl }] + : []; + + const list = [ + ...siteCrumbs, + ...trail.map((node) => ({ + key: node.slugId, + name: node.name || t("untitled"), + url: getNodeUrl(node), + })), + ]; + return list.length ? list : null; + }, [treeData, siteName, homeUrl, getNodeUrl, pageSlug, t]); + + if (!crumbs) return null; + + // Mobile keeps a single line (menu + last crumb, like the app header's + // breadcrumb); desktop collapses the middle beyond 4 crumbs. + const collapsed = crumbs.length > (isMobile ? 1 : 4); + const hidden = !collapsed + ? [] + : isMobile + ? crumbs.slice(0, crumbs.length - 1) + : crumbs.slice(1, crumbs.length - 1); + + const items: ReactNode[] = []; + if (collapsed && !isMobile) { + items.push( + + {crumbs[0].name} + , + ); + } + if (collapsed) { + items.push( + + + + + + {hidden.map((item) => ( + + {item.name} + + ))} + + , + ); + } + const trailing = collapsed ? [crumbs[crumbs.length - 1]] : crumbs; + for (const crumb of trailing) { + items.push( + + {crumb.name} + , + ); + } + + return ( + + ); +} diff --git a/apps/client/src/features/public-space/components/docs/docs-copy-page.tsx b/apps/client/src/features/public-space/components/docs/docs-copy-page.tsx new file mode 100644 index 000000000..119407dad --- /dev/null +++ b/apps/client/src/features/public-space/components/docs/docs-copy-page.tsx @@ -0,0 +1,45 @@ +import { Button } from "@mantine/core"; +import { useAtomValue } from "jotai"; +import { useTranslation } from "react-i18next"; +import { IconCheck, IconCopy } from "@tabler/icons-react"; +import { htmlToMarkdown } from "@docmost/editor-ext"; +import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts"; +import { useDocsCurrentPage } from "@/features/public-space/hooks/use-docs-current-page.ts"; +import { useClipboard } from "@/hooks/use-clipboard"; +import styles from "./docs.module.css"; + +export default function DocsCopyPage() { + const { t } = useTranslation(); + const editor = useAtomValue(readOnlyEditorAtom); + const page = useDocsCurrentPage(); + const clipboard = useClipboard(); + + if (!editor) { + return null; + } + + const handleCopy = () => { + if (editor.isDestroyed) return; + const markdown = htmlToMarkdown(editor.getHTML()); + const title = page?.name ? `# ${page.name}\n\n` : ""; + clipboard.copy(`${title}${markdown}`); + }; + + return ( + + ); +} diff --git a/apps/client/src/features/public-space/components/docs/docs-edit-page.tsx b/apps/client/src/features/public-space/components/docs/docs-edit-page.tsx new file mode 100644 index 000000000..77ef37c9b --- /dev/null +++ b/apps/client/src/features/public-space/components/docs/docs-edit-page.tsx @@ -0,0 +1,31 @@ +import { Link, useParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { IconPencil } from "@tabler/icons-react"; +import { useAuthenticatedUser } from "@/features/public-space/hooks/use-authenticated-user.ts"; +import { useDocsCurrentPage } from "@/features/public-space/hooks/use-docs-current-page.ts"; +import { buildPageUrl } from "@/features/page/page.utils.ts"; +import styles from "./docs.module.css"; + +export default function DocsEditPage() { + const { t } = useTranslation(); + const { spaceSlug } = useParams(); + const page = useDocsCurrentPage(); + + const { data: currentUser } = useAuthenticatedUser(); + + if (!currentUser?.user || !page) { + return null; + } + + return ( + + + {t("Edit page")} + + ); +} diff --git a/apps/client/src/features/public-space/components/docs/docs-footer-branding.tsx b/apps/client/src/features/public-space/components/docs/docs-footer-branding.tsx new file mode 100644 index 000000000..9e55ebba3 --- /dev/null +++ b/apps/client/src/features/public-space/components/docs/docs-footer-branding.tsx @@ -0,0 +1,23 @@ +import clsx from "clsx"; +import styles from "./docs.module.css"; + +export default function DocsFooterBranding({ + className, + refSource = "public-space", +}: { + className?: string; + refSource?: string; +}) { + return ( +
    + ); +} diff --git a/apps/client/src/features/public-space/components/docs/docs-hub.module.css b/apps/client/src/features/public-space/components/docs/docs-hub.module.css new file mode 100644 index 000000000..53a93362c --- /dev/null +++ b/apps/client/src/features/public-space/components/docs/docs-hub.module.css @@ -0,0 +1,485 @@ +/* Design tokens for the public docs hub, transcribed from the "1a solid brand + * band" direction of the Directory design spec (a fixed light look). Declared + * on :root like --docs-* so cover/brand customization can override them at + * runtime. */ +:root { + --docs-hub-max: 80rem; + --docs-hub-bg: #ffffff; + --docs-hub-fg: #111827; + --docs-hub-muted: #5b6270; + --docs-hub-nav-fg: #4b5563; + --docs-hub-footer-fg: #6b7280; + --docs-hub-border: #eceef2; + --docs-hub-brand: #12275c; + --docs-hub-brand-fg: #ffffff; + --docs-hub-hero-bg: var(--docs-hub-brand); + --docs-hub-hero-fg: #ffffff; + --docs-hub-hero-muted: rgba(255, 255, 255, 0.78); + --docs-hub-search-bg: #ffffff; + --docs-hub-search-fg: #111827; + --docs-hub-search-placeholder: #8a919e; + --docs-hub-search-shadow: 0 12px 32px rgba(0, 0, 0, 0.18); + --docs-hub-card-bg: #ffffff; + --docs-hub-card-border: #e6e7ea; + --docs-hub-card-radius: 14px; + --docs-hub-card-shadow: 0 4px 16px rgba(15, 23, 42, 0.06); + --docs-hub-card-border-hover: #c4c8d0; + --docs-hub-card-shadow-hover: 0 12px 32px rgba(15, 23, 42, 0.14); + --docs-hub-tile-fg: #ffffff; +} + +.root { + min-height: 100dvh; + background-color: var(--docs-hub-bg); + color: var(--docs-hub-fg); +} + +.container { + max-width: var(--docs-hub-max); + margin-inline: auto; +} + +/* ---------- Top bar ---------- */ + +.topBar { + border-bottom: 1px solid var(--docs-hub-border); + padding: 0 rem(48px); + + @media (max-width: $mantine-breakpoint-sm) { + padding: 0 rem(20px); + } +} + +.topBarInner { + height: rem(64px); + display: flex; + align-items: center; + justify-content: space-between; + + @media (max-width: $mantine-breakpoint-sm) { + height: rem(56px); + } +} + +.brand { + display: flex; + align-items: center; + gap: rem(10px); + min-width: 0; + color: var(--docs-hub-fg); + text-decoration: none; + font-size: rem(16px); + font-weight: 600; + border-radius: rem(8px); + + &:focus-visible { + outline: 2px solid var(--docs-hub-brand); + outline-offset: 4px; + } + + @media (max-width: $mantine-breakpoint-sm) { + gap: rem(8px); + font-size: rem(15px); + } +} + +.brandTile { + width: rem(28px); + height: rem(28px); + border-radius: rem(8px); + background-color: var(--docs-hub-brand); + color: var(--docs-hub-brand-fg); + display: grid; + place-items: center; + flex-shrink: 0; + font-weight: 700; + font-size: rem(13px); + + @media (max-width: $mantine-breakpoint-sm) { + width: rem(26px); + height: rem(26px); + border-radius: rem(7px); + font-size: rem(12px); + } +} + +.topActions { + display: flex; + align-items: center; + gap: rem(24px); + font-size: rem(14px); + color: var(--docs-hub-nav-fg); +} + +.signIn { + display: inline-block; + padding: rem(8px) rem(14px); + border-radius: rem(8px); + background-color: var(--docs-hub-brand); + color: var(--docs-hub-brand-fg); + text-decoration: none; + font-weight: 500; + white-space: nowrap; + + @media (hover: hover) { + &:hover { + filter: brightness(1.15); + } + } + + &:focus-visible { + outline: 2px solid var(--docs-hub-brand); + outline-offset: 2px; + } + + @media (max-width: $mantine-breakpoint-sm) { + padding: rem(8px) rem(12px); + font-size: rem(13px); + } +} + +/* ---------- Hero band ---------- */ + +.hero { + background-color: var(--docs-hub-hero-bg); + color: var(--docs-hub-hero-fg); + padding: rem(72px) rem(48px) rem(80px); + + @media (max-width: $mantine-breakpoint-sm) { + padding: rem(40px) rem(20px) rem(44px); + } +} + +.heroInner { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: rem(16px); + + @media (max-width: $mantine-breakpoint-sm) { + gap: rem(10px); + } +} + +.heading { + margin: 0; + color: var(--docs-hub-hero-fg); + font-size: rem(48px); + font-weight: 700; + letter-spacing: -0.02em; + line-height: 1.15; + + @media (max-width: $mantine-breakpoint-sm) { + font-size: rem(30px); + } +} + +.subtitle { + margin: 0; + color: var(--docs-hub-hero-muted); + font-size: rem(18px); + + @media (max-width: $mantine-breakpoint-sm) { + font-size: rem(15px); + line-height: 1.45; + } +} + +.search { + margin-top: rem(16px); + width: rem(640px); + max-width: 100%; + height: rem(56px); + display: flex; + align-items: center; + padding: 0 rem(6px) 0 rem(22px); + border-radius: 999px; + background-color: var(--docs-hub-search-bg); + box-shadow: var(--docs-hub-search-shadow); + + &:focus-within { + outline: 2px solid var(--docs-hub-hero-fg); + outline-offset: 3px; + } + + @media (max-width: $mantine-breakpoint-sm) { + margin-top: rem(10px); + width: 100%; + height: rem(48px); + padding: 0 rem(5px) 0 rem(18px); + box-shadow: none; + } +} + +.searchInput { + flex: 1; + min-width: 0; + height: 100%; + border: 0; + background: transparent; + font: inherit; + font-size: rem(16px); + color: var(--docs-hub-search-fg); + text-align: left; + outline: none; + + &::placeholder { + color: var(--docs-hub-search-placeholder); + } + + @media (max-width: $mantine-breakpoint-sm) { + font-size: rem(15px); + } +} + +.searchButton { + width: rem(44px); + height: rem(44px); + flex-shrink: 0; + border: 0; + border-radius: 999px; + background-color: var(--docs-hub-brand); + color: var(--docs-hub-brand-fg); + display: grid; + place-items: center; + cursor: pointer; + + &:focus-visible { + outline: 2px solid var(--docs-hub-search-fg); + outline-offset: 2px; + } + + @media (max-width: $mantine-breakpoint-sm) { + width: rem(38px); + height: rem(38px); + } +} + +/* ---------- Spaces ---------- */ + +.main { + padding: rem(48px) rem(48px) rem(56px); + + @media (max-width: $mantine-breakpoint-sm) { + padding: rem(24px) rem(20px) rem(28px); + } +} + +.mainInner { + display: flex; + flex-direction: column; + gap: rem(24px); + + @media (max-width: $mantine-breakpoint-sm) { + gap: rem(16px); + } +} + +.sectionHeader { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: rem(16px); + border-bottom: 1px solid var(--docs-hub-border); + padding-bottom: rem(14px); + + @media (max-width: $mantine-breakpoint-sm) { + padding-bottom: rem(10px); + } +} + +.sectionTitle { + margin: 0; + font-size: rem(13px); + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--docs-hub-fg); + + @media (max-width: $mantine-breakpoint-sm) { + font-size: rem(12px); + } +} + +.sectionCount { + font-size: rem(14px); + color: var(--docs-hub-footer-fg); + + @media (max-width: $mantine-breakpoint-sm) { + font-size: rem(13px); + } +} + +/* Phones flow one card per row; wider viewports fit two, then 3/4 columns. */ +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(rem(220px), 1fr)); + gap: rem(12px); + + @media (min-width: $mantine-breakpoint-sm) { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: rem(20px); + } + + @media (min-width: 64em) { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} + +.card { + display: flex; + flex-direction: column; + gap: rem(14px); + padding: rem(24px) rem(24px) rem(26px); + background-color: var(--docs-hub-card-bg); + border: 1px solid var(--docs-hub-card-border); + border-radius: var(--docs-hub-card-radius); + box-shadow: var(--docs-hub-card-shadow); + color: inherit; + text-decoration: none; + transition: + border-color 120ms ease, + box-shadow 160ms ease; + + @media (hover: hover) { + &:hover { + border-color: var(--docs-hub-card-border-hover); + box-shadow: var(--docs-hub-card-shadow-hover); + } + } + + &:focus-visible { + outline: 2px solid var(--docs-hub-brand); + outline-offset: 2px; + } + + @media (max-width: $mantine-breakpoint-sm) { + gap: rem(12px); + padding: rem(18px) rem(18px) rem(20px); + } +} + +/* Tile and title share a row so titles get the card's full width. */ +.cardHeader { + display: flex; + align-items: center; + gap: rem(14px); + min-width: 0; + + @media (max-width: $mantine-breakpoint-sm) { + gap: rem(12px); + } +} + +.cardTile { + width: rem(40px); + height: rem(40px); + flex-shrink: 0; + border-radius: rem(10px); + display: grid; + place-items: center; + overflow: hidden; + color: var(--docs-hub-tile-fg); + font-weight: 700; + font-size: rem(15px); + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + @media (max-width: $mantine-breakpoint-sm) { + width: rem(36px); + height: rem(36px); + border-radius: rem(9px); + font-size: rem(14px); + } +} + +.cardName { + min-width: 0; + font-size: rem(18px); + font-weight: 600; + line-height: 1.3; + color: var(--docs-hub-fg); + text-wrap: balance; + + @media (max-width: $mantine-breakpoint-sm) { + font-size: rem(16px); + } +} + +.cardDescription { + font-size: rem(15px); + line-height: 1.5; + color: var(--docs-hub-muted); + text-wrap: pretty; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + + @media (max-width: $mantine-breakpoint-sm) { + font-size: rem(14px); + } +} + +.empty { + margin: 0; + padding: rem(48px) 0; + text-align: center; + color: var(--docs-hub-muted); +} + +/* ---------- Footer ---------- */ + +.footer { + border-top: 1px solid var(--docs-hub-border); + padding: rem(24px) rem(48px); + font-size: rem(14px); + color: var(--docs-hub-footer-fg); + + @media (max-width: $mantine-breakpoint-sm) { + padding: rem(20px); + font-size: rem(13px); + } +} + +.footerInner { + display: flex; + align-items: center; + justify-content: flex-end; + + @media (max-width: $mantine-breakpoint-sm) { + flex-direction: column; + align-items: center; + justify-content: center; + gap: rem(12px); + } +} + +.footerBranding { + color: var(--docs-hub-fg); + font-weight: 600; + text-decoration: none; + + @media (hover: hover) { + &:hover { + color: var(--docs-hub-brand); + } + } + + &:focus-visible { + outline: 2px solid var(--docs-hub-brand); + outline-offset: 2px; + border-radius: rem(2px); + } +} + +@media (prefers-reduced-motion: reduce) { + .card { + transition: none; + } +} diff --git a/apps/client/src/features/public-space/components/docs/docs-page-nav.tsx b/apps/client/src/features/public-space/components/docs/docs-page-nav.tsx new file mode 100644 index 000000000..f98d3ee93 --- /dev/null +++ b/apps/client/src/features/public-space/components/docs/docs-page-nav.tsx @@ -0,0 +1,70 @@ +import { useMemo } from "react"; +import { Link, useParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { IconArrowLeft, IconArrowRight } from "@tabler/icons-react"; +import { useDocsSurface } from "@/features/public-space/components/docs/docs-surface-context.tsx"; +import { flattenTreePreorder } from "@/features/public-space/utils/docs-tree.ts"; +import { extractPageSlugId } from "@/lib"; +import { SharedPageTreeNode } from "@/features/share/utils.ts"; +import styles from "./docs.module.css"; + +export default function DocsPageNav() { + const { t } = useTranslation(); + const { pageSlug } = useParams(); + const { treeData, getNodeUrl } = useDocsSurface(); + + const { prev, next } = useMemo(() => { + if (!treeData?.length) { + return { + prev: null as SharedPageTreeNode | null, + next: null as SharedPageTreeNode | null, + }; + } + const flat = flattenTreePreorder(treeData); + const currentSlugId = pageSlug + ? extractPageSlugId(pageSlug) + : treeData[0]?.slugId; + const index = flat.findIndex((node) => node.slugId === currentSlugId); + return { + prev: index > 0 ? flat[index - 1] : null, + next: index >= 0 && index < flat.length - 1 ? flat[index + 1] : null, + }; + }, [treeData, pageSlug]); + + if (!prev && !next) return null; + + return ( + + ); +} diff --git a/apps/client/src/features/public-space/components/docs/docs-search-button.tsx b/apps/client/src/features/public-space/components/docs/docs-search-button.tsx new file mode 100644 index 000000000..20fc8e778 --- /dev/null +++ b/apps/client/src/features/public-space/components/docs/docs-search-button.tsx @@ -0,0 +1,22 @@ +import { IconSearch } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { platformModifierLabel } from "@/lib"; +import styles from "./docs.module.css"; + +type DocsSearchButtonProps = { + onClick: () => void; +}; + +export default function DocsSearchButton({ onClick }: DocsSearchButtonProps) { + const { t } = useTranslation(); + + return ( + + ); +} diff --git a/apps/client/src/features/public-space/components/docs/docs-shell.tsx b/apps/client/src/features/public-space/components/docs/docs-shell.tsx new file mode 100644 index 000000000..1b630644f --- /dev/null +++ b/apps/client/src/features/public-space/components/docs/docs-shell.tsx @@ -0,0 +1,182 @@ +import React from "react"; +import { ActionIcon, Drawer, Tooltip } from "@mantine/core"; +import { Link } from "react-router-dom"; +import { useAtom } from "jotai"; +import { useTranslation } from "react-i18next"; +import { IconList, IconMenu2 } from "@tabler/icons-react"; +import clsx from "clsx"; +import { + docsMobileSidebarAtom, + docsMobileTocAtom, +} from "@/features/public-space/atoms/public-space-atoms.ts"; +import { + DocsSurface, + DocsSurfaceProvider, +} from "@/features/public-space/components/docs/docs-surface-context.tsx"; +import DocsSidebarTree from "@/features/public-space/components/docs/docs-sidebar-tree.tsx"; +import DocsToc from "@/features/public-space/components/docs/docs-toc.tsx"; +import DocsEditPage from "@/features/public-space/components/docs/docs-edit-page.tsx"; +import DocsCopyPage from "@/features/public-space/components/docs/docs-copy-page.tsx"; +import DocsSearchButton from "@/features/public-space/components/docs/docs-search-button.tsx"; +import DocsThemeToggle from "@/features/public-space/components/docs/docs-theme-toggle.tsx"; +import DocsFooterBranding from "@/features/public-space/components/docs/docs-footer-branding.tsx"; +import { MAIN_CONTENT_ID, SkipToMain } from "@/components/ui/skip-to-main.tsx"; +import { SearchMobileControl } from "@/features/search/components/search-control.tsx"; +import styles from "./docs.module.css"; + +const MemoizedDocsSidebarTree = React.memo(DocsSidebarTree); + +type DocsShellProps = { + surface: DocsSurface; + onSearchOpen?: () => void; + searchSpotlight?: React.ReactNode; + children: React.ReactNode; +}; + +export default function DocsShell({ + surface, + onSearchOpen, + searchSpotlight, + children, +}: DocsShellProps) { + const { t } = useTranslation(); + const { hasSidebar, siteName, homeUrl, showBranding, showEditPage } = surface; + + const [mobileSidebarOpen, setMobileSidebarOpen] = useAtom( + docsMobileSidebarAtom, + ); + const [mobileTocOpen, setMobileTocOpen] = useAtom(docsMobileTocAtom); + + return ( + +
    + + +
    +
    +
    + {hasSidebar && ( + + setMobileSidebarOpen((value) => !value)} + aria-label={t("Toggle sidebar")} + aria-expanded={mobileSidebarOpen} + > + + + + )} + + {!hasSidebar && siteName && homeUrl && ( + + {siteName} + + )} +
    + +
    + {onSearchOpen && ( +
    + +
    + )} +
    + +
    + {onSearchOpen && ( + + + + )} + + +
    +
    +
    + +
    + + +
    +
    +
    + + + + setMobileTocOpen(true)} + size="md" + aria-label={t("Table of contents")} + > + + + + +
    + {children} + {showBranding && ( + + )} +
    +
    + + +
    + + setMobileSidebarOpen(false)} + title={siteName} + size={300} + padding="sm" + > +
    + {hasSidebar && } +
    +
    + + setMobileTocOpen(false)} + position="right" + size={300} + padding="md" + > + + {showEditPage && } + + + {searchSpotlight} +
    +
    + ); +} diff --git a/apps/client/src/features/public-space/components/docs/docs-sidebar-tree.tsx b/apps/client/src/features/public-space/components/docs/docs-sidebar-tree.tsx new file mode 100644 index 000000000..183fae8e9 --- /dev/null +++ b/apps/client/src/features/public-space/components/docs/docs-sidebar-tree.tsx @@ -0,0 +1,198 @@ +import { SharedPageTreeNode } from "@/features/share/utils.ts"; +import React, { useCallback, useEffect, useMemo, useRef } from "react"; +import { Link, useParams } from "react-router-dom"; +import { useAtom, useSetAtom } from "jotai"; +import { useTranslation } from "react-i18next"; +import { IconChevronRight } from "@tabler/icons-react"; +import { ActionIcon } from "@mantine/core"; +import { extractPageSlugId } from "@/lib"; +import { + DocTree, + type DocTreeApi, + type RenderRowProps, +} from "@/features/page/tree/components/doc-tree"; +import { + docsMobileSidebarAtom, + openPublicSpaceTreeNodesAtom, +} from "@/features/public-space/atoms/public-space-atoms.ts"; +import { useDocsSurface } from "@/features/public-space/components/docs/docs-surface-context.tsx"; +import { findAncestorTrail } from "@/features/public-space/utils/docs-tree.ts"; +import styles from "./docs.module.css"; + +export default function DocsSidebarTree() { + const { t } = useTranslation(); + const treeRef = useRef(null); + const { pageSlug } = useParams(); + const { treeData, getNodeUrl } = useDocsSurface(); + const [openTreeNodes, setOpenTreeNodes] = useAtom( + openPublicSpaceTreeNodesAtom, + ); + + // The first root page is the surface home, served at the bare URL. + const firstRootSlugId = treeData?.[0]?.slugId; + + const currentNodeId = pageSlug ? extractPageSlugId(pageSlug) : firstRootSlugId; + + const openIds = useMemo( + () => new Set(Object.keys(openTreeNodes).filter((k) => openTreeNodes[k])), + [openTreeNodes], + ); + + useEffect(() => { + // Auto-open the first level of the tree on initial load. + const root = treeData?.[0]; + if (!root) return; + setOpenTreeNodes((prev) => { + if (prev[root.slugId]) return prev; + const next = { ...prev, [root.slugId]: true }; + for (const child of root.children ?? []) { + next[child.slugId] = true; + } + return next; + }); + }, [treeData, setOpenTreeNodes]); + + // Reveal the current page: expand its ancestor trail (deep links land with + // everything collapsed otherwise) and the page itself when it has children. + useEffect(() => { + if (!currentNodeId || !treeData?.length) return; + const trail = findAncestorTrail(treeData, currentNodeId); + if (trail === null) return; + setOpenTreeNodes((prev) => { + const next = { ...prev }; + let changed = false; + for (const node of [...trail.map((n) => n.slugId), currentNodeId]) { + if (!next[node]) { + next[node] = true; + changed = true; + } + } + return changed ? next : prev; + }); + }, [currentNodeId, treeData, setOpenTreeNodes]); + + useEffect(() => { + if (currentNodeId) { + treeRef.current?.select(currentNodeId, { scrollIntoView: true }); + } + }, [currentNodeId, treeData]); + + const handleToggle = useCallback( + (id: string, isOpen: boolean) => + setOpenTreeNodes((prev) => ({ ...prev, [id]: isOpen })), + [setOpenTreeNodes], + ); + const getDragLabel = useCallback( + (n: SharedPageTreeNode) => n.name || "untitled", + [], + ); + + const renderRow = useCallback( + (props: RenderRowProps) => ( + + ), + [getNodeUrl], + ); + + if (!treeData?.length) { + return null; + } + + return ( + + readOnly + ref={treeRef} + data={treeData} + openIds={openIds} + selectedId={currentNodeId} + renderRow={renderRow} + indentPerLevel={INDENT_PER_LEVEL} + rowHeight={36} + dynamicRowHeight + rowClassName={styles.treeNodeChrome} + onMove={noopMove} + onToggle={handleToggle} + getDragLabel={getDragLabel} + aria-label={t("Pages")} + /> + ); +} + +// Module-scope noop so it's a stable reference across renders. +const noopMove = () => {}; + +const INDENT_PER_LEVEL = 16; + + +type DocsTreeRowProps = RenderRowProps & { + getNodeUrl: (node: Pick) => string; +}; + +function DocsTreeRow({ + node, + level, + isOpen, + hasChildren, + isSelected, + rowRef, + tabIndex, + treeItemProps, + toggleOpen, + getNodeUrl, +}: DocsTreeRowProps) { + const { t } = useTranslation(); + const setMobileSidebarOpen = useSetAtom(docsMobileSidebarAtom); + + return ( + } + tabIndex={tabIndex} + {...treeItemProps} + data-selected={isSelected || undefined} + data-open-parent={(level === 0 && isOpen && hasChildren) || undefined} + className={styles.treeRow} + to={getNodeUrl(node)} + onClick={() => { + setMobileSidebarOpen(false); + }} + > + {/* One segment per ancestor level; contiguous rows join into a rail. */} + {Array.from({ length: level }, (_, ancestor) => ( + + ))} + {node.icon && ( + + {node.icon} + + )} + {node.name || t("untitled")} + {hasChildren && ( + { + e.preventDefault(); + e.stopPropagation(); + toggleOpen(); + }} + > + + + )} + + ); +} diff --git a/apps/client/src/features/public-space/components/docs/docs-surface-context.tsx b/apps/client/src/features/public-space/components/docs/docs-surface-context.tsx new file mode 100644 index 000000000..db5c23651 --- /dev/null +++ b/apps/client/src/features/public-space/components/docs/docs-surface-context.tsx @@ -0,0 +1,27 @@ +import { createContext, useContext } from "react"; +import { SharedPageTreeNode } from "@/features/share/utils.ts"; + +// What varies between the public surfaces (/docs and /share) rendered by the +// docs shell; every shell component reads this contract instead of a feature. +export type DocsSurface = { + treeData: SharedPageTreeNode[] | null; + hasSidebar: boolean; + siteName?: string; + homeUrl?: string; + getNodeUrl: (node: Pick) => string; + showBranding: boolean; + showEditPage: boolean; + brandingRef?: string; +}; + +const DocsSurfaceContext = createContext(null); + +export const DocsSurfaceProvider = DocsSurfaceContext.Provider; + +export function useDocsSurface(): DocsSurface { + const surface = useContext(DocsSurfaceContext); + if (!surface) { + throw new Error("useDocsSurface must be used within DocsShell"); + } + return surface; +} diff --git a/apps/client/src/features/public-space/components/docs/docs-theme-toggle.tsx b/apps/client/src/features/public-space/components/docs/docs-theme-toggle.tsx new file mode 100644 index 000000000..fdc8d6523 --- /dev/null +++ b/apps/client/src/features/public-space/components/docs/docs-theme-toggle.tsx @@ -0,0 +1,35 @@ +import { + ActionIcon, + Tooltip, + useComputedColorScheme, + useMantineColorScheme, +} from "@mantine/core"; +import { IconMoon, IconSun } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import styles from "./docs.module.css"; + +export default function DocsThemeToggle() { + const { t } = useTranslation(); + const { setColorScheme } = useMantineColorScheme(); + const computedColorScheme = useComputedColorScheme("light"); + + return ( + + + setColorScheme(computedColorScheme === "light" ? "dark" : "light") + } + aria-label={t("Toggle color scheme")} + > + {computedColorScheme === "light" ? ( + + ) : ( + + )} + + + ); +} diff --git a/apps/client/src/features/public-space/components/docs/docs-toc.tsx b/apps/client/src/features/public-space/components/docs/docs-toc.tsx new file mode 100644 index 000000000..ceb43641f --- /dev/null +++ b/apps/client/src/features/public-space/components/docs/docs-toc.tsx @@ -0,0 +1,116 @@ +import { useCallback, useEffect, useState } from "react"; +import { TextSelection } from "@tiptap/pm/state"; +import { useAtomValue } from "jotai"; +import { useTranslation } from "react-i18next"; +import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts"; +import { + HeadingLink, + recalculateLinks, +} from "@/features/editor/components/table-of-contents/table-of-contents.tsx"; +import styles from "./docs.module.css"; + +function getHeaderOffset(): number { + const raw = getComputedStyle(document.documentElement).getPropertyValue( + "--docs-header-h", + ); + const parsed = parseInt(raw, 10); + return Number.isNaN(parsed) ? 56 : parsed; +} + +export default function DocsToc() { + const { t } = useTranslation(); + const editor = useAtomValue(readOnlyEditorAtom); + const [links, setLinks] = useState([]); + const [headingDOMNodes, setHeadingDOMNodes] = useState([]); + const [activeElement, setActiveElement] = useState(null); + + const handleUpdate = useCallback(() => { + if (!editor || editor.isDestroyed) return; + const result = recalculateLinks(editor.$nodes("heading")); + setLinks(result.links); + setHeadingDOMNodes(result.nodes); + }, [editor]); + + useEffect(() => { + // "create" repopulates once the editor view mounts after this component. + editor?.on("create", handleUpdate); + editor?.on("update", handleUpdate); + handleUpdate(); + + return () => { + editor?.off("create", handleUpdate); + editor?.off("update", handleUpdate); + }; + }, [editor, handleUpdate]); + + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + setActiveElement(entry.target as HTMLElement); + } + }); + }, + { + rootMargin: `-${getHeaderOffset()}px 0px -85% 0px`, + threshold: 0, + root: null, + }, + ); + + headingDOMNodes.forEach((heading) => observer.observe(heading)); + return () => { + headingDOMNodes.forEach((heading) => observer.unobserve(heading)); + }; + }, [headingDOMNodes]); + + const handleScrollToHeading = (position: number) => { + if (!editor || editor.isDestroyed) return; + const { view } = editor; + + const { node } = view.domAtPos(position); + const element = node as HTMLElement; + const scrollPosition = + element.getBoundingClientRect().top + + window.scrollY - + getHeaderOffset() - + 16; + + window.scrollTo({ top: scrollPosition, behavior: "smooth" }); + + const tr = view.state.tr; + tr.setSelection(new TextSelection(tr.doc.resolve(position))); + view.dispatch(tr); + view.focus(); + }; + + if (!links.length) { + return null; + } + + const minLevel = Math.min(...links.map((link) => link.level)); + const effectiveActive = activeElement ?? links[0]?.element; + + return ( +
    + {t("On this page")} +
    + {links.map((item, idx) => ( + + ))} +
    +
    + ); +} diff --git a/apps/client/src/features/public-space/components/docs/docs.module.css b/apps/client/src/features/public-space/components/docs/docs.module.css new file mode 100644 index 000000000..6f008a429 --- /dev/null +++ b/apps/client/src/features/public-space/components/docs/docs.module.css @@ -0,0 +1,902 @@ +/* Design tokens for the public docs surface. Declared on :root (not .root) + * because mobile drawers and the spotlight render in portals outside the shell + * subtree. The accent pair is overridden at runtime by docs-theme.ts. */ +/* light-dark() cannot live in a bare :root block (the transform emits a + * descendant selector that never matches :root), so dark values get their own + * attribute-qualified block; flipping semantics ride Mantine's own vars. */ +:root { + --docs-header-h: 56px; + --docs-sidebar-w: 280px; + --docs-toc-w: 240px; + --docs-site-max: 96rem; + --docs-content-max: 54rem; + --docs-radius: 6px; + + --docs-accent: #2b7af1; + --docs-accent-soft: color-mix(in srgb, var(--docs-accent) 10%, transparent); + + /* Cloudflare-style single-ink model: one foreground for headings, bold, and + * body on a just-off-white page; neither end of the scale is pure. */ + --docs-bg: oklch(99% 0 0); + --docs-fg: oklch(21% 0 0); + --docs-content-fg: var(--docs-fg); + --docs-nav-fg: oklch(47% 0 0); + --docs-muted: var(--mantine-color-dimmed); + --docs-hover: var(--mantine-color-default-hover); + --docs-faint: var(--mantine-color-gray-6); + --docs-border: var(--mantine-color-gray-2); + --docs-header-bg: color-mix(in srgb, var(--docs-bg) 78%, transparent); +} + +:root[data-mantine-color-scheme="dark"] { + --docs-bg: var(--mantine-color-body); + --docs-fg: oklch(90% 0 0); + --docs-nav-fg: oklch(72% 0 0); + --docs-faint: var(--mantine-color-dark-2); + --docs-border: var(--mantine-color-dark-5); +} + +.root { + min-height: 100dvh; + background-color: var(--docs-bg); + color: var(--docs-fg); +} + +/* ---------- Header ---------- */ + +.header { + position: sticky; + top: 0; + z-index: 90; + height: var(--docs-header-h); + padding-inline: rem(20px); + background-color: var(--docs-header-bg); + backdrop-filter: saturate(180%) blur(10px); + -webkit-backdrop-filter: saturate(180%) blur(10px); + border-bottom: 1px solid var(--docs-border); +} + +.headerInner { + max-width: var(--docs-site-max); + margin-inline: auto; + height: 100%; + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: rem(16px); +} + +.headerLeft { + display: flex; + align-items: center; + gap: rem(10px); + min-width: 0; +} + +.headerCenter { + display: flex; + justify-content: center; + min-width: 0; +} + +.headerRight { + display: flex; + align-items: center; + justify-content: flex-end; + gap: rem(4px); +} + +.searchSlot { + @media (max-width: $mantine-breakpoint-sm) { + display: none; + } +} + +.mobileOnly { + display: inline-flex; + + @media (min-width: $mantine-breakpoint-sm) { + display: none; + } +} + +/* Plain space name in the header, used only when there is no sidebar to + * carry it. The brand slot is reserved for future org logo/name support. */ +.headerSpaceName { + font-size: rem(14.5px); + font-weight: 600; + letter-spacing: -0.011em; + color: var(--docs-fg); + text-decoration: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border-radius: var(--docs-radius); + padding: rem(4px) rem(6px); + + &:focus-visible { + outline: 2px solid var(--docs-accent); + outline-offset: 1px; + } +} + +/* Burger appears only once the sidebar column is collapsed. */ +@media (min-width: 64em) { + .sidebarToggle { + display: none !important; + } +} + +.headerAction { + color: var(--docs-faint); + border-radius: var(--docs-radius); + + @media (hover: hover) { + &:hover { + color: var(--docs-fg); + background-color: var(--docs-hover); + } + } +} + +/* ---------- Search ---------- */ + +.searchButton { + display: flex; + align-items: center; + gap: rem(8px); + width: rem(320px); + height: rem(34px); + padding-inline: rem(10px); + border: 1px solid var(--docs-border); + border-radius: rem(8px); + background-color: var(--docs-bg); + color: var(--docs-muted); + font-size: rem(13px); + cursor: pointer; + transition: border-color 120ms ease; + + @media (hover: hover) { + &:hover { + border-color: light-dark( + var(--mantine-color-gray-4), + var(--mantine-color-dark-3) + ); + } + } + + &:focus-visible { + outline: 2px solid var(--docs-accent); + outline-offset: 1px; + } + + @media (max-width: 62em) { + width: rem(220px); + } +} + +.searchLabel { + flex: 1; + text-align: left; +} + +.searchKbd { + font-size: rem(11px); + font-weight: 500; + color: var(--docs-faint); + border: 1px solid var(--docs-border); + border-radius: rem(4px); + padding: rem(1px) rem(5px); + line-height: 1.4; +} + +/* Quiet bordered chip in the docs palette instead of Mantine's full-ink default. */ +.copyPageButton { + color: var(--docs-muted); + border-color: var(--docs-border); + background-color: transparent; + font-size: rem(13px); + font-weight: 500; + border-radius: var(--docs-radius); + + @media (hover: hover) { + &:hover { + color: var(--docs-fg); + background-color: var(--docs-hover); + } + } +} + +/* Page actions pinned to the article's top-right corner; on small viewports + * they drop into flow above the content so breadcrumbs never run under them. */ +.articleActions { + position: absolute; + top: rem(30px); + right: rem(24px); + display: inline-flex; + align-items: center; + gap: rem(6px); + + @media (max-width: $mantine-breakpoint-sm) { + position: static; + display: flex; + justify-content: flex-end; + margin-bottom: rem(4px); + } +} + +/* Below the rail breakpoint the toc opens as a drawer overlay instead. */ +.tocOverlayControl { + display: inline-flex; + + @media (min-width: 75em) { + display: none; + } +} + +/* ---------- Body grid ---------- */ + +/* Contained site layout: rails anchor the edges of a centered max-width + * container, the article centers itself in the fixed middle track. Hiding a + * rail keeps its track, so toggling never moves the content column. */ +.body { + max-width: var(--docs-site-max); + margin-inline: auto; + display: grid; + grid-template-columns: + var(--docs-sidebar-w) + minmax(0, 1fr) + var(--docs-toc-w); + align-items: start; +} + +.sidebar { + grid-column: 1; + width: var(--docs-sidebar-w); + position: sticky; + top: var(--docs-header-h); + height: calc(100dvh - var(--docs-header-h)); + display: flex; + flex-direction: column; + padding: rem(20px) rem(10px) rem(16px) rem(20px); + opacity: 1; + transform: translateX(0); + transition: + opacity 160ms ease, + transform 160ms ease; +} + +.sidebar[data-hidden="true"] { + visibility: hidden; + opacity: 0; + transform: translateX(rem(-8px)); +} + +.sidebarTitle { + display: block; + font-size: rem(14px); + font-weight: 600; + letter-spacing: -0.011em; + color: var(--docs-fg); + text-decoration: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border-radius: var(--docs-radius); + padding: rem(5px) rem(8px); + + @media (hover: hover) { + &:hover { + color: var(--docs-accent); + } + } + + &:focus-visible { + outline: 2px solid var(--docs-accent); + outline-offset: -2px; + } +} + +.sidebarDivider { + height: 1px; + background-color: var(--docs-border); + margin: rem(10px) 0 rem(14px); + flex-shrink: 0; +} + +.sidebarScroll { + flex: 1; + min-height: 0; +} + +.main { + grid-column: 2; + min-width: 0; +} + +.article { + max-width: var(--docs-content-max); + margin-inline: auto; + padding: rem(36px) rem(32px) rem(72px); + position: relative; +} + +.toc { + grid-column: 3; + width: var(--docs-toc-w); + position: sticky; + top: var(--docs-header-h); + max-height: calc(100dvh - var(--docs-header-h)); + overflow-y: auto; + scrollbar-width: thin; + padding: rem(36px) rem(16px) rem(24px) rem(4px); +} + +@media (max-width: 75em) { + .body { + grid-template-columns: var(--docs-sidebar-w) minmax(0, 1fr); + } + + .toc { + display: none; + } +} + +/* Below 64em the sidebar collapses into the burger drawer. */ +@media (max-width: 64em) { + .body { + display: block; + } + + .sidebar { + display: none; + } +} + +@media (max-width: $mantine-breakpoint-sm) { + .article { + padding: rem(24px) rem(20px) rem(56px); + } +} + +@media (prefers-reduced-motion: reduce) { + .body, + .sidebar, + .toc, + .searchButton { + transition: none; + } +} + +/* ---------- Sidebar tree ---------- */ + +/* Applied through DocTree's rowClassName onto the engine wrapper that carries + * data-selected. Class doubled to outrank the engine's own module styles. */ +.treeNodeChrome.treeNodeChrome { + border-radius: var(--docs-radius); + color: var(--docs-nav-fg); + + @media (hover: hover) { + &:hover { + background-color: var(--docs-hover); + color: var(--docs-fg); + } + } +} + +.treeNodeChrome.treeNodeChrome[data-selected="true"] { + background-color: var(--docs-accent-soft); +} + +.treeRow { + position: relative; + display: flex; + align-items: flex-start; + gap: rem(8px); + width: 100%; + min-width: 0; + min-height: rem(32px); + padding: rem(6px) rem(4px) rem(6px) rem(8px); + text-decoration: none; + color: inherit; + font-size: rem(14px); + font-weight: 400; + line-height: 1.45; + border-radius: var(--docs-radius); + + &:focus-visible { + outline: 2px solid var(--docs-accent); + outline-offset: -2px; + } +} + +/* Expanded parents read as section headers, Cloudflare-style. */ +.treeRow[data-open-parent="true"] { + color: var(--docs-fg); + font-weight: 500; +} + +.treeRow[data-selected="true"] { + color: var(--docs-accent); + font-weight: 500; +} + +.treeIcon { + display: inline-flex; + align-items: center; + justify-content: center; + width: rem(18px); + font-size: rem(14px); + line-height: 1; + flex-shrink: 0; + margin-top: rem(2px); +} + +/* Names wrap instead of truncating: the sidebar has a fixed width, so an + * ellipsis would permanently hide the tail of long titles. */ +.treeText { + flex: 1; + min-width: 0; + overflow-wrap: anywhere; +} + +/* Nesting rail: each row draws its ancestors' segments in the indent gutter; + * the -2px bleed covers the engine's row padding so segments connect. */ +.treeGuide { + position: absolute; + top: 0; + bottom: rem(-2px); + width: 1px; + background-color: var(--docs-border); + pointer-events: none; +} + +.treeChevron { + flex-shrink: 0; + color: var(--docs-faint); + transition: transform 140ms ease; +} + +.treeChevron[data-open="true"] { + transform: rotate(90deg); +} + +@media (prefers-reduced-motion: reduce) { + .treeChevron { + transition: none; + } +} + +/* ---------- Table of contents ---------- */ + +.tocLabel { + display: block; + font-size: rem(11px); + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--docs-muted); + margin-bottom: rem(10px); +} + +.tocList { + border-left: 1px solid var(--docs-border); +} + +.tocLink { + display: block; + width: 100%; + text-align: left; + background: none; + border: 0; + border-left: 2px solid transparent; + margin-left: -1px; + padding: rem(4px) rem(8px) rem(4px) rem(11px); + font-size: rem(13px); + line-height: 1.45; + color: var(--docs-muted); + cursor: pointer; + overflow-wrap: break-word; + + @media (hover: hover) { + &:hover { + color: var(--docs-fg); + } + } + + &:focus-visible { + outline: 2px solid var(--docs-accent); + outline-offset: -2px; + border-radius: rem(2px); + } +} + +.tocLink[data-active="true"] { + color: var(--docs-accent); + border-left-color: var(--docs-accent); + font-weight: 500; +} + +/* ---------- Edit page (signed-in visitors) ---------- */ + +.editPageLink { + display: flex; + align-items: center; + gap: rem(6px); + margin-top: rem(16px); + padding-top: rem(14px); + border-top: 1px solid var(--docs-border); + font-size: rem(13px); + color: var(--docs-muted); + text-decoration: none; + + @media (hover: hover) { + &:hover { + color: var(--docs-fg); + } + } + + &:focus-visible { + outline: 2px solid var(--docs-accent); + outline-offset: 2px; + border-radius: rem(2px); + } +} + +/* ---------- Sidebar branding experiments (GitBook card / ReadMe line) ---------- */ + +/* ---------- Footer branding ---------- */ + +.footer { + margin-top: rem(40px); + padding-top: rem(16px); + border-top: 1px solid var(--docs-border); +} + +.footerBranding { + font-size: rem(14px); + color: var(--docs-muted); + text-decoration: none; + + @media (hover: hover) { + &:hover { + color: var(--docs-fg); + } + } + + &:focus-visible { + outline: 2px solid var(--docs-accent); + outline-offset: 2px; + border-radius: rem(2px); + } +} + +/* ---------- Breadcrumbs ---------- */ + +.breadcrumbs { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: rem(4px); + font-size: rem(13px); + color: var(--docs-muted); + margin-bottom: rem(6px); + /* keep long trails clear of the pinned page actions */ + padding-right: rem(160px); + + @media (max-width: $mantine-breakpoint-sm) { + padding-right: 0; + } +} + +.crumbLink { + color: var(--docs-muted); + text-decoration: none; + border-radius: rem(4px); + padding: rem(1px) rem(3px); + max-width: rem(220px); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + @media (hover: hover) { + &:hover { + color: var(--docs-accent); + } + } + + &:focus-visible { + outline: 2px solid var(--docs-accent); + outline-offset: 0; + } +} + +.crumbSeparator { + color: light-dark( + var(--mantine-color-gray-4), + var(--mantine-color-dark-3) + ); + user-select: none; +} + +.crumbEllipsis { + color: var(--docs-muted); + border: 0; + background: none; + cursor: pointer; + border-radius: rem(4px); + padding: rem(1px) rem(4px); + + @media (hover: hover) { + &:hover { + color: var(--docs-accent); + background-color: var(--docs-hover); + } + } + + &:focus-visible { + outline: 2px solid var(--docs-accent); + outline-offset: 0; + } +} + +/* ---------- Byline ---------- */ + +/* Pulled up into the title's own bottom margin so it reads as one block. */ +.byline { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: rem(8px); + margin-top: rem(-16px); + margin-bottom: rem(28px); + font-size: rem(13px); + color: var(--docs-muted); +} + +.bylineAuthor { + display: inline-flex; + align-items: center; + gap: rem(6px); +} + +.bylineDot { + color: var(--docs-faint); + user-select: none; +} + +/* ---------- Prev / next ---------- */ + +.pageNav { + display: grid; + grid-template-columns: 1fr 1fr; + gap: rem(12px); + margin-top: rem(48px); +} + +.pageNavCard { + display: flex; + flex-direction: column; + gap: rem(4px); + padding: rem(12px) rem(16px); + border: 1px solid var(--docs-border); + border-radius: rem(10px); + text-decoration: none; + min-width: 0; + transition: border-color 120ms ease; + + @media (hover: hover) { + &:hover { + border-color: var(--docs-accent); + } + + &:hover .pageNavTitle { + color: var(--docs-accent); + } + } + + &:focus-visible { + outline: 2px solid var(--docs-accent); + outline-offset: 1px; + } +} + +.pageNavCard[data-direction="next"] { + grid-column: 2; + align-items: flex-end; + text-align: right; +} + +.pageNavLabel { + display: inline-flex; + align-items: center; + gap: rem(4px); + font-size: rem(12px); + color: var(--docs-muted); +} + +.pageNavTitle { + font-size: rem(14px); + font-weight: 500; + color: var(--docs-fg); + max-width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transition: color 120ms ease; +} + +@media (max-width: $mantine-breakpoint-sm) { + .pageNav { + grid-template-columns: 1fr; + } + + .pageNavCard[data-direction="next"] { + grid-column: auto; + } +} + +/* ---------- Content ---------- */ + +/* Reading typography: body copy softens to a blue-gray with relaxed leading + * while headings and bold keep full contrast. Class doubled to outrank the + * shared .public-typography metrics regardless of stylesheet order. */ +.root.root :global(.ProseMirror) { + color: var(--docs-content-fg); + line-height: 1.75; + letter-spacing: normal; + /* The article owns horizontal spacing here; the editor's 3rem gutter would + * misalign content with the breadcrumb, and its own background bands + * against the off-white page. */ + padding-left: 0; + padding-right: 0; + background-color: transparent; +} + +/* Wide columns bleed into the editor gutter that no longer exists here. */ +.root.root :global(div[data-type="columns"][data-width-mode="wide"]) { + margin-left: 0; + margin-right: 0; + width: 100%; +} + +/* One ink by inheritance: Mantine's baseline gives headings an explicit + * color, which forks them from the body. Neutralize instead of re-declaring, + * so changing the single content foreground repaints all text. */ +.root.root :global(.ProseMirror) h1, +.root.root :global(.ProseMirror) h2, +.root.root :global(.ProseMirror) h3, +.root.root :global(.ProseMirror) h4, +.root.root :global(.ProseMirror) h5, +.root.root :global(.ProseMirror) h6, +.root.root :global(.ProseMirror) strong { + color: inherit; +} + +/* Modest semibold heading scale (Cloudflare-style); class doubled to outrank + * the shared editor and .public-typography rules. */ +.root.root :global(.ProseMirror) h1 { + font-size: 2.1875rem; + font-weight: 600; + letter-spacing: -0.025em; + line-height: 1.25; +} + +.root.root :global(.ProseMirror) h2 { + font-size: 1.3rem; + font-weight: 600; + letter-spacing: -0.015em; + line-height: 1.4; +} + +.root.root :global(.ProseMirror) h3 { + font-size: 1.1rem; + font-weight: 600; + letter-spacing: -0.01em; + line-height: 1.45; +} + +.root.root :global(.ProseMirror) h4, +.root.root :global(.ProseMirror) h5, +.root.root :global(.ProseMirror) h6 { + font-size: 1rem; + font-weight: 600; + line-height: 1.5; +} + +.root.root :global(.ProseMirror) strong { + font-weight: 600; +} + +.root.root :global(.page-title .ProseMirror) h1 { + font-size: 2.1875rem; + font-weight: 600; + letter-spacing: -0.025em; + line-height: 1.25; +} + +.root :global(.ProseMirror) a { + color: var(--docs-accent); +} + +/* Internal page links (mentions, subpages lists) follow the accent like any + * other link; the app skin pins them to ink with !important, hence the + * counter-!important, and the underline tint softens to match. */ +.root :global(.ProseMirror) a[class*="pageMentionLink"] { + color: var(--docs-accent) !important; +} + +.root :global(.ProseMirror) [class*="pageMentionText"] { + border-bottom-color: color-mix(in srgb, var(--docs-accent) 40%, transparent); +} + +.root :global(.ProseMirror) h1, +.root :global(.ProseMirror) h2, +.root :global(.ProseMirror) h3, +.root :global(.ProseMirror) h4 { + scroll-margin-top: calc(var(--docs-header-h) + rem(16px)); +} + +/* ---------- Content tables ---------- */ + +/* The wrapper carries the rounded outer border so border-collapse never + * fights border-radius; its overflow-x clips the corners. */ +.root.root :global(.ProseMirror .tableWrapper) { + border: 1px solid var(--docs-border); + border-radius: rem(10px); +} + +.root.root :global(.ProseMirror table td), +.root.root :global(.ProseMirror table th) { + border: 0; + border-bottom: 1px solid var(--docs-border); + border-right: 1px solid var(--docs-border); + padding: rem(10px) rem(14px); +} + +.root.root :global(.ProseMirror table :is(td, th):last-child) { + border-right: 0; +} + +.root.root :global(.ProseMirror table tr:last-child td), +.root.root :global(.ProseMirror table tr:last-child th) { + border-bottom: 0; +} + +.root.root :global(.ProseMirror table th) { + background-color: var(--docs-hover); + color: var(--docs-fg); + font-weight: 600; +} + +/* Round the corner cells too for the no-overflow (pinned header) variant, + * where the wrapper does not clip. */ +.root.root :global(.ProseMirror table tr:first-child :is(th, td):first-child) { + border-top-left-radius: rem(9px); +} + +.root.root :global(.ProseMirror table tr:first-child :is(th, td):last-child) { + border-top-right-radius: rem(9px); +} + +.root.root :global(.ProseMirror table tr:last-child :is(th, td):first-child) { + border-bottom-left-radius: rem(9px); +} + +.root.root :global(.ProseMirror table tr:last-child :is(th, td):last-child) { + border-bottom-right-radius: rem(9px); +} + +.emptyState { + padding-top: rem(96px); + text-align: center; + color: var(--docs-muted); +} + +/* ---------- Mobile drawers ---------- */ + +.drawerTree { + height: calc(100dvh - rem(60px)); + display: flex; + flex-direction: column; +} diff --git a/apps/client/src/features/public-space/components/public-space-layout.tsx b/apps/client/src/features/public-space/components/public-space-layout.tsx new file mode 100644 index 000000000..2bd01b20a --- /dev/null +++ b/apps/client/src/features/public-space/components/public-space-layout.tsx @@ -0,0 +1,69 @@ +import "@fontsource-variable/inter"; +import "@/styles/public-typography.css"; +import { useEffect, useMemo } from "react"; +import { Outlet, useParams } from "react-router-dom"; +import { useSetAtom } from "jotai"; +import { usePublicSpaceTreeQuery } from "@/features/public-space/queries/public-space-query.ts"; +import { buildSharedPageTree } from "@/features/share/utils.ts"; +import { + publicSpaceTreeAtom, + publicSpaceTreeDataAtom, +} from "@/features/public-space/atoms/public-space-atoms.ts"; +import { useDocsAccent } from "@/features/public-space/theme/docs-theme.ts"; +import DocsShell from "@/features/public-space/components/docs/docs-shell.tsx"; +import { DocsSurface } from "@/features/public-space/components/docs/docs-surface-context.tsx"; +import { buildPublicSpaceUrl } from "@/features/page/page.utils.ts"; +import { PublicSpaceSearchSpotlight } from "@/features/search/components/public-space-search-spotlight.tsx"; +import { publicSpaceSearchSpotlight } from "@/features/search/constants"; + +export default function PublicSpaceLayout() { + const { spaceSlug } = useParams(); + const { data } = usePublicSpaceTreeQuery(spaceSlug); + + useDocsAccent(data?.appearance); + + const setPublicSpaceTree = useSetAtom(publicSpaceTreeAtom); + const setPublicSpaceTreeData = useSetAtom(publicSpaceTreeDataAtom); + + const treeData = useMemo(() => { + if (!data?.pageTree) return null; + return buildSharedPageTree(data.pageTree); + }, [data?.pageTree]); + + useEffect(() => { + setPublicSpaceTree(data || null); + setPublicSpaceTreeData(treeData); + }, [data, treeData, setPublicSpaceTree, setPublicSpaceTreeData]); + + const surface = useMemo(() => { + const homeUrl = buildPublicSpaceUrl({ spaceSlug }); + // The first root page is the space home, served at the bare space URL. + const firstRootSlugId = treeData?.[0]?.slugId; + return { + treeData, + hasSidebar: (data?.pageTree?.length ?? 0) > 1, + siteName: data?.space?.name, + homeUrl, + getNodeUrl: (node) => + node.slugId === firstRootSlugId + ? homeUrl + : buildPublicSpaceUrl({ + spaceSlug, + pageSlugId: node.slugId, + pageTitle: node.name, + }), + showBranding: Boolean(data), + showEditPage: true, + }; + }, [data, treeData, spaceSlug]); + + return ( + } + > + + + ); +} diff --git a/apps/client/src/features/public-space/components/publish-space-settings.tsx b/apps/client/src/features/public-space/components/publish-space-settings.tsx new file mode 100644 index 000000000..332ebd373 --- /dev/null +++ b/apps/client/src/features/public-space/components/publish-space-settings.tsx @@ -0,0 +1,283 @@ +import { ActionIcon, Group, Text, Switch, TextInput } from "@mantine/core"; +import { modals } from "@mantine/modals"; +import { useAtom } from "jotai"; +import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { IconExternalLink, IconWorld } from "@tabler/icons-react"; +import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; +import { ISpace } from "@/features/space/types/space.types.ts"; +import { IPublicSpace } from "@/features/public-space/types/public-space.types.ts"; +import { + usePublicSpaceForSpaceQuery, + usePublishSpaceMutation, +} from "@/features/public-space/queries/public-space-query.ts"; +import { getAppUrl } from "@/lib/config.ts"; +import CopyTextButton from "@/components/common/copy.tsx"; +import AppearanceSettings from "@/features/public-space/components/appearance-settings.tsx"; +import { isPublicSpacesAllowed } from "@/features/public-space/utils/public-space-access.ts"; + +type PublishSpaceSettingsProps = { + space: ISpace; +}; + +export default function PublishSpaceSettings({ + space, +}: PublishSpaceSettingsProps) { + const { t } = useTranslation(); + const [workspace] = useAtom(workspaceAtom); + + const allowPublicSpaces = isPublicSpacesAllowed(workspace); + + const { data: publicSpace } = usePublicSpaceForSpaceQuery( + allowPublicSpaces ? space?.id : undefined, + ); + const publishMutation = usePublishSpaceMutation(); + + const [published, setPublished] = useState(false); + const [searchIndexing, setSearchIndexing] = useState(false); + const [bylineAuthor, setBylineAuthor] = useState(false); + const [bylineUpdatedAt, setBylineUpdatedAt] = useState(true); + const [directoryListed, setDirectoryListed] = useState(false); + + const workspaceDirectoryEnabled = + workspace?.settings?.publicSpaces?.directory === true; + + const syncFromPublicSpace = (state?: IPublicSpace | null) => { + const byline = state?.settings?.byline; + setPublished(state?.enabled === true); + setSearchIndexing(state?.searchIndexing === true); + setBylineAuthor(byline?.author === true); + setBylineUpdatedAt(byline?.updatedAt !== false); + setDirectoryListed(state?.settings?.directory === true); + }; + + useEffect(() => { + syncFromPublicSpace(publicSpace); + }, [publicSpace]); + + if (!allowPublicSpaces || !space) { + return null; + } + + const publicUrl = `${getAppUrl()}/docs/${space.slug}`; + + const applyPublish = async (enabled: boolean) => { + try { + const result = await publishMutation.mutateAsync({ + spaceId: space.id, + enabled, + }); + syncFromPublicSpace(result); + } catch { + // error handled by mutation + } + }; + + const handlePublishChange = (event: React.ChangeEvent) => { + const value = event.currentTarget.checked; + if (!value) { + applyPublish(false); + return; + } + + modals.openConfirmModal({ + title: t("Publish space to the web"), + children: ( + + {t( + "Anyone on the internet will be able to read every page in this space, except restricted pages. Are you sure?", + )} + + ), + centered: true, + labels: { confirm: t("Publish"), cancel: t("Cancel") }, + onConfirm: () => applyPublish(true), + }); + }; + + const handleIndexingChange = async ( + event: React.ChangeEvent, + ) => { + const value = event.currentTarget.checked; + try { + await publishMutation.mutateAsync({ + spaceId: space.id, + enabled: true, + searchIndexing: value, + }); + setSearchIndexing(value); + } catch { + // error handled by mutation + } + }; + + const handleBylineAuthorChange = async ( + event: React.ChangeEvent, + ) => { + const value = event.currentTarget.checked; + try { + await publishMutation.mutateAsync({ + spaceId: space.id, + enabled: true, + bylineAuthor: value, + }); + setBylineAuthor(value); + } catch { + // error handled by mutation + } + }; + + const handleBylineUpdatedAtChange = async ( + event: React.ChangeEvent, + ) => { + const value = event.currentTarget.checked; + try { + await publishMutation.mutateAsync({ + spaceId: space.id, + enabled: true, + bylineUpdatedAt: value, + }); + setBylineUpdatedAt(value); + } catch { + // error handled by mutation + } + }; + + const handleDirectoryChange = async ( + event: React.ChangeEvent, + ) => { + const value = event.currentTarget.checked; + try { + await publishMutation.mutateAsync({ + spaceId: space.id, + enabled: true, + directory: value, + }); + setDirectoryListed(value); + } catch { + // error handled by mutation + } + }; + + return ( +
    + +
    + {t("Publish space to the web")} + + {t("Make this space publicly readable by anyone on the internet.")} + +
    + +
    + + {published && ( + <> + +
    + {t("Allow search engines to index")} + + {t( + "Let public pages in this space appear in search engine results.", + )} + +
    + +
    + + +
    + {t("Show page author")} + + {t("Display the page creator's name on public pages.")} + +
    + +
    + + +
    + {t("Show last updated")} + + {t("Display when each page was last updated.")} + +
    + +
    + + {workspaceDirectoryEnabled && ( + +
    + {t("Show in public directory")} + + {t("List this space in the public directory at /docs.")} + +
    + +
    + )} + + + } + aria-label={t("Public space link")} + rightSection={ + + } + /> + + + + + + + {t("Renaming the space slug will break public links.")} + + + + + )} +
    + ); +} diff --git a/apps/client/src/features/public-space/components/published-spaces-list.tsx b/apps/client/src/features/public-space/components/published-spaces-list.tsx new file mode 100644 index 000000000..a4aec36a0 --- /dev/null +++ b/apps/client/src/features/public-space/components/published-spaces-list.tsx @@ -0,0 +1,210 @@ +import { Table, Group, Text, Anchor, Menu, ActionIcon } from "@mantine/core"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { modals } from "@mantine/modals"; +import { notifications } from "@mantine/notifications"; +import { + IconCopy, + IconDots, + IconExternalLink, + IconWorld, + IconWorldOff, +} from "@tabler/icons-react"; +import Paginate from "@/components/common/paginate.tsx"; +import { useCursorPaginate } from "@/hooks/use-cursor-paginate"; +import { + usePublishedSpacesQuery, + usePublishSpaceMutation, +} from "@/features/public-space/queries/public-space-query.ts"; +import { IPublishedSpaceItem } from "@/features/public-space/types/public-space.types.ts"; +import { buildPublicSpaceUrl } from "@/features/page/page.utils.ts"; +import { getAppUrl, getSpaceUrl } from "@/lib/config.ts"; +import { useClipboard } from "@/hooks/use-clipboard"; +import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts"; +import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; +import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts"; +import { EmptyState } from "@/components/ui/empty-state.tsx"; +import rowClasses from "@/components/ui/clickable-table-row.module.css"; + +export default function PublishedSpacesList() { + const { t } = useTranslation(); + const { cursor, goNext, goPrev } = useCursorPaginate(); + const { data, isLoading } = usePublishedSpacesQuery({ cursor }); + const locale = useDateFnsLocale(); + + if (!isLoading && data?.items.length === 0) { + return ; + } + + return ( + <> + + + + + {t("Space")} + {t("Published by")} + {t("Published at")} + + + + + {data?.items.map((item: IPublishedSpaceItem) => ( + + + + + + + {item.space.name} + + + + + + + + + {item.creator?.name} + + + + + + {formatLocalized( + item.createdAt, + "MMM dd, yyyy", + "PP", + locale, + )} + + + + + + + ))} + +
    +
    + + {data?.items.length > 0 && ( + goNext(data?.meta?.nextCursor)} + onPrev={goPrev} + /> + )} + + ); +} + +function PublishedSpaceActionMenu({ item }: { item: IPublishedSpaceItem }) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const clipboard = useClipboard(); + const publishMutation = usePublishSpaceMutation(); + + const publicPath = buildPublicSpaceUrl({ spaceSlug: item.space.slug }); + + const copyLink = () => { + clipboard.copy(`${getAppUrl()}${publicPath}`); + notifications.show({ message: t("Link copied") }); + }; + + const onUnpublish = async () => { + try { + await publishMutation.mutateAsync({ + spaceId: item.spaceId, + enabled: false, + }); + } catch { + // error handled by mutation + } + }; + + const openUnpublishModal = () => + modals.openConfirmModal({ + title: t("Unpublish space"), + children: ( + + {t( + "This space will no longer be publicly accessible. Are you sure?", + )} + + ), + centered: true, + labels: { confirm: t("Unpublish"), cancel: t("Cancel") }, + confirmProps: { color: "red" }, + onConfirm: onUnpublish, + }); + + return ( + + + + + + + + + }> + {t("Copy link")} + + + navigate(getSpaceUrl(item.space.slug))} + leftSection={} + > + {t("Open space")} + + + } + disabled={item.space?.userRole !== "admin"} + > + {t("Unpublish")} + + + + ); +} diff --git a/apps/client/src/features/public-space/components/space-public-notice.tsx b/apps/client/src/features/public-space/components/space-public-notice.tsx new file mode 100644 index 000000000..5655995f0 --- /dev/null +++ b/apps/client/src/features/public-space/components/space-public-notice.tsx @@ -0,0 +1,47 @@ +import { Alert, Anchor, Group, Text } from "@mantine/core"; +import { IconExternalLink, IconWorld } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { ISpace } from "@/features/space/types/space.types.ts"; +import { buildPublicSpaceUrl } from "@/features/page/page.utils.ts"; +import { isBetaPublicSpaces } from "@/lib/config.ts"; + +type SpacePublicNoticeProps = { + space: ISpace; +}; + +export default function SpacePublicNotice({ space }: SpacePublicNoticeProps) { + const { t } = useTranslation(); + + if (!isBetaPublicSpaces() || !space?.isPublished) { + return null; + } + + return ( + } + title={t("This space is public")} + mb="lg" + > + + + {t( + "Anyone on the internet can read the pages in this space, except restricted pages.", + )} + + + {t("Open public site")} + + + + + ); +} diff --git a/apps/client/src/features/public-space/hooks/use-authenticated-user.ts b/apps/client/src/features/public-space/hooks/use-authenticated-user.ts new file mode 100644 index 000000000..644619b26 --- /dev/null +++ b/apps/client/src/features/public-space/hooks/use-authenticated-user.ts @@ -0,0 +1,14 @@ +import { useQuery } from "@tanstack/react-query"; +import { getMyInfo } from "@/features/user/services/user-service"; +import { ICurrentUser } from "@/features/user/types/user.types"; + +/** Probes login state from public surfaces; the /docs 401 exemption keeps anonymous visitors off the login redirect. */ +export function useAuthenticatedUser(enabled = true) { + return useQuery({ + queryKey: ["currentUser"], + queryFn: getMyInfo, + retry: false, + staleTime: 5 * 60 * 1000, + enabled, + }); +} diff --git a/apps/client/src/features/public-space/hooks/use-docs-current-page.ts b/apps/client/src/features/public-space/hooks/use-docs-current-page.ts new file mode 100644 index 000000000..eb799ee0e --- /dev/null +++ b/apps/client/src/features/public-space/hooks/use-docs-current-page.ts @@ -0,0 +1,23 @@ +import { useMemo } from "react"; +import { useParams } from "react-router-dom"; +import { useDocsSurface } from "@/features/public-space/components/docs/docs-surface-context.tsx"; +import { flattenTreePreorder } from "@/features/public-space/utils/docs-tree.ts"; +import { extractPageSlugId } from "@/lib"; +import { SharedPageTreeNode } from "@/features/share/utils.ts"; + +export function useDocsCurrentPage(): SharedPageTreeNode | null { + const { pageSlug } = useParams(); + const { treeData } = useDocsSurface(); + + return useMemo(() => { + if (!treeData?.length) return null; + const currentSlugId = pageSlug + ? extractPageSlugId(pageSlug) + : treeData[0]?.slugId; + return ( + flattenTreePreorder(treeData).find( + (node) => node.slugId === currentSlugId, + ) ?? null + ); + }, [treeData, pageSlug]); +} diff --git a/apps/client/src/features/public-space/queries/public-space-query.ts b/apps/client/src/features/public-space/queries/public-space-query.ts new file mode 100644 index 000000000..49cba8cc6 --- /dev/null +++ b/apps/client/src/features/public-space/queries/public-space-query.ts @@ -0,0 +1,109 @@ +import { + keepPreviousData, + useMutation, + useQuery, + useQueryClient, + UseQueryResult, +} from "@tanstack/react-query"; +import { notifications } from "@mantine/notifications"; +import { useTranslation } from "react-i18next"; +import { + getPublicSpaceDirectory, + getPublicSpaceForSpace, + getPublicSpacePage, + getPublicSpaceTree, + getPublishedSpaces, + publishSpace, +} from "@/features/public-space/services/public-space-service.ts"; +import { + IPublicSpace, + IPublicSpaceDirectory, + IPublicSpacePage, + IPublicSpaceTree, + IPublishedSpaceItem, + IPublishSpace, +} from "@/features/public-space/types/public-space.types.ts"; +import { IPagination, QueryParams } from "@/lib/types.ts"; + +export function usePublicSpaceTreeQuery( + spaceSlug: string, +): UseQueryResult { + return useQuery({ + queryKey: ["public-space-tree", spaceSlug], + queryFn: () => getPublicSpaceTree(spaceSlug), + enabled: !!spaceSlug, + placeholderData: keepPreviousData, + staleTime: 60 * 60 * 1000, + }); +} + +export function usePublicSpacePageQuery(params: { + spaceSlug: string; + pageSlugId?: string; + contentless?: boolean; +}): UseQueryResult { + return useQuery({ + queryKey: ["public-space-page", params], + queryFn: () => getPublicSpacePage(params), + enabled: !!params.spaceSlug, + }); +} + +export function usePublicSpaceDirectoryQuery(): UseQueryResult< + IPublicSpaceDirectory, + Error +> { + return useQuery({ + queryKey: ["public-space-directory"], + queryFn: () => getPublicSpaceDirectory(), + }); +} + +export function usePublicSpaceForSpaceQuery( + spaceId: string, +): UseQueryResult { + return useQuery({ + queryKey: ["public-space-for-space", spaceId], + queryFn: () => getPublicSpaceForSpace(spaceId), + enabled: !!spaceId, + staleTime: 60 * 1000, + retry: false, + }); +} + +export function usePublishedSpacesQuery( + params?: QueryParams, +): UseQueryResult, Error> { + return useQuery({ + queryKey: ["published-spaces", params], + queryFn: () => getPublishedSpaces(params), + placeholderData: keepPreviousData, + }); +} + +export function usePublishSpaceMutation() { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data) => publishSpace(data), + onSuccess: () => { + queryClient.invalidateQueries({ + predicate: (item) => + [ + "public-space-for-space", + "published-spaces", + "space", + "spaces", + ].includes(item.queryKey[0] as string), + }); + }, + onError: (error) => { + notifications.show({ + message: + error?.["response"]?.data?.message || t("Failed to update space"), + color: "red", + }); + }, + }); +} diff --git a/apps/client/src/features/public-space/services/public-space-service.ts b/apps/client/src/features/public-space/services/public-space-service.ts new file mode 100644 index 000000000..77899650d --- /dev/null +++ b/apps/client/src/features/public-space/services/public-space-service.ts @@ -0,0 +1,73 @@ +import api from "@/lib/api-client"; +import { IPagination, QueryParams } from "@/lib/types.ts"; +import { + IPublicSpace, + IPublicSpaceDirectory, + IPublicSpaceInfo, + IPublicSpacePage, + IPublicSpaceTree, + IPublishedSpaceItem, + IPublishSpace, +} from "@/features/public-space/types/public-space.types.ts"; + +export async function getPublishedSpaces( + params?: QueryParams, +): Promise> { + const req = await api.post>( + "/public-spaces", + params, + ); + return req.data; +} + +export async function getPublicSpaceInfo( + spaceSlug: string, +): Promise { + const req = await api.post("/public-spaces/info", { + spaceSlug, + }); + return req.data; +} + +export async function getPublicSpaceTree( + spaceSlug: string, +): Promise { + const req = await api.post("/public-spaces/tree", { + spaceSlug, + }); + return req.data; +} + +export async function getPublicSpacePage(params: { + spaceSlug: string; + pageSlugId?: string; + contentless?: boolean; +}): Promise { + const req = await api.post( + "/public-spaces/page-info", + params, + ); + return req.data; +} + +export async function getPublicSpaceDirectory(): Promise { + const req = await api.post( + "/public-spaces/directory", + {}, + ); + return req.data; +} + +export async function getPublicSpaceForSpace( + spaceId: string, +): Promise { + const req = await api.post("/public-spaces/for-space", { + spaceId, + }); + return req.data; +} + +export async function publishSpace(data: IPublishSpace): Promise { + const req = await api.post("/public-spaces/publish", data); + return req.data; +} diff --git a/apps/client/src/features/public-space/theme/docs-theme.ts b/apps/client/src/features/public-space/theme/docs-theme.ts new file mode 100644 index 000000000..4e9368335 --- /dev/null +++ b/apps/client/src/features/public-space/theme/docs-theme.ts @@ -0,0 +1,91 @@ +import { useEffect } from "react"; +import { useComputedColorScheme } from "@mantine/core"; +import { IPublicSpaceAppearance } from "@/features/public-space/types/public-space.types.ts"; + +export type DocsThemePreset = { + id: string; + nameKey: string; + light: string; + dark: string; +}; + +export const DOCS_THEME_PRESETS: DocsThemePreset[] = [ + { id: "default", nameKey: "Default", light: "#2b7af1", dark: "#6ea6f6" }, + { id: "forest", nameKey: "Forest", light: "#0f766e", dark: "#2dd4bf" }, + { id: "violet", nameKey: "Violet", light: "#6d28d9", dark: "#a78bfa" }, + { id: "ember", nameKey: "Ember", light: "#c2410c", dark: "#fb923c" }, + { id: "rose", nameKey: "Rose", light: "#be123c", dark: "#fb7185" }, +]; + +export const DEFAULT_DOCS_PRESET = DOCS_THEME_PRESETS[0]; + +const HEX_COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; + +export function isValidDocsColor(value: unknown): value is string { + return typeof value === "string" && HEX_COLOR_REGEX.test(value); +} + +export function resolveDocsAccent( + appearance: IPublicSpaceAppearance | undefined, + scheme: "light" | "dark", +): string { + const custom = + scheme === "dark" + ? appearance?.primaryColorDark + : appearance?.primaryColorLight; + if (isValidDocsColor(custom)) return custom; + return scheme === "dark" + ? DEFAULT_DOCS_PRESET.dark + : DEFAULT_DOCS_PRESET.light; +} + +export function matchDocsPreset( + appearance: IPublicSpaceAppearance | undefined, +): DocsThemePreset | null { + const light = appearance?.primaryColorLight; + const dark = appearance?.primaryColorDark; + if (!light && !dark) return DEFAULT_DOCS_PRESET; + return ( + DOCS_THEME_PRESETS.find( + (preset) => + preset.light.toLowerCase() === light?.toLowerCase() && + preset.dark.toLowerCase() === dark?.toLowerCase(), + ) ?? null + ); +} + +// Set on documentElement (not the shell root) so portaled Mantine surfaces on +// /docs routes (spotlight, drawers) follow the space accent too. +const ACCENT_VARIABLES = (accent: string): Record => ({ + "--docs-accent": accent, + "--docs-accent-soft": `color-mix(in srgb, ${accent} 10%, transparent)`, + "--mantine-primary-color-filled": accent, + "--mantine-primary-color-filled-hover": `color-mix(in srgb, ${accent} 85%, black)`, + "--mantine-primary-color-light": `color-mix(in srgb, ${accent} 10%, transparent)`, + "--mantine-primary-color-light-hover": `color-mix(in srgb, ${accent} 15%, transparent)`, + "--mantine-primary-color-light-color": accent, + "--mantine-color-anchor": accent, +}); + +export function useDocsAccent(appearance: IPublicSpaceAppearance | undefined) { + const scheme = useComputedColorScheme("light"); + const light = appearance?.primaryColorLight; + const dark = appearance?.primaryColorDark; + + useEffect(() => { + const accent = resolveDocsAccent( + { primaryColorLight: light, primaryColorDark: dark }, + scheme, + ); + const root = document.documentElement; + const variables = ACCENT_VARIABLES(accent); + for (const [name, value] of Object.entries(variables)) { + root.style.setProperty(name, value); + } + return () => { + for (const name of Object.keys(variables)) { + root.style.removeProperty(name); + } + }; + }, [light, dark, scheme]); +} diff --git a/apps/client/src/features/public-space/types/public-space.types.ts b/apps/client/src/features/public-space/types/public-space.types.ts new file mode 100644 index 000000000..55fabe9af --- /dev/null +++ b/apps/client/src/features/public-space/types/public-space.types.ts @@ -0,0 +1,105 @@ +import { IPage } from "@/features/page/types/page.types.ts"; + +export interface IPublicSpaceSummary { + id: string; + name: string; + slug: string; + description?: string; + logo?: string; +} + +export interface IPublicSpaceAppearance { + primaryColorLight?: string; + primaryColorDark?: string; +} + +export interface IPublicSpaceByline { + author: boolean; + updatedAt: boolean; +} + +export interface IPublicSpaceInfo { + space: IPublicSpaceSummary; + searchIndexing: boolean; + appearance?: IPublicSpaceAppearance; + features?: string[]; +} + +export interface IPublicSpaceTree { + space: IPublicSpaceSummary; + pageTree: Partial; + appearance?: IPublicSpaceAppearance; + features?: string[]; +} + +export interface IPublicSpacePage { + page: IPage | null; + space: IPublicSpaceSummary; + searchIndexing: boolean; + appearance?: IPublicSpaceAppearance; + byline?: IPublicSpaceByline; + features?: string[]; +} + +export interface IPublicSpace { + id: string; + spaceId: string; + workspaceId: string; + enabled: boolean; + searchIndexing: boolean; + settings?: { + appearance?: IPublicSpaceAppearance; + byline?: Partial; + directory?: boolean; + } | null; + creatorId?: string; + createdAt: string; + updatedAt: string; +} + +export interface IPublishedSpaceItem { + id: string; + spaceId: string; + workspaceId: string; + searchIndexing: boolean; + settings?: IPublicSpace["settings"]; + createdAt: string; + updatedAt: string; + space: { + id: string; + name: string; + slug: string; + logo?: string; + userRole: string; + }; + creator: { + id: string; + name: string; + avatarUrl: string | null; + }; +} + +export interface IPublishSpace { + spaceId: string; + enabled: boolean; + searchIndexing?: boolean; + appearance?: { + primaryColorLight?: string | null; + primaryColorDark?: string | null; + }; + bylineAuthor?: boolean; + bylineUpdatedAt?: boolean; + directory?: boolean; +} + +export interface IPublicSpaceDirectoryEntry { + name: string; + slug: string; + description?: string; + logo?: string; +} + +export interface IPublicSpaceDirectory { + spaces: IPublicSpaceDirectoryEntry[]; + features?: string[]; +} diff --git a/apps/client/src/features/public-space/utils/docs-tree.ts b/apps/client/src/features/public-space/utils/docs-tree.ts new file mode 100644 index 000000000..22ad8ffb3 --- /dev/null +++ b/apps/client/src/features/public-space/utils/docs-tree.ts @@ -0,0 +1,39 @@ +import { SharedPageTreeNode } from "@/features/share/utils.ts"; + +// Preorder walk of the whole tree, matching the sidebar's visual order. Drives +// prev/next navigation independently of which nodes are expanded. +export function flattenTreePreorder( + nodes: SharedPageTreeNode[], +): SharedPageTreeNode[] { + const out: SharedPageTreeNode[] = []; + const walk = (list: SharedPageTreeNode[]) => { + for (const node of list) { + out.push(node); + if (node.children?.length) walk(node.children); + } + }; + walk(nodes); + return out; +} + +// Ancestors of the node with the given slugId, root-first, excluding the node +// itself. Null when the slugId is not in the tree. +export function findAncestorTrail( + nodes: SharedPageTreeNode[], + slugId: string, +): SharedPageTreeNode[] | null { + const walk = ( + list: SharedPageTreeNode[], + trail: SharedPageTreeNode[], + ): SharedPageTreeNode[] | null => { + for (const node of list) { + if (node.slugId === slugId) return trail; + if (node.children?.length) { + const found = walk(node.children, [...trail, node]); + if (found) return found; + } + } + return null; + }; + return walk(nodes, []); +} diff --git a/apps/client/src/features/public-space/utils/public-space-access.ts b/apps/client/src/features/public-space/utils/public-space-access.ts new file mode 100644 index 000000000..d10bc1753 --- /dev/null +++ b/apps/client/src/features/public-space/utils/public-space-access.ts @@ -0,0 +1,8 @@ +import { isBetaPublicSpaces } from "@/lib/config.ts"; +import { IWorkspace } from "@/features/workspace/types/workspace.types.ts"; + +export function isPublicSpacesAllowed(workspace?: IWorkspace): boolean { + return ( + isBetaPublicSpaces() && workspace?.settings?.publicSpaces?.enabled === true + ); +} diff --git a/apps/client/src/features/search/components/public-space-search-spotlight.tsx b/apps/client/src/features/search/components/public-space-search-spotlight.tsx new file mode 100644 index 000000000..0dca1e053 --- /dev/null +++ b/apps/client/src/features/search/components/public-space-search-spotlight.tsx @@ -0,0 +1,112 @@ +import { Group, Center, Text, Button } from "@mantine/core"; +import { Spotlight } from "@mantine/spotlight"; +import { IconLetterCase, IconSearch } from "@tabler/icons-react"; +import React, { useState } from "react"; +import { Link } from "react-router-dom"; +import { useDebouncedValue } from "@mantine/hooks"; +import { usePublicSpaceSearchQuery } from "@/features/search/queries/search-query"; +import { buildPublicSpaceUrl } from "@/features/page/page.utils.ts"; +import { getPageIcon } from "@/lib"; +import { useTranslation } from "react-i18next"; +import { publicSpaceSearchSpotlightStore } from "@/features/search/constants.ts"; +import DOMPurify from "dompurify"; + +interface PublicSpaceSearchSpotlightProps { + spaceSlug: string; +} +export function PublicSpaceSearchSpotlight({ + spaceSlug, +}: PublicSpaceSearchSpotlightProps) { + const { t } = useTranslation(); + const [query, setQuery] = useState(""); + const [titleOnly, setTitleOnly] = useState(false); + const [debouncedSearchQuery] = useDebouncedValue(query, 300); + + const { data: searchResults } = usePublicSpaceSearchQuery({ + query: debouncedSearchQuery, + spaceSlug, + ...(titleOnly && { titleOnly: true }), + }); + + const pages = ( + searchResults && searchResults.length > 0 ? searchResults : [] + ).map((page) => ( + + +
    {getPageIcon(page?.icon)}
    + +
    + {page.title} + + {page?.highlight && ( + + )} +
    +
    +
    + )); + + return ( + <> + + } + /> + + + + + {query.length === 0 && pages.length === 0 && ( + {t("Start typing to search...")} + )} + + {query.length > 0 && pages.length === 0 && ( + {t("No results found...")} + )} + + {pages.length > 0 && pages} + + + + ); +} diff --git a/apps/client/src/features/search/constants.ts b/apps/client/src/features/search/constants.ts index a4c6c2f70..365387625 100644 --- a/apps/client/src/features/search/constants.ts +++ b/apps/client/src/features/search/constants.ts @@ -5,3 +5,6 @@ export const [searchSpotlightStore, searchSpotlight] = createSpotlight(); export const [shareSearchSpotlightStore, shareSearchSpotlight] = createSpotlight(); +export const [publicSpaceSearchSpotlightStore, publicSpaceSearchSpotlight] = + createSpotlight(); + diff --git a/apps/client/src/features/search/queries/search-query.ts b/apps/client/src/features/search/queries/search-query.ts index f536b441b..3cbc9a371 100644 --- a/apps/client/src/features/search/queries/search-query.ts +++ b/apps/client/src/features/search/queries/search-query.ts @@ -2,6 +2,7 @@ import { keepPreviousData, useQuery, UseQueryResult } from "@tanstack/react-quer import { searchAttachments, searchPage, + searchPublicSpace, searchShare, searchSuggestions, } from '@/features/search/services/search-service'; @@ -55,3 +56,13 @@ export function useAttachmentSearchQuery( enabled: !!params.query, }); } + +export function usePublicSpaceSearchQuery( + params: IPageSearchParams & { spaceSlug: string }, +): UseQueryResult { + return useQuery({ + queryKey: ["public-space-search", params], + queryFn: () => searchPublicSpace(params), + enabled: !!params.query && !!params.spaceSlug, + }); +} diff --git a/apps/client/src/features/search/services/search-service.ts b/apps/client/src/features/search/services/search-service.ts index 5e52ddf77..b94cccab9 100644 --- a/apps/client/src/features/search/services/search-service.ts +++ b/apps/client/src/features/search/services/search-service.ts @@ -28,6 +28,16 @@ export async function searchShare( return req.data.items; } +export async function searchPublicSpace( + params: IPageSearchParams & { spaceSlug: string }, +): Promise { + const req = await api.post<{ items: IPageSearch[] }>( + "/search/public-space-search", + params, + ); + return req.data.items; +} + export async function searchAttachments( params: IPageSearchParams, ): Promise { diff --git a/apps/client/src/features/share/atoms/open-shared-tree-nodes-atom.ts b/apps/client/src/features/share/atoms/open-shared-tree-nodes-atom.ts deleted file mode 100644 index 47882e5e9..000000000 --- a/apps/client/src/features/share/atoms/open-shared-tree-nodes-atom.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { atom } from "jotai"; - -export const openSharedTreeNodesAtom = atom>({}); diff --git a/apps/client/src/features/share/atoms/shared-page-atom.ts b/apps/client/src/features/share/atoms/shared-page-atom.ts index bf8929942..efb0940db 100644 --- a/apps/client/src/features/share/atoms/shared-page-atom.ts +++ b/apps/client/src/features/share/atoms/shared-page-atom.ts @@ -1,11 +1,10 @@ import { atom } from "jotai"; -import { atomWithStorage } from "jotai/utils"; import { ISharedPageTree } from "@/features/share/types/share.types"; import { SharedPageTreeNode } from "@/features/share/utils"; -export const sharedPageTreeAtom = atom(null); -export const sharedTreeDataAtom = atom(null); -export const sharedPageFullWidthAtom = atomWithStorage( - "sharedPageFullWidth", - false, -); \ No newline at end of file +export const sharedPageTreeAtom = atom( + null as ISharedPageTree | null, +); +export const sharedTreeDataAtom = atom( + null as SharedPageTreeNode[] | null, +); diff --git a/apps/client/src/features/share/atoms/sidebar-atom.ts b/apps/client/src/features/share/atoms/sidebar-atom.ts deleted file mode 100644 index 0bc9d6817..000000000 --- a/apps/client/src/features/share/atoms/sidebar-atom.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { atomWithWebStorage } from "@/lib/jotai-helper.ts"; -import { atom } from 'jotai'; - -export const tableOfContentAsideAtom = atomWithWebStorage( - "showTOC", - true, -); - -export const mobileTableOfContentAsideAtom = atom(false); \ No newline at end of file diff --git a/apps/client/src/features/share/components/share-branding.tsx b/apps/client/src/features/share/components/share-branding.tsx deleted file mode 100644 index 4b3dfb3eb..000000000 --- a/apps/client/src/features/share/components/share-branding.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { Affix, Button } from "@mantine/core"; - -export default function ShareBranding() { - return ( - - - - ); -} diff --git a/apps/client/src/features/share/components/share-layout.tsx b/apps/client/src/features/share/components/share-layout.tsx index e3b2eb177..21bfca77e 100644 --- a/apps/client/src/features/share/components/share-layout.tsx +++ b/apps/client/src/features/share/components/share-layout.tsx @@ -1,10 +1,67 @@ -import { Outlet } from "react-router-dom"; -import ShareShell from "@/features/share/components/share-shell.tsx"; +import "@fontsource-variable/inter"; +import "@/styles/public-typography.css"; +import { useEffect, useMemo } from "react"; +import { Outlet, useParams } from "react-router-dom"; +import { useSetAtom } from "jotai"; +import { useGetSharedPageTreeQuery } from "@/features/share/queries/share-query.ts"; +import { buildSharedPageTree } from "@/features/share/utils.ts"; +import { + sharedPageTreeAtom, + sharedTreeDataAtom, +} from "@/features/share/atoms/shared-page-atom.ts"; +import DocsShell from "@/features/public-space/components/docs/docs-shell.tsx"; +import { DocsSurface } from "@/features/public-space/components/docs/docs-surface-context.tsx"; +import { useDocsAccent } from "@/features/public-space/theme/docs-theme.ts"; +import { buildSharedPageUrl } from "@/features/page/page.utils.ts"; +import { ShareSearchSpotlight } from "@/features/search/components/share-search-spotlight.tsx"; +import { shareSearchSpotlight } from "@/features/search/constants"; export default function ShareLayout() { + const { shareId } = useParams(); + const { data } = useGetSharedPageTreeQuery(shareId); + + // Shares have no appearance settings; apply the default docs accent. + useDocsAccent(undefined); + + const setSharedPageTree = useSetAtom(sharedPageTreeAtom); + const setSharedTreeData = useSetAtom(sharedTreeDataAtom); + + const treeData = useMemo(() => { + if (!data?.pageTree) return null; + return buildSharedPageTree(data.pageTree); + }, [data?.pageTree]); + + useEffect(() => { + setSharedPageTree(data || null); + setSharedTreeData(treeData); + }, [data, treeData, setSharedPageTree, setSharedTreeData]); + + const surface = useMemo( + () => ({ + treeData, + hasSidebar: (data?.pageTree?.length ?? 0) > 1, + getNodeUrl: (node) => + buildSharedPageUrl({ + shareId, + pageSlugId: node.slugId, + pageTitle: node.name, + }), + showBranding: Boolean(data), + showEditPage: true, + brandingRef: "public-share", + }), + [data, treeData, shareId], + ); + return ( - + : undefined + } + > - + ); } diff --git a/apps/client/src/features/share/components/share-shell.tsx b/apps/client/src/features/share/components/share-shell.tsx deleted file mode 100644 index 54ddbe347..000000000 --- a/apps/client/src/features/share/components/share-shell.tsx +++ /dev/null @@ -1,272 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - ActionIcon, - AppShell, - Group, - ScrollArea, - Tooltip, -} from "@mantine/core"; -import { useGetSharedPageTreeQuery } from "@/features/share/queries/share-query.ts"; -import { useParams } from "react-router-dom"; -import SharedTree from "@/features/share/components/shared-tree.tsx"; -import { TableOfContents } from "@/features/editor/components/table-of-contents/table-of-contents.tsx"; -import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts"; -import { ThemeToggle } from "@/components/theme-toggle.tsx"; -import { useAtomValue, useSetAtom } from "jotai"; -import { useAtom } from "jotai"; -import { - sharedPageFullWidthAtom, - sharedPageTreeAtom, - sharedTreeDataAtom, -} from "@/features/share/atoms/shared-page-atom"; -import { buildSharedPageTree } from "@/features/share/utils"; -import { - desktopSidebarAtom, - mobileSidebarAtom, - sidebarWidthAtom, -} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; -import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx"; -import { useTranslation } from "react-i18next"; -import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts"; -import { - mobileTableOfContentAsideAtom, - tableOfContentAsideAtom, -} from "@/features/share/atoms/sidebar-atom.ts"; -import { IconArrowsHorizontal, IconList } from "@tabler/icons-react"; -import { useToggleToc } from "@/features/share/hooks/use-toggle-toc.ts"; -import classes from "./share.module.css"; -import { - SearchControl, - SearchMobileControl, -} from "@/features/search/components/search-control.tsx"; -import { ShareSearchSpotlight } from "@/features/search/components/share-search-spotlight.tsx"; -import { shareSearchSpotlight } from "@/features/search/constants"; -import ShareBranding from '@/features/share/components/share-branding.tsx'; -import { MAIN_CONTENT_ID, SkipToMain } from "@/components/ui/skip-to-main.tsx"; - -const MemoizedSharedTree = React.memo(SharedTree); - -export default function ShareShell({ - children, -}: { - children: React.ReactNode; -}) { - const { t } = useTranslation(); - const [mobileOpened] = useAtom(mobileSidebarAtom); - const [desktopOpened] = useAtom(desktopSidebarAtom); - const toggleMobile = useToggleSidebar(mobileSidebarAtom); - const toggleDesktop = useToggleSidebar(desktopSidebarAtom); - - const [tocOpened] = useAtom(tableOfContentAsideAtom); - const [mobileTocOpened] = useAtom(mobileTableOfContentAsideAtom); - const toggleTocMobile = useToggleToc(mobileTableOfContentAsideAtom); - const toggleToc = useToggleToc(tableOfContentAsideAtom); - const [fullWidth, setFullWidth] = useAtom(sharedPageFullWidthAtom); - const [sidebarWidth, setSidebarWidth] = useAtom(sidebarWidthAtom); - const [isResizing, setIsResizing] = useState(false); - const sidebarRef = useRef(null); - - const startResizing = useCallback((e: React.MouseEvent) => { - e.preventDefault(); - setIsResizing(true); - }, []); - - const stopResizing = useCallback(() => { - setIsResizing(false); - }, []); - - const resize = useCallback( - (e: MouseEvent) => { - if (!isResizing || !sidebarRef.current) return; - const newWidth = - e.clientX - sidebarRef.current.getBoundingClientRect().left; - if (newWidth < 220) { - setSidebarWidth(220); - return; - } - if (newWidth > 600) { - setSidebarWidth(600); - return; - } - setSidebarWidth(newWidth); - }, - [isResizing, setSidebarWidth], - ); - - useEffect(() => { - window.addEventListener("mousemove", resize); - window.addEventListener("mouseup", stopResizing); - return () => { - window.removeEventListener("mousemove", resize); - window.removeEventListener("mouseup", stopResizing); - }; - }, [resize, stopResizing]); - - const { shareId } = useParams(); - const { data } = useGetSharedPageTreeQuery(shareId); - const readOnlyEditor = useAtomValue(readOnlyEditorAtom); - - // @ts-ignore - const setSharedPageTree = useSetAtom(sharedPageTreeAtom); - // @ts-ignore - const setSharedTreeData = useSetAtom(sharedTreeDataAtom); - - // Build and set the tree data when it changes - const treeData = useMemo(() => { - if (!data?.pageTree) return null; - return buildSharedPageTree(data.pageTree); - }, [data?.pageTree]); - - useEffect(() => { - setSharedPageTree(data || null); - setSharedTreeData(treeData); - }, [data, treeData, setSharedPageTree, setSharedTreeData]); - - return ( - <> - - 1 && { - navbar: { - width: sidebarWidth, - breakpoint: "sm", - collapsed: { - mobile: !mobileOpened, - desktop: !desktopOpened, - }, - }, - })} - aside={{ - width: 300, - breakpoint: "sm", - collapsed: { - mobile: !mobileTocOpened, - desktop: !tocOpened, - }, - }} - padding="md" - > - - - - {data?.pageTree?.length > 1 && ( - <> - - - - - - - - - )} - - - {shareId && ( - - - - )} - - - <> - {shareId && ( - - - - )} - - - - - - - - - - - - - - - setFullWidth((v) => !v)} - visibleFrom="sm" - size="sm" - > - - - - - - - - - - - {data?.pageTree?.length > 1 && ( - -
    - - - )} - - - {children} - - {data && shareId && !(data.features?.length > 0) && } - - - - -
    - {readOnlyEditor && ( - - )} -
    -
    -
    - - - - - ); -} diff --git a/apps/client/src/features/share/components/share.module.css b/apps/client/src/features/share/components/share.module.css index ebf1e74cb..99e84e413 100644 --- a/apps/client/src/features/share/components/share.module.css +++ b/apps/client/src/features/share/components/share.module.css @@ -6,39 +6,3 @@ border-bottom: 0.05em solid var(--mantine-color-dark-2); } } - -.treeNode { - text-decoration: none; - user-select: none; - padding-bottom: 0; -} - -.navbar, -.aside { - @media (max-width: $mantine-breakpoint-sm) { - width: 350px; - } -} - -.resizeHandle { - width: 3px; - cursor: col-resize; - position: absolute; - right: 0; - top: 0; - bottom: 0; - z-index: 1; - - &:hover, - &:active { - width: 5px; - background: light-dark( - var(--mantine-color-gray-4), - var(--mantine-color-dark-5) - ); - } - - @media (max-width: $mantine-breakpoint-sm) { - display: none; - } -} diff --git a/apps/client/src/features/share/components/shared-tree.tsx b/apps/client/src/features/share/components/shared-tree.tsx deleted file mode 100644 index 370c59e70..000000000 --- a/apps/client/src/features/share/components/shared-tree.tsx +++ /dev/null @@ -1,220 +0,0 @@ -import { ISharedPageTree } from "@/features/share/types/share.types.ts"; -import { - buildSharedPageTree, - SharedPageTreeNode, -} from "@/features/share/utils.ts"; -import { useCallback, useEffect, useMemo, useRef } from "react"; -import { Link, useParams } from "react-router-dom"; -import { useAtom } from "jotai"; -import { useTranslation } from "react-i18next"; -import { buildSharedPageUrl } from "@/features/page/page.utils.ts"; -import clsx from "clsx"; -import { - IconChevronDown, - IconChevronRight, - IconFileDescription, - IconPointFilled, -} from "@tabler/icons-react"; -import { ActionIcon, Box } from "@mantine/core"; -import { extractPageSlugId } from "@/lib"; -import classes from "@/features/page/tree/styles/tree.module.css"; -import styles from "./share.module.css"; -import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; -import EmojiPicker from "@/components/ui/emoji-picker.tsx"; -import { - DocTree, - type DocTreeApi, - type RenderRowProps, -} from "@/features/page/tree/components/doc-tree"; -import { openSharedTreeNodesAtom } from "@/features/share/atoms/open-shared-tree-nodes-atom"; - -interface SharedTreeProps { - sharedPageTree: ISharedPageTree; -} - -export default function SharedTree({ sharedPageTree }: SharedTreeProps) { - const { t } = useTranslation(); - const treeRef = useRef(null); - const { pageSlug } = useParams(); - const [openTreeNodes, setOpenTreeNodes] = useAtom(openSharedTreeNodesAtom); - - const currentNodeId = extractPageSlugId(pageSlug); - - const treeData: SharedPageTreeNode[] = useMemo(() => { - if (!sharedPageTree?.pageTree) return [] as SharedPageTreeNode[]; - return buildSharedPageTree(sharedPageTree.pageTree); - }, [sharedPageTree?.pageTree]); - - const openIds = useMemo( - () => - new Set( - Object.keys(openTreeNodes).filter((k) => openTreeNodes[k]), - ), - [openTreeNodes], - ); - - useEffect(() => { - // Auto-open the first level of the shared tree on initial load. - const root = treeData?.[0]; - if (!root) return; - setOpenTreeNodes((prev) => { - if (prev[root.slugId]) return prev; - const next = { ...prev, [root.slugId]: true }; - for (const child of root.children ?? []) { - next[child.slugId] = true; - } - return next; - }); - }, [treeData, setOpenTreeNodes]); - - useEffect(() => { - if (currentNodeId) { - treeRef.current?.select(currentNodeId, { scrollIntoView: true }); - } - }, [currentNodeId, treeData]); - - // Stable callbacks so memo(DocTreeRow) actually saves work — see I2 in the - // post-implementation code review. - const handleToggle = useCallback( - (id: string, isOpen: boolean) => - setOpenTreeNodes((prev) => ({ ...prev, [id]: isOpen })), - [setOpenTreeNodes], - ); - const getDragLabel = useCallback( - (n: SharedPageTreeNode) => n.name || "untitled", - [], - ); - - if (!sharedPageTree || !sharedPageTree?.pageTree) { - return null; - } - - return ( -
    - - readOnly - ref={treeRef} - data={treeData} - openIds={openIds} - selectedId={currentNodeId} - renderRow={SharedTreeRow} - onMove={noopMove} - onToggle={handleToggle} - getDragLabel={getDragLabel} - aria-label={t("Pages")} - /> -
    - ); -} - -// Module-scope noop so it's a stable reference across renders. -const noopMove = () => {}; - -function SharedTreeRow({ - node, - isOpen, - hasChildren, - isSelected, - rowRef, - tabIndex, - treeItemProps, - toggleOpen, -}: RenderRowProps) { - const { shareId } = useParams(); - const { t } = useTranslation(); - const [, setMobileSidebarState] = useAtom(mobileSidebarAtom); - - const pageUrl = buildSharedPageUrl({ - shareId: shareId, - pageSlugId: node.slugId, - pageTitle: node.name, - }); - - return ( - } - tabIndex={tabIndex} - {...treeItemProps} - data-selected={isSelected || undefined} - className={clsx(classes.node, styles.treeNode)} - component={Link} - to={pageUrl} - onClick={() => { - setMobileSidebarState(false); - }} - > - -
    - {}} - icon={ - node.icon ? ( - node.icon - ) : ( - - ) - } - readOnly={true} - removeEmojiAction={() => {}} - actionIconProps={{ tabIndex: -1 }} - /> -
    - {node.name || t("untitled")} -
    - ); -} - -interface SharedPageArrowProps { - isOpen: boolean; - hasChildren: boolean; - onToggle: () => void; -} - -function SharedPageArrow({ - isOpen, - hasChildren, - onToggle, -}: SharedPageArrowProps) { - if (!hasChildren) { - return ( - - - - ); - } - - return ( - { - e.preventDefault(); - e.stopPropagation(); - onToggle(); - }} - > - {isOpen ? ( - - ) : ( - - )} - - ); -} diff --git a/apps/client/src/features/share/hooks/use-shared-page-subpages.ts b/apps/client/src/features/share/hooks/use-shared-page-subpages.ts index 6112d3fdb..76b17912e 100644 --- a/apps/client/src/features/share/hooks/use-shared-page-subpages.ts +++ b/apps/client/src/features/share/hooks/use-shared-page-subpages.ts @@ -1,29 +1,13 @@ import { useMemo } from "react"; import { useAtomValue } from "jotai"; import { sharedTreeDataAtom } from "@/features/share/atoms/shared-page-atom"; -import { SharedPageTreeNode } from "@/features/share/utils"; +import { findSubpagesInTree } from "@/features/share/utils"; export function useSharedPageSubpages(pageId: string | undefined) { const treeData = useAtomValue(sharedTreeDataAtom); - return useMemo(() => { - if (!treeData || !pageId) return []; - - function findSubpages(nodes: SharedPageTreeNode[]): SharedPageTreeNode[] { - for (const node of nodes) { - if (node.value === pageId || node.slugId === pageId) { - return node.children || []; - } - if (node.children && node.children.length > 0) { - const subpages = findSubpages(node.children); - if (subpages.length > 0) { - return subpages; - } - } - } - return []; - } - - return findSubpages(treeData); - }, [treeData, pageId]); + return useMemo( + () => findSubpagesInTree(treeData, pageId), + [treeData, pageId], + ); } diff --git a/apps/client/src/features/share/hooks/use-toggle-toc.ts b/apps/client/src/features/share/hooks/use-toggle-toc.ts deleted file mode 100644 index ec43086a7..000000000 --- a/apps/client/src/features/share/hooks/use-toggle-toc.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { useAtom } from "jotai"; - -export function useToggleToc(tocAtom: any) { - const [tocState, setTocState] = useAtom(tocAtom); - return () => { - setTocState(!tocState); - } -} diff --git a/apps/client/src/features/share/utils.ts b/apps/client/src/features/share/utils.ts index d52016932..334cc786f 100644 --- a/apps/client/src/features/share/utils.ts +++ b/apps/client/src/features/share/utils.ts @@ -68,6 +68,27 @@ export function buildSharedPageTree( } +// Returns the children of the node matching the page id or slugId. +export function findSubpagesInTree( + tree: SharedPageTreeNode[] | null | undefined, + pageId: string | undefined, +): SharedPageTreeNode[] { + if (!tree || !pageId) return []; + + for (const node of tree) { + if (node.value === pageId || node.slugId === pageId) { + return node.children || []; + } + if (node.children && node.children.length > 0) { + const subpages = findSubpagesInTree(node.children, pageId); + if (subpages.length > 0) { + return subpages; + } + } + } + return []; +} + // Recursively checks if a page exists in the shared page tree. export function isPageInTree( tree: SharedPageTreeNode[], diff --git a/apps/client/src/features/space/components/settings-modal.tsx b/apps/client/src/features/space/components/settings-modal.tsx index 3d24d100c..9383feda4 100644 --- a/apps/client/src/features/space/components/settings-modal.tsx +++ b/apps/client/src/features/space/components/settings-modal.tsx @@ -4,6 +4,8 @@ import AddSpaceMembersModal from "@/features/space/components/add-space-members- import React from "react"; import SpaceDetails from "@/features/space/components/space-details.tsx"; import SpaceSecuritySettings from "@/features/space/components/space-security-settings.tsx"; +import PublishSpaceSettings from "@/features/public-space/components/publish-space-settings.tsx"; +import { isPublicSpacesAllowed } from "@/features/public-space/utils/public-space-access.ts"; import { useSpaceQuery } from "@/features/space/queries/space-query.ts"; import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts"; import { @@ -11,6 +13,8 @@ import { SpaceCaslSubject, } from "@/features/space/permissions/permissions.type.ts"; import { useTranslation } from "react-i18next"; +import { useAtom } from "jotai"; +import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; interface SpaceSettingsModalProps { spaceId: string; @@ -29,6 +33,13 @@ export default function SpaceSettingsModal({ const spaceRules = space?.membership?.permissions; const spaceAbility = useSpaceAbility(spaceRules); + const [workspace] = useAtom(workspaceAtom); + const allowPublicSpaces = isPublicSpacesAllowed(workspace); + const canManageSettings = spaceAbility.can( + SpaceCaslAction.Manage, + SpaceCaslSubject.Settings, + ); + return ( <> {t("Members")} - {spaceAbility.can( - SpaceCaslAction.Manage, - SpaceCaslSubject.Settings, - ) && ( + {canManageSettings && allowPublicSpaces && ( + + {t("Publish")} + + )} + {canManageSettings && ( {t("Security")} @@ -114,6 +127,16 @@ export default function SpaceSettingsModal({
    + + + +
    + {canManageSettings && allowPublicSpaces && ( + + )} +
    +
    +
    diff --git a/apps/client/src/features/space/components/sidebar/space-sidebar.tsx b/apps/client/src/features/space/components/sidebar/space-sidebar.tsx index ac67deeb9..dc13bc46b 100644 --- a/apps/client/src/features/space/components/sidebar/space-sidebar.tsx +++ b/apps/client/src/features/space/components/sidebar/space-sidebar.tsx @@ -35,7 +35,7 @@ import clsx from "clsx"; import { useDisclosure } from "@mantine/hooks"; import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx"; import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts"; -import { getSpaceUrl } from "@/lib/config.ts"; +import { getSpaceUrl, isBetaPublicSpaces } from "@/lib/config.ts"; import SpaceTree from "@/features/page/tree/components/space-tree.tsx"; import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts"; import { @@ -106,6 +106,7 @@ export function SpaceSidebar() { spaceName={space?.name} spaceSlug={space?.slug} spaceIcon={space?.logo} + isPublished={isBetaPublicSpaces() && space?.isPublished} />
    diff --git a/apps/client/src/features/space/components/sidebar/switch-space.tsx b/apps/client/src/features/space/components/sidebar/switch-space.tsx index ff07572fd..447a5bf94 100644 --- a/apps/client/src/features/space/components/sidebar/switch-space.tsx +++ b/apps/client/src/features/space/components/sidebar/switch-space.tsx @@ -2,24 +2,32 @@ import classes from "./switch-space.module.css"; import { useNavigate } from "react-router-dom"; import { SpaceSelect } from "./space-select"; import { getSpaceUrl } from "@/lib/config"; -import { Button, Popover, Text } from "@mantine/core"; -import { IconChevronDown, IconChevronUp } from "@tabler/icons-react"; +import { Button, Popover, Text, Tooltip } from "@mantine/core"; +import { + IconChevronDown, + IconChevronUp, + IconWorld, +} from "@tabler/icons-react"; import { useDisclosure } from "@mantine/hooks"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts"; import React from "react"; +import { useTranslation } from "react-i18next"; interface SwitchSpaceProps { spaceName: string; spaceSlug: string; spaceIcon?: string; + isPublished?: boolean; } export function SwitchSpace({ spaceName, spaceSlug, spaceIcon, + isPublished, }: SwitchSpaceProps) { + const { t } = useTranslation(); const navigate = useNavigate(); const [opened, { close, toggle }] = useDisclosure(false); @@ -61,6 +69,16 @@ export function SwitchSpace({ {spaceName} + + {isPublished && ( + + + + )} diff --git a/apps/client/src/features/space/types/space.types.ts b/apps/client/src/features/space/types/space.types.ts index 6937b233c..733a17d32 100644 --- a/apps/client/src/features/space/types/space.types.ts +++ b/apps/client/src/features/space/types/space.types.ts @@ -30,6 +30,7 @@ export interface ISpace { createdAt: Date; updatedAt: Date; memberCount?: number; + isPublished?: boolean; spaceId?: string; membership?: IMembership; settings?: ISpaceSettings; diff --git a/apps/client/src/features/transclusion/services/transclusion-api.ts b/apps/client/src/features/transclusion/services/transclusion-api.ts index 9aa695221..bf164d988 100644 --- a/apps/client/src/features/transclusion/services/transclusion-api.ts +++ b/apps/client/src/features/transclusion/services/transclusion-api.ts @@ -19,6 +19,14 @@ export async function lookupTransclusionForShare(params: { return r.data; } +export async function lookupTransclusionForPublicSpace(params: { + spaceSlug: string; + references: Array<{ sourcePageId: string; transclusionId: string }>; +}): Promise<{ items: TransclusionLookup[] }> { + const r = await api.post("/public-spaces/transclusion/lookup", params); + return r.data; +} + export async function listReferences(params: { sourcePageId: string; transclusionId: string; diff --git a/apps/client/src/features/workspace/components/settings/components/allow-public-spaces.tsx b/apps/client/src/features/workspace/components/settings/components/allow-public-spaces.tsx new file mode 100644 index 000000000..90078f633 --- /dev/null +++ b/apps/client/src/features/workspace/components/settings/components/allow-public-spaces.tsx @@ -0,0 +1,129 @@ +import { Group, Text, Switch } from "@mantine/core"; +import { modals } from "@mantine/modals"; +import { useAtom } from "jotai"; +import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; +import { notifications } from "@mantine/notifications"; + +export default function AllowPublicSpaces() { + const { t } = useTranslation(); + const [workspace] = useAtom(workspaceAtom); + + return ( + <> + +
    + {t("Allow public spaces")} + + {t("Space admins can publish their spaces to the web.")} + +
    + + +
    + + {workspace?.settings?.publicSpaces?.enabled === true && ( + +
    + {t("Show public directory")} + + {t("List published spaces at /docs for anyone to browse.")} + +
    + + +
    + )} + + ); +} + +function PublicSpacesDirectoryToggle() { + const { t } = useTranslation(); + const [workspace, setWorkspace] = useAtom(workspaceAtom); + const [checked, setChecked] = useState( + workspace?.settings?.publicSpaces?.directory === true, + ); + + const handleChange = async (event: React.ChangeEvent) => { + const value = event.currentTarget.checked; + try { + const updatedWorkspace = await updateWorkspace({ + publicSpacesDirectory: value, + }); + setChecked(value); + setWorkspace(updatedWorkspace); + } catch (err) { + notifications.show({ + message: err?.response?.data?.message, + color: "red", + }); + } + }; + + return ( + + ); +} + +function AllowPublicSpacesToggle() { + const { t } = useTranslation(); + const [workspace, setWorkspace] = useAtom(workspaceAtom); + const [checked, setChecked] = useState( + workspace?.settings?.publicSpaces?.enabled === true, + ); + + const applyChange = async (value: boolean) => { + try { + const updatedWorkspace = await updateWorkspace({ + allowPublicSpaces: value, + }); + setChecked(value); + setWorkspace(updatedWorkspace); + } catch (err) { + notifications.show({ + message: err?.response?.data?.message, + color: "red", + }); + } + }; + + const handleChange = (event: React.ChangeEvent) => { + const value = event.currentTarget.checked; + modals.openConfirmModal({ + title: value ? t("Allow public spaces") : t("Disable public spaces"), + children: ( + + {value + ? t( + "Space admins will be able to make their spaces publicly readable by anyone on the internet. Are you sure?", + ) + : t( + "This will immediately unpublish every published space. Re-enabling later will not republish them. Are you sure?", + )} + + ), + centered: true, + labels: { + confirm: value ? t("Allow") : t("Disable"), + cancel: t("Cancel"), + }, + confirmProps: value ? undefined : { color: "red" }, + onConfirm: () => applyChange(value), + }); + }; + + return ( + + ); +} diff --git a/apps/client/src/features/workspace/types/workspace.types.ts b/apps/client/src/features/workspace/types/workspace.types.ts index 80bf1f69d..2d3c8679d 100644 --- a/apps/client/src/features/workspace/types/workspace.types.ts +++ b/apps/client/src/features/workspace/types/workspace.types.ts @@ -24,6 +24,8 @@ export interface IWorkspace { aiSearch?: boolean; generativeAi?: boolean; disablePublicSharing?: boolean; + allowPublicSpaces?: boolean; + publicSpacesDirectory?: boolean; mcpEnabled?: boolean; aiChatReadOnly?: boolean; aiChatWorkspaceKnowledgeOnly?: boolean; @@ -42,6 +44,7 @@ export interface IWorkspaceSettings { api?: IWorkspaceApiSettings; templates?: IWorkspaceTemplateSettings; spaces?: IWorkspaceSpaceSettings; + publicSpaces?: IWorkspacePublicSpacesSettings; defaultPageEditMode?: string; } @@ -71,6 +74,11 @@ export interface IWorkspaceSpaceSettings { allowPersonal?: boolean; } +export interface IWorkspacePublicSpacesSettings { + enabled?: boolean; + directory?: boolean; +} + export interface ICreateInvite { role: string; emails: string[]; diff --git a/apps/client/src/lib/api-client.ts b/apps/client/src/lib/api-client.ts index c39b70a1e..cde9317ac 100644 --- a/apps/client/src/lib/api-client.ts +++ b/apps/client/src/lib/api-client.ts @@ -32,6 +32,13 @@ api.interceptors.response.use( const url = new URL(error.request.responseURL)?.pathname; if (url === "/api/auth/collab-token") return; if (window.location.pathname.startsWith("/share/")) return; + // public docs probe authed endpoints; reject without the login redirect + if ( + window.location.pathname === "/docs" || + window.location.pathname.startsWith("/docs/") + ) { + break; + } // Handle unauthorized error redirectToLogin(); diff --git a/apps/client/src/lib/config.ts b/apps/client/src/lib/config.ts index 388447060..246017114 100644 --- a/apps/client/src/lib/config.ts +++ b/apps/client/src/lib/config.ts @@ -43,6 +43,10 @@ export function isCloud(): boolean { return castToBoolean(getConfigValue("CLOUD")); } +export function isBetaPublicSpaces(): boolean { + return castToBoolean(getConfigValue("BETA_PUBLIC_SPACES")); +} + export function getAiVectorDriver(): string { return getConfigValue("AI_VECTOR_DRIVER"); } diff --git a/apps/client/src/pages/public-space/public-space-directory-page.tsx b/apps/client/src/pages/public-space/public-space-directory-page.tsx new file mode 100644 index 000000000..1b50aea06 --- /dev/null +++ b/apps/client/src/pages/public-space/public-space-directory-page.tsx @@ -0,0 +1,235 @@ +import "@fontsource-variable/inter"; +import "@/styles/public-typography.css"; +import { useMemo, useState } from "react"; +import { Skeleton } from "@mantine/core"; +import { useMediaQuery } from "@mantine/hooks"; +import clsx from "clsx"; +import { Link } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { IconSearch } from "@tabler/icons-react"; +import { usePublicSpaceDirectoryQuery } from "@/features/public-space/queries/public-space-query.ts"; +import { useAuthenticatedUser } from "@/features/public-space/hooks/use-authenticated-user.ts"; +import { buildPublicSpaceUrl } from "@/features/page/page.utils.ts"; +import { getAvatarUrl } from "@/lib/config.ts"; +import { Error404 } from "@/components/ui/error-404.tsx"; +import { DocumentTitle } from "@/components/ui/document-title.tsx"; +import { MAIN_CONTENT_ID, SkipToMain } from "@/components/ui/skip-to-main.tsx"; +import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts"; +import styles from "@/features/public-space/components/docs/docs-hub.module.css"; + +const TILE_COLORS = [ + "#1f9d55", + "#2b4bd6", + "#7c3aed", + "#0e7490", + "#d9480f", + "#b42318", + "#a16207", + "#475569", + "#be185d", + "#0f766e", + "#4338ca", + "#65a30d", +]; + +function getInitials(name: string) { + return name + .split(/[\s&]+/) + .filter(Boolean) + .slice(0, 2) + .map((word) => word[0]) + .join("") + .toUpperCase(); +} + +export default function PublicSpaceDirectoryPage() { + const { t } = useTranslation(); + const { data, isLoading, isError, error } = usePublicSpaceDirectoryQuery(); + const { data: currentUser } = useAuthenticatedUser(); + const isMobile = useMediaQuery("(max-width: 48em)"); + const [query, setQuery] = useState(""); + + const spaces = useMemo(() => { + const all = (data?.spaces ?? []).map((space, index) => ({ + ...space, + color: TILE_COLORS[index % TILE_COLORS.length], + initials: getInitials(space.name), + })); + const needle = query.trim().toLowerCase(); + if (!needle) return all; + return all.filter( + (space) => + space.name.toLowerCase().includes(needle) || + space.description?.toLowerCase().includes(needle), + ); + }, [data?.spaces, query]); + + if (isError) { + if ([401, 403, 404].includes(error?.["response"]?.status)) { + return ; + } + return
    {t("Error fetching page data.")}
    ; + } + + const total = data?.spaces.length ?? 0; + const title = t("Documentation"); + const searchLabel = isMobile ? t("Search...") : t("Search documentation..."); + + return ( +
    + + + + +
    +
    + + + {title.charAt(0).toUpperCase()} + + {title} + + +
    + {currentUser?.user ? ( + + {t("Open app")} + + ) : ( + + {t("Sign in")} + + )} +
    +
    +
    + +
    +
    +

    + {t("Welcome to our documentation")} +

    +

    + {isMobile + ? t("Guides, references and answers across all our spaces.") + : t( + "Guides, references and answers across all our published spaces.", + )} +

    + +
    event.preventDefault()} + > + setQuery(event.target.value)} + placeholder={searchLabel} + aria-label={searchLabel} + /> + +
    +
    +
    + +
    +
    +
    +

    {t("Spaces")}

    + {!isLoading && ( + + {total === 1 + ? t("1 published space") + : t("{{count}} published spaces", { count: total })} + + )} +
    + + {isLoading && ( +
    + + + + +
    + )} + + {!isLoading && total === 0 && ( +

    {t("No public spaces yet.")}

    + )} + + {!isLoading && total > 0 && spaces.length === 0 && ( +

    + {t("No spaces match your search.")} +

    + )} + + {spaces.length > 0 && ( +
    + {spaces.map((space) => { + const logoUrl = getAvatarUrl( + space.logo, + AvatarIconType.SPACE_ICON, + ); + return ( + + + + {logoUrl ? ( + + ) : ( + space.initials + )} + + {space.name} + + {space.description && ( + + {space.description} + + )} + + ); + })} +
    + )} +
    +
    + + {data && ( + + )} +
    + ); +} diff --git a/apps/client/src/pages/public-space/public-space-page.tsx b/apps/client/src/pages/public-space/public-space-page.tsx new file mode 100644 index 000000000..d3fc09117 --- /dev/null +++ b/apps/client/src/pages/public-space/public-space-page.tsx @@ -0,0 +1,125 @@ +import { useParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { Skeleton, Stack, Text } from "@mantine/core"; +import React from "react"; +import ReadonlyPageEditor from "@/features/editor/readonly-page-editor.tsx"; +import { extractPageSlugId } from "@/lib"; +import { Error404 } from "@/components/ui/error-404.tsx"; +import { usePublicSpacePageQuery } from "@/features/public-space/queries/public-space-query.ts"; +import { DocumentTitle } from "@/components/ui/document-title.tsx"; +import DocsBreadcrumbs from "@/features/public-space/components/docs/docs-breadcrumbs.tsx"; +import DocsPageNav from "@/features/public-space/components/docs/docs-page-nav.tsx"; +import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; +import { timeAgo } from "@/lib/time.ts"; +import styles from "@/features/public-space/components/docs/docs.module.css"; + +export default function PublicSpacePage() { + const { t } = useTranslation(); + const { spaceSlug, pageSlug } = useParams(); + + const { data, isLoading, isError, error } = usePublicSpacePageQuery({ + spaceSlug, + pageSlugId: pageSlug ? extractPageSlugId(pageSlug) : undefined, + }); + + if (isLoading) { + return ( + + + + + + + + + ); + } + + if (isError || !data) { + if ([401, 403, 404].includes(error?.["status"])) { + return ; + } + return
    {t("Error fetching page data.")}
    ; + } + + if (data.page === null) { + return ( +
    + {t("This space has no public pages yet.")} +
    + ); + } + + const showAuthor = + data.byline?.author === true && Boolean(data.page.creator?.name); + const showUpdatedAt = + data.byline?.updatedAt === true && Boolean(data.page.updatedAt); + + return ( +
    + + {!data.searchIndexing && } + + + + + + } + trailingSpace={false} + /> + + +
    + ); +} + +type DocsBylineProps = { + creator?: { name: string; avatarUrl: string }; + updatedAt?: Date | string; +}; + +function DocsByline({ creator, updatedAt }: DocsBylineProps) { + const { t } = useTranslation(); + + if (!creator && !updatedAt) return null; + + return ( +
    + {creator && ( + + + {t("By {{name}}", { name: creator.name })} + + )} + + {creator && updatedAt && ( + + • + + )} + + {updatedAt && ( + + {t("Updated {{date}}", { date: timeAgo(new Date(updatedAt)) })} + + )} +
    + ); +} diff --git a/apps/client/src/pages/settings/shares/shares.tsx b/apps/client/src/pages/settings/shares/shares.tsx index d327681af..23a74827e 100644 --- a/apps/client/src/pages/settings/shares/shares.tsx +++ b/apps/client/src/pages/settings/shares/shares.tsx @@ -1,19 +1,23 @@ import SettingsTitle from "@/components/settings/settings-title.tsx"; import { useTranslation } from "react-i18next"; +import { useAtom } from "jotai"; import ShareList from "@/features/share/components/share-list.tsx"; -import { Alert, Text } from "@mantine/core"; +import PublishedSpacesList from "@/features/public-space/components/published-spaces-list.tsx"; +import { isPublicSpacesAllowed } from "@/features/public-space/utils/public-space-access.ts"; +import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; +import { Alert, Tabs } from "@mantine/core"; import { IconInfoCircle } from "@tabler/icons-react"; import React from "react"; import { DocumentTitle } from "@/components/ui/document-title.tsx"; export default function Shares() { const { t } = useTranslation(); + const [workspace] = useAtom(workspaceAtom); - return ( + const allowPublicSpaces = isPublicSpacesAllowed(workspace); + + const sharedPages = ( <> - - - }> {t( "Publicly shared pages from spaces you are a member of will appear here", @@ -23,4 +27,38 @@ export default function Shares() { ); + + return ( + <> + + + + {allowPublicSpaces ? ( + + + + {t("Shared pages")} + + + {t("Published spaces")} + + + + + {sharedPages} + + + + }> + {t("Spaces published to the web will appear here")} + + + + + + ) : ( + sharedPages + )} + + ); } diff --git a/apps/client/src/pages/settings/workspace/workspace-settings.tsx b/apps/client/src/pages/settings/workspace/workspace-settings.tsx index dc9e7c5fc..e43586ecc 100644 --- a/apps/client/src/pages/settings/workspace/workspace-settings.tsx +++ b/apps/client/src/pages/settings/workspace/workspace-settings.tsx @@ -2,12 +2,13 @@ import SettingsTitle from "@/components/settings/settings-title.tsx"; import WorkspaceNameForm from "@/features/workspace/components/settings/components/workspace-name-form"; import WorkspaceIcon from "@/features/workspace/components/settings/components/workspace-icon.tsx"; import { useTranslation } from "react-i18next"; -import { isCloud } from "@/lib/config.ts"; +import { isBetaPublicSpaces, isCloud } from "@/lib/config.ts"; import ManageHostname from "@/ee/components/manage-hostname.tsx"; import { Divider } from "@mantine/core"; import AllowMemberTemplates from "@/ee/security/components/allow-member-templates.tsx"; import WorkspaceDefaultPageEditMode from "@/features/workspace/components/settings/components/workspace-default-page-edit-mode.tsx"; import PersonalSpacesSetting from "@/ee/personal-space/components/personal-spaces-setting.tsx"; +import AllowPublicSpaces from "@/features/workspace/components/settings/components/allow-public-spaces.tsx"; import { DocumentTitle } from "@/components/ui/document-title.tsx"; export default function WorkspaceSettings() { @@ -25,6 +26,13 @@ export default function WorkspaceSettings() { + {isBetaPublicSpaces() && ( + <> + + + + )} + {isCloud() && ( <> diff --git a/apps/client/src/pages/share/shared-page.tsx b/apps/client/src/pages/share/shared-page.tsx index b7028b2fb..dbce86233 100644 --- a/apps/client/src/pages/share/shared-page.tsx +++ b/apps/client/src/pages/share/shared-page.tsx @@ -1,19 +1,18 @@ import { useNavigate, useParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { useSharePageQuery } from "@/features/share/queries/share-query.ts"; -import { Container } from "@mantine/core"; +import { Skeleton, Stack } from "@mantine/core"; import React, { useEffect } from "react"; import ReadonlyPageEditor from "@/features/editor/readonly-page-editor.tsx"; import { extractPageSlugId } from "@/lib"; import { Error404 } from "@/components/ui/error-404.tsx"; -import ShareBranding from "@/features/share/components/share-branding.tsx"; import { useAtomValue } from "jotai"; -import { - sharedPageFullWidthAtom, - sharedTreeDataAtom, -} from "@/features/share/atoms/shared-page-atom.ts"; +import { sharedTreeDataAtom } from "@/features/share/atoms/shared-page-atom.ts"; import { isPageInTree } from "@/features/share/utils.ts"; import { DocumentTitle } from "@/components/ui/document-title.tsx"; +import DocsBreadcrumbs from "@/features/public-space/components/docs/docs-breadcrumbs.tsx"; +import DocsPageNav from "@/features/public-space/components/docs/docs-page-nav.tsx"; +import DocsFooterBranding from "@/features/public-space/components/docs/docs-footer-branding.tsx"; export default function SharedPage() { const { t } = useTranslation(); @@ -26,7 +25,6 @@ export default function SharedPage() { }); const sharedTreeData = useAtomValue(sharedTreeDataAtom); - const fullWidth = useAtomValue(sharedPageFullWidthAtom); useEffect(() => { if (shareId && data) { @@ -44,7 +42,16 @@ export default function SharedPage() { }, [shareId, data, sharedTreeData]); if (isLoading) { - return <>; + return ( + + + + + + + + + ); } if (isError || !data) { @@ -65,17 +72,21 @@ export default function SharedPage() { )} - - - + - {data && !shareId && !(data.features?.length > 0) && } + + + + + {/* No tree query without a shareId, so the shell can't own branding here. */} + {!shareId && } ); } diff --git a/apps/client/src/pages/space/space-home.tsx b/apps/client/src/pages/space/space-home.tsx index df8e297d2..8b349edde 100644 --- a/apps/client/src/pages/space/space-home.tsx +++ b/apps/client/src/pages/space/space-home.tsx @@ -1,5 +1,6 @@ import {Container} from "@mantine/core"; import SpaceHomeTabs from "@/features/space/components/space-home-tabs.tsx"; +import SpacePublicNotice from "@/features/public-space/components/space-public-notice.tsx"; import {useParams} from "react-router-dom"; import {useGetSpaceBySlugQuery} from "@/features/space/queries/space-query.ts"; import { DocumentTitle } from "@/components/ui/document-title.tsx"; @@ -12,6 +13,7 @@ export default function SpaceHome() { <> + {space && } {space && } diff --git a/apps/client/src/styles/a11y-overrides.css b/apps/client/src/styles/a11y-overrides.css index 1fb4264d9..5114b5865 100644 --- a/apps/client/src/styles/a11y-overrides.css +++ b/apps/client/src/styles/a11y-overrides.css @@ -61,3 +61,13 @@ var(--mantine-color-dark-5) ); } + +/* The skip-link target main is tabindex="-1", so clicks in readonly content + * focus it and overlays (e.g. the lightbox) return focus to it on close, + * ringing the whole content region. The skip link's purpose is reading + * position, not a visible ring around everything, so suppress the outline. + */ +#main-content:focus, +#main-content:focus-visible { + outline: none; +} diff --git a/apps/client/src/styles/public-typography.css b/apps/client/src/styles/public-typography.css new file mode 100644 index 000000000..505e315b3 --- /dev/null +++ b/apps/client/src/styles/public-typography.css @@ -0,0 +1,39 @@ +/* Reading typography for the public surfaces (public spaces and share links). + * Scoped to the .public-typography root so the authenticated app keeps the + * system font stack. Loaded only from the lazy public layouts. */ + +.public-typography { + font-family: "Inter Variable", var(--mantine-font-family); + letter-spacing: -0.011em; + -webkit-font-smoothing: antialiased; +} + +/* Code keeps its own mono family; only undo the inherited tracking. */ +.public-typography code, +.public-typography pre { + letter-spacing: normal; +} + +/* Pins the reading metrics that core.css otherwise takes from the Mantine + * type scale. rem, not px, so browser font-size preferences still apply. */ +.public-typography .ProseMirror { + font-size: 1rem; + line-height: 1.65; +} + +.public-typography .ProseMirror h1 { + letter-spacing: -0.021em; +} + +.public-typography .ProseMirror h2 { + letter-spacing: -0.017em; +} + +.public-typography .ProseMirror h3, +.public-typography .ProseMirror h4 { + letter-spacing: -0.014em; +} + +.public-typography .page-title .ProseMirror h1 { + letter-spacing: -0.022em; +} diff --git a/apps/client/vite.config.ts b/apps/client/vite.config.ts index d1127bca9..e4361fb4b 100644 --- a/apps/client/vite.config.ts +++ b/apps/client/vite.config.ts @@ -17,6 +17,7 @@ export default defineConfig(({ mode }) => { POSTHOG_HOST, POSTHOG_KEY, AI_VECTOR_DRIVER, + BETA_PUBLIC_SPACES, } = loadEnv(mode, envPath, ""); return { @@ -33,6 +34,7 @@ export default defineConfig(({ mode }) => { POSTHOG_HOST, POSTHOG_KEY, AI_VECTOR_DRIVER, + BETA_PUBLIC_SPACES, }, APP_VERSION: JSON.stringify(process.env.npm_package_version), }, diff --git a/apps/server/src/common/features.ts b/apps/server/src/common/features.ts index 0afb3ca61..ac446d325 100644 --- a/apps/server/src/common/features.ts +++ b/apps/server/src/common/features.ts @@ -26,6 +26,7 @@ export const Feature = { OAUTH: 'oauth', AI_CONTROLS: 'ai:controls', MCP_CONTROLS: 'mcp:controls', + PUBLIC_SPACE_APPEARANCE: 'public-space:appearance', SIEM: 'siem', } as const; diff --git a/apps/server/src/core/core.module.ts b/apps/server/src/core/core.module.ts index e898a4a1c..5bd9be1c1 100644 --- a/apps/server/src/core/core.module.ts +++ b/apps/server/src/core/core.module.ts @@ -18,6 +18,7 @@ import { PageAccessModule } from './page/page-access/page-access.module'; import { DomainMiddleware } from '../common/middlewares/domain.middleware'; import { AuditContextMiddleware } from '../common/middlewares/audit-context.middleware'; import { ShareModule } from './share/share.module'; +import { PublicSpaceModule } from './public-space/public-space.module'; import { LabelModule } from './label/label.module'; import { NotificationModule } from './notification/notification.module'; import { WatcherModule } from './watcher/watcher.module'; @@ -40,6 +41,7 @@ import { ClsMiddleware } from 'nestjs-cls'; CaslModule, PageAccessModule, ShareModule, + PublicSpaceModule, LabelModule, NotificationModule, WatcherModule, diff --git a/apps/server/src/core/public-space/dto/public-space.dto.ts b/apps/server/src/core/public-space/dto/public-space.dto.ts new file mode 100644 index 000000000..8a27fc4b5 --- /dev/null +++ b/apps/server/src/core/public-space/dto/public-space.dto.ts @@ -0,0 +1,79 @@ +import { + IsBoolean, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, + Matches, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { LookupDto } from '../../page/transclusion/dto/lookup.dto'; + +export const APPEARANCE_HEX_REGEX = /^#[0-9a-fA-F]{6}$/; + +export class PublicSpaceSlugDto { + @IsString() + @IsNotEmpty() + spaceSlug: string; +} + +export class PublicSpacePageDto extends PublicSpaceSlugDto { + @IsString() + @IsOptional() + pageSlugId?: string; + + @IsOptional() + @IsBoolean() + contentless?: boolean; +} + +export class PublicSpaceTransclusionLookupDto extends LookupDto { + @IsString() + @IsNotEmpty() + spaceSlug!: string; +} + +export class PublicSpaceAppearanceDto { + @IsOptional() + @Matches(APPEARANCE_HEX_REGEX) + primaryColorLight?: string | null; + + @IsOptional() + @Matches(APPEARANCE_HEX_REGEX) + primaryColorDark?: string | null; +} + +export class PublishSpaceDto { + @IsUUID() + spaceId: string; + + @IsBoolean() + enabled: boolean; + + @IsOptional() + @IsBoolean() + searchIndexing?: boolean; + + @IsOptional() + @ValidateNested() + @Type(() => PublicSpaceAppearanceDto) + appearance?: PublicSpaceAppearanceDto; + + @IsOptional() + @IsBoolean() + bylineAuthor?: boolean; + + @IsOptional() + @IsBoolean() + bylineUpdatedAt?: boolean; + + @IsOptional() + @IsBoolean() + directory?: boolean; +} + +export class PublicSpaceForSpaceDto { + @IsUUID() + spaceId: string; +} diff --git a/apps/server/src/core/public-space/public-space-seo.controller.ts b/apps/server/src/core/public-space/public-space-seo.controller.ts new file mode 100644 index 000000000..f35ecc5e5 --- /dev/null +++ b/apps/server/src/core/public-space/public-space-seo.controller.ts @@ -0,0 +1,173 @@ +import { Controller, Get, Logger, Param, Req, Res } from '@nestjs/common'; +import { FastifyReply, FastifyRequest } from 'fastify'; +import { join } from 'path'; +import * as fs from 'node:fs'; +import { validate as isValidUUID } from 'uuid'; +import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; +import { EnvironmentService } from '../../integrations/environment/environment.service'; +import { Workspace } from '@docmost/db/types/entity.types'; +import { htmlEscape } from '../../common/helpers/html-escaper'; +import { PublicSpaceService } from './public-space.service'; + +@Controller('docs') +export class PublicSpaceSeoController { + private readonly logger = new Logger(PublicSpaceSeoController.name); + + constructor( + private readonly publicSpaceService: PublicSpaceService, + private workspaceRepo: WorkspaceRepo, + private environmentService: EnvironmentService, + ) {} + + /* + * The /docs hub: inject meta only when the directory is enabled; + * otherwise the untouched SPA shell (uniform with 404s). + */ + @Get() + async getDirectoryHub( + @Res({ passthrough: false }) res: FastifyReply, + @Req() req: FastifyRequest, + ) { + const workspace = await this.resolveWorkspace(req); + + const clientDistPath = join( + __dirname, + '..', + '..', + '..', + '..', + 'client/dist', + ); + if (!fs.existsSync(clientDistPath)) { + return; + } + const indexFilePath = join(clientDistPath, 'index.html'); + + if (!workspace) { + return this.sendIndex(indexFilePath, res); + } + + try { + await this.publicSpaceService.getPublicSpaceDirectory(workspace); + } catch (err) { + return this.sendIndex(indexFilePath, res); + } + + const metaTitle = 'Documentation'; + const metaTagVar = ''; + const metaTags = ``; + + const html = fs.readFileSync(indexFilePath, 'utf8'); + const transformedHtml = html + .replace(/[\s\S]*?<\/title>/i, () => `<title>${metaTitle}`) + .replace(metaTagVar, () => metaTags); + + res.type('text/html').send(transformedHtml); + } + + /* + * add meta tags to public space pages + */ + @Get([':spaceSlug', ':spaceSlug/:pageSlug']) + async getPublicSpacePage( + @Res({ passthrough: false }) res: FastifyReply, + @Req() req: FastifyRequest, + @Param('spaceSlug') spaceSlug: string, + @Param('pageSlug') pageSlug: string, + ) { + const workspace = await this.resolveWorkspace(req); + + const clientDistPath = join( + __dirname, + '..', + '..', + '..', + '..', + 'client/dist', + ); + + if (!fs.existsSync(clientDistPath)) { + return; + } + const indexFilePath = join(clientDistPath, 'index.html'); + + if (!workspace) { + return this.sendIndex(indexFilePath, res); + } + + let title: string = null; + let searchIndexing = false; + + try { + if (pageSlug) { + const pageSlugId = this.extractPageSlugId(pageSlug); + const pageData = await this.publicSpaceService.getPublicPage( + spaceSlug, + pageSlugId, + workspace, + { includeContent: false }, + ); + title = pageData.page?.title ?? pageData.space.name; + searchIndexing = pageData.searchIndexing; + } else { + const info = await this.publicSpaceService.getPublicSpaceInfo( + spaceSlug, + workspace, + ); + title = info.space.name; + searchIndexing = info.searchIndexing; + } + } catch (err) { + // Not public: serve the untouched SPA shell with zero injected meta. + this.logger.debug(`no public meta for ${spaceSlug}`); + return this.sendIndex(indexFilePath, res); + } + + const rawTitle = htmlEscape(title ?? 'untitled'); + const metaTitle = + rawTitle.length > 80 ? `${rawTitle.slice(0, 77)}…` : rawTitle; + + const metaTagVar = ''; + const metaTags = [ + ``, + ``, + !searchIndexing ? `` : '', + ] + .filter(Boolean) + .join('\n '); + + const html = fs.readFileSync(indexFilePath, 'utf8'); + const transformedHtml = html + .replace(/[\s\S]*?<\/title>/i, () => `<title>${metaTitle}`) + .replace(metaTagVar, () => metaTags); + + res.type('text/html').send(transformedHtml); + } + + // Prefix-excluded routes skip middleware, so resolve the workspace inline + // exactly like ShareSeoController does. + private async resolveWorkspace(req: FastifyRequest): Promise { + if (this.environmentService.isSelfHosted()) { + return this.workspaceRepo.findFirst(); + } + const header = req.raw.headers.host; + const subdomain = header.split('.')[0]; + return this.workspaceRepo.findByHostname(subdomain); + } + + sendIndex(indexFilePath: string, res: FastifyReply) { + const stream = fs.createReadStream(indexFilePath); + res.type('text/html').send(stream); + } + + extractPageSlugId(slug: string): string { + if (!slug) { + return undefined; + } + if (isValidUUID(slug)) { + return slug; + } + const parts = slug.split('-'); + return parts.length > 1 ? parts[parts.length - 1] : slug; + } +} diff --git a/apps/server/src/core/public-space/public-space.controller.ts b/apps/server/src/core/public-space/public-space.controller.ts new file mode 100644 index 000000000..d60ebc8d9 --- /dev/null +++ b/apps/server/src/core/public-space/public-space.controller.ts @@ -0,0 +1,249 @@ +import { + Body, + Controller, + ForbiddenException, + HttpCode, + HttpStatus, + Inject, + NotFoundException, + Post, + UseGuards, +} from '@nestjs/common'; +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 { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { Public } from '../../common/decorators/public.decorator'; +import { PublicSpaceService } from './public-space.service'; +import { + PublicSpaceForSpaceDto, + PublicSpacePageDto, + PublicSpaceSlugDto, + PublicSpaceTransclusionLookupDto, + PublishSpaceDto, +} from './dto/public-space.dto'; +import { PublicSpaceRepo } from '@docmost/db/repos/public-space/public-space.repo'; +import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; +import { SpaceRepo } from '@docmost/db/repos/space/space.repo'; +import SpaceAbilityFactory from '../casl/abilities/space-ability.factory'; +import { + SpaceCaslAction, + SpaceCaslSubject, +} from '../casl/interfaces/space-ability.type'; +import { LicenseCheckService } from '../../integrations/environment/license-check.service'; +import { EnvironmentService } from '../../integrations/environment/environment.service'; +import { AuditEvent, AuditResource } from '../../common/events/audit-events'; +import { + AUDIT_SERVICE, + IAuditService, +} from '../../integrations/audit/audit.service'; +import { Feature, FeatureKey } from '../../common/features'; + +const PUBLIC_SPACE_FEATURES: FeatureKey[] = [Feature.PUBLIC_SPACE_APPEARANCE]; + +@UseGuards(JwtAuthGuard) +@Controller('public-spaces') +export class PublicSpaceController { + constructor( + private readonly publicSpaceService: PublicSpaceService, + private readonly publicSpaceRepo: PublicSpaceRepo, + private readonly spaceRepo: SpaceRepo, + private readonly spaceAbility: SpaceAbilityFactory, + private readonly licenseCheckService: LicenseCheckService, + private readonly environmentService: EnvironmentService, + @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, + ) {} + + private assertBetaPublicSpaces() { + if (!this.environmentService.isBetaPublicSpaces()) { + throw new ForbiddenException( + 'Public spaces are not enabled on this instance', + ); + } + } + + @HttpCode(HttpStatus.OK) + @Post('/') + async getPublishedSpaces( + @Body() pagination: PaginationOptions, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + this.assertBetaPublicSpaces(); + return this.publicSpaceRepo.getPublishedSpaces( + user.id, + workspace.id, + pagination, + ); + } + + @Public() + @HttpCode(HttpStatus.OK) + @Post('/info') + async getInfo( + @Body() dto: PublicSpaceSlugDto, + @AuthWorkspace() workspace: Workspace, + ) { + const info = await this.publicSpaceService.getPublicSpaceInfo( + dto.spaceSlug, + workspace, + ); + return { + ...info, + features: this.publicFeatures(workspace), + }; + } + + @Public() + @HttpCode(HttpStatus.OK) + @Post('/directory') + async getDirectory(@AuthWorkspace() workspace: Workspace) { + const directory = + await this.publicSpaceService.getPublicSpaceDirectory(workspace); + return { + ...directory, + features: this.publicFeatures(workspace), + }; + } + + @Public() + @HttpCode(HttpStatus.OK) + @Post('/tree') + async getTree( + @Body() dto: PublicSpaceSlugDto, + @AuthWorkspace() workspace: Workspace, + ) { + const treeData = await this.publicSpaceService.getPublicSpaceTree( + dto.spaceSlug, + workspace, + ); + return { + ...treeData, + features: this.publicFeatures(workspace), + }; + } + + @Public() + @HttpCode(HttpStatus.OK) + @Post('/page-info') + async getPageInfo( + @Body() dto: PublicSpacePageDto, + @AuthWorkspace() workspace: Workspace, + ) { + const pageData = await this.publicSpaceService.getPublicPage( + dto.spaceSlug, + dto.pageSlugId, + workspace, + { includeContent: dto.contentless !== true }, + ); + return { + ...pageData, + features: this.publicFeatures(workspace), + }; + } + + @Public() + @HttpCode(HttpStatus.OK) + @Post('/transclusion/lookup') + async transclusionLookup( + @Body() dto: PublicSpaceTransclusionLookupDto, + @AuthWorkspace() workspace: Workspace, + ) { + return this.publicSpaceService.lookupTransclusionForPublicSpace( + dto.spaceSlug, + dto.references, + workspace, + ); + } + + private publicFeatures(workspace: Workspace): string[] { + const features = this.licenseCheckService.resolveFeatures( + workspace.licenseKey, + workspace.plan, + ); + return PUBLIC_SPACE_FEATURES.filter((feature) => + features.includes(feature), + ); + } + + @HttpCode(HttpStatus.OK) + @Post('/for-space') + async getForSpace( + @Body() dto: PublicSpaceForSpaceDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + this.assertBetaPublicSpaces(); + const space = await this.spaceRepo.findById(dto.spaceId, workspace.id); + if (!space || space.deletedAt) { + throw new NotFoundException('Space not found'); + } + + const ability = await this.spaceAbility.createForUser(user, space.id); + if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) { + throw new ForbiddenException(); + } + + return (await this.publicSpaceRepo.findBySpaceId(space.id)) ?? null; + } + + @HttpCode(HttpStatus.OK) + @Post('/publish') + async publish( + @Body() dto: PublishSpaceDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + const space = await this.spaceRepo.findById(dto.spaceId, workspace.id); + if (!space || space.deletedAt) { + throw new NotFoundException('Space not found'); + } + + const ability = await this.spaceAbility.createForUser(user, space.id); + if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) { + throw new ForbiddenException(); + } + + const prev = await this.publicSpaceRepo.findBySpaceId(space.id); + + const publicSpace = await this.publicSpaceService.publish({ + space, + workspace, + authUserId: user.id, + enabled: dto.enabled, + searchIndexing: dto.searchIndexing, + appearance: dto.appearance, + bylineAuthor: dto.bylineAuthor, + bylineUpdatedAt: dto.bylineUpdatedAt, + directory: dto.directory, + }); + + const prevByline = (prev?.settings as any)?.byline; + const nextSettings = publicSpace?.settings as any; + + this.auditService.log({ + event: AuditEvent.SPACE_UPDATED, + resourceType: AuditResource.SPACE, + resourceId: space.id, + spaceId: space.id, + changes: { + before: { + isPublished: prev?.enabled ?? false, + searchIndexing: prev?.searchIndexing === true, + bylineAuthor: prevByline?.author === true, + bylineUpdatedAt: prevByline?.updatedAt !== false, + directory: (prev?.settings as any)?.directory === true, + }, + after: { + isPublished: publicSpace?.enabled ?? dto.enabled, + searchIndexing: publicSpace?.searchIndexing === true, + bylineAuthor: nextSettings?.byline?.author === true, + bylineUpdatedAt: nextSettings?.byline?.updatedAt !== false, + directory: nextSettings?.directory === true, + }, + }, + }); + + return publicSpace; + } +} diff --git a/apps/server/src/core/public-space/public-space.module.ts b/apps/server/src/core/public-space/public-space.module.ts new file mode 100644 index 000000000..466cc2055 --- /dev/null +++ b/apps/server/src/core/public-space/public-space.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { PublicSpaceController } from './public-space.controller'; +import { PublicSpaceSeoController } from './public-space-seo.controller'; +import { PublicSpaceService } from './public-space.service'; +import { ShareModule } from '../share/share.module'; +import { TransclusionModule } from '../page/transclusion/transclusion.module'; + +@Module({ + imports: [ShareModule, TransclusionModule], + controllers: [PublicSpaceController, PublicSpaceSeoController], + providers: [PublicSpaceService], + exports: [PublicSpaceService], +}) +export class PublicSpaceModule {} diff --git a/apps/server/src/core/public-space/public-space.service.spec.ts b/apps/server/src/core/public-space/public-space.service.spec.ts new file mode 100644 index 000000000..794f93f8c --- /dev/null +++ b/apps/server/src/core/public-space/public-space.service.spec.ts @@ -0,0 +1,1103 @@ +import { NotFoundException, ForbiddenException } from '@nestjs/common'; +import { PublicSpaceService } from './public-space.service'; +import { Feature } from '../../common/features'; + +const WORKSPACE_ID = '018f0000-0000-7000-8000-000000000001'; +const SPACE_ID = '018f0000-0000-7000-8000-000000000002'; + +function makeWorkspace(settings: any = {}) { + return { id: WORKSPACE_ID, settings } as any; +} + +const optInSettings = { publicSpaces: { enabled: true } }; + +function makeService(overrides: any = {}) { + const publicSpaceRepo = { + findBySpaceId: jest.fn().mockResolvedValue({ + id: 'ps1', + spaceId: SPACE_ID, + enabled: true, + searchIndexing: false, + }), + upsert: jest.fn().mockImplementation(async (opts) => opts), + ...overrides.publicSpaceRepo, + }; + const spaceRepo = { + findBySlug: jest.fn().mockResolvedValue({ + id: SPACE_ID, + slug: 'handbook', + name: 'Handbook', + workspaceId: WORKSPACE_ID, + deletedAt: null, + }), + ...overrides.spaceRepo, + }; + const pageRepo = { + findById: jest.fn().mockResolvedValue({ + id: 'page1', + slugId: 'abc123XYZ0', + spaceId: SPACE_ID, + workspaceId: WORKSPACE_ID, + deletedAt: null, + content: null, + }), + getSpacePagesExcludingRestricted: jest.fn().mockResolvedValue([]), + getFirstUnrestrictedRootPage: jest + .fn() + .mockResolvedValue({ id: 'page1', slugId: 'abc123XYZ0' }), + ...overrides.pageRepo, + }; + const pagePermissionRepo = { + hasRestrictedAncestor: jest.fn().mockResolvedValue(false), + ...overrides.pagePermissionRepo, + }; + const shareService = { + updatePublicAttachments: jest.fn().mockImplementation(async (p) => p.content), + sanitizeTransclusionItemsForPublic: jest + .fn() + .mockImplementation(async (items) => items), + ...overrides.shareService, + }; + const transclusionService = { + lookupWithAccessSet: jest.fn().mockResolvedValue({ items: [] }), + ...overrides.transclusionService, + }; + + const licenseCheckService = { + resolveFeatures: jest.fn().mockReturnValue([Feature.PUBLIC_SPACE_APPEARANCE]), + ...overrides.licenseCheckService, + }; + + const environmentService = { + isBetaPublicSpaces: jest.fn().mockReturnValue(true), + ...overrides.environmentService, + }; + + const service = new PublicSpaceService( + publicSpaceRepo as any, + spaceRepo as any, + pageRepo as any, + pagePermissionRepo as any, + shareService as any, + transclusionService as any, + licenseCheckService as any, + environmentService as any, + ); + return { + service, + publicSpaceRepo, + spaceRepo, + pageRepo, + pagePermissionRepo, + shareService, + transclusionService, + licenseCheckService, + environmentService, + }; +} + +describe('PublicSpaceService', () => { + describe('getPublicSpace', () => { + it('404s when the workspace opt-in is off (default)', async () => { + const { service } = makeService(); + await expect( + service.getPublicSpace('handbook', makeWorkspace({})), + ).rejects.toThrow(NotFoundException); + }); + + it('404s when BETA_PUBLIC_SPACES is off even with the workspace opt-in on', async () => { + const { service, spaceRepo } = makeService({ + environmentService: { + isBetaPublicSpaces: jest.fn().mockReturnValue(false), + }, + }); + const ws = makeWorkspace({ publicSpaces: { enabled: true } }); + await expect(service.getPublicSpace('handbook', ws)).rejects.toThrow( + NotFoundException, + ); + expect(spaceRepo.findBySlug).not.toHaveBeenCalled(); + }); + + it('resolves when public page sharing is disabled', async () => { + const { service } = makeService(); + const ws = makeWorkspace({ + publicSpaces: { enabled: true }, + sharing: { disabled: true }, + }); + const result = await service.getPublicSpace('handbook', ws); + + expect(result.space.id).toBe(SPACE_ID); + expect(result.publicSpace.enabled).toBe(true); + }); + + it('404s when the space row is disabled', async () => { + const { service } = makeService({ + publicSpaceRepo: { + findBySpaceId: jest.fn().mockResolvedValue({ enabled: false }), + }, + }); + await expect( + service.getPublicSpace('handbook', makeWorkspace(optInSettings)), + ).rejects.toThrow(NotFoundException); + }); + + it('404s when there is no public_spaces row', async () => { + const { service } = makeService({ + publicSpaceRepo: { + findBySpaceId: jest.fn().mockResolvedValue(undefined), + }, + }); + await expect( + service.getPublicSpace('handbook', makeWorkspace(optInSettings)), + ).rejects.toThrow(NotFoundException); + }); + + it('404s when the space is deleted', async () => { + const { service } = makeService({ + spaceRepo: { + findBySlug: jest + .fn() + .mockResolvedValue({ id: SPACE_ID, deletedAt: new Date() }), + }, + }); + await expect( + service.getPublicSpace('handbook', makeWorkspace(optInSettings)), + ).rejects.toThrow(NotFoundException); + }); + + it('resolves when opted in, enabled, and space exists', async () => { + const { service } = makeService(); + const result = await service.getPublicSpace( + 'handbook', + makeWorkspace(optInSettings), + ); + expect(result.space.id).toBe(SPACE_ID); + expect(result.publicSpace.enabled).toBe(true); + }); + }); + + describe('getPublicPage', () => { + it('404s for a page belonging to another space', async () => { + const { service } = makeService({ + pageRepo: { + findById: jest.fn().mockResolvedValue({ + id: 'page2', + spaceId: 'other-space', + deletedAt: null, + }), + }, + }); + await expect( + service.getPublicPage('handbook', 'abc123XYZ0', makeWorkspace(optInSettings)), + ).rejects.toThrow(NotFoundException); + }); + + it('404s for a restricted page', async () => { + const { service } = makeService({ + pagePermissionRepo: { + hasRestrictedAncestor: jest.fn().mockResolvedValue(true), + }, + }); + await expect( + service.getPublicPage('handbook', 'abc123XYZ0', makeWorkspace(optInSettings)), + ).rejects.toThrow(NotFoundException); + }); + + it('resolves the first unrestricted root page as the home', async () => { + const { service, pageRepo } = makeService(); + const result = await service.getPublicPage( + 'handbook', + undefined, + makeWorkspace(optInSettings), + ); + expect(pageRepo.getFirstUnrestrictedRootPage).toHaveBeenCalledWith(SPACE_ID); + expect(result.page.id).toBe('page1'); + }); + + it('sanitizes the served page content for public delivery', async () => { + const sanitizedDoc = { type: 'doc', sanitized: true }; + const { service, shareService } = makeService({ + pageRepo: { + findById: jest.fn().mockResolvedValue({ + id: 'page1', + slugId: 'abc123XYZ0', + spaceId: SPACE_ID, + workspaceId: WORKSPACE_ID, + deletedAt: null, + content: { type: 'doc', content: [] }, + }), + }, + shareService: { + updatePublicAttachments: jest.fn().mockResolvedValue(sanitizedDoc), + }, + }); + + const result = await service.getPublicPage( + 'handbook', + 'abc123XYZ0', + makeWorkspace(optInSettings), + ); + + expect(shareService.updatePublicAttachments.mock.calls[0][0]).toBe( + result.page, + ); + expect(result.page.content).toBe(sanitizedDoc); + }); + + it('returns page null for an empty published space', async () => { + const { service } = makeService({ + pageRepo: { + getFirstUnrestrictedRootPage: jest.fn().mockResolvedValue(undefined), + }, + }); + const result = await service.getPublicPage( + 'handbook', + undefined, + makeWorkspace(optInSettings), + ); + expect(result.page).toBeNull(); + }); + + it('skips content preparation when includeContent is false', async () => { + const { service, pageRepo, shareService } = makeService(); + + const result = await service.getPublicPage( + 'handbook', + 'abc123XYZ0', + makeWorkspace(optInSettings), + { includeContent: false }, + ); + + expect(pageRepo.findById).toHaveBeenCalledWith('abc123XYZ0'); + expect(shareService.updatePublicAttachments).not.toHaveBeenCalled(); + expect(result.page.id).toBe('page1'); + }); + }); + + describe('lookupTransclusionForPublicSpace', () => { + const refs = [{ sourcePageId: 'ref1', transclusionId: 't1' }]; + + it('excludes a candidate from another space', async () => { + const { service, transclusionService } = makeService({ + pageRepo: { + findById: jest.fn().mockResolvedValue({ + id: 'page2', + spaceId: 'other-space', + deletedAt: null, + }), + }, + }); + + await service.lookupTransclusionForPublicSpace( + 'handbook', + refs, + makeWorkspace(optInSettings), + ); + + const accessSet = transclusionService.lookupWithAccessSet.mock.calls[0][1]; + expect(Array.from(accessSet)).toEqual([]); + }); + + it('excludes a restricted candidate', async () => { + const { service, transclusionService } = makeService({ + pagePermissionRepo: { + hasRestrictedAncestor: jest.fn().mockResolvedValue(true), + }, + }); + + await service.lookupTransclusionForPublicSpace( + 'handbook', + refs, + makeWorkspace(optInSettings), + ); + + const accessSet = transclusionService.lookupWithAccessSet.mock.calls[0][1]; + expect(Array.from(accessSet)).toEqual([]); + }); + + it('includes a valid same-space candidate by its page id', async () => { + const { service, transclusionService } = makeService(); + + await service.lookupTransclusionForPublicSpace( + 'handbook', + refs, + makeWorkspace(optInSettings), + ); + + const accessSet = transclusionService.lookupWithAccessSet.mock.calls[0][1]; + expect(Array.from(accessSet)).toEqual(['page1']); + }); + + it('delegates item sanitization to the share service', async () => { + const rawItems = [ + { sourcePageId: 'page1', transclusionId: 't1', status: 'not_found' }, + ]; + const sanitizedItems = [ + { sourcePageId: 'page1', transclusionId: 't1', status: 'no_access' }, + ]; + const { service, shareService } = makeService({ + transclusionService: { + lookupWithAccessSet: jest.fn().mockResolvedValue({ items: rawItems }), + }, + shareService: { + sanitizeTransclusionItemsForPublic: jest + .fn() + .mockResolvedValue(sanitizedItems), + }, + }); + + const result = await service.lookupTransclusionForPublicSpace( + 'handbook', + refs, + makeWorkspace(optInSettings), + ); + + expect( + shareService.sanitizeTransclusionItemsForPublic, + ).toHaveBeenCalledWith(rawItems, WORKSPACE_ID); + expect(result.items).toBe(sanitizedItems); + }); + }); + + describe('BETA_PUBLIC_SPACES off', () => { + const flagOff = { + environmentService: { + isBetaPublicSpaces: jest.fn().mockReturnValue(false), + }, + }; + const refs = [{ sourcePageId: 'ref1', transclusionId: 't1' }]; + + it.each([ + ['getPublicSpaceInfo', (s, ws) => s.getPublicSpaceInfo('handbook', ws)], + ['getPublicSpaceTree', (s, ws) => s.getPublicSpaceTree('handbook', ws)], + [ + 'getPublicPage', + (s, ws) => s.getPublicPage('handbook', 'abc123XYZ0', ws), + ], + [ + 'lookupTransclusionForPublicSpace', + (s, ws) => s.lookupTransclusionForPublicSpace('handbook', refs, ws), + ], + ])( + '%s 404s before any lookup even with the workspace opted in', + async (_name, call) => { + const { service, spaceRepo, publicSpaceRepo, pageRepo } = + makeService(flagOff); + await expect( + call(service, makeWorkspace(optInSettings)), + ).rejects.toBeInstanceOf(NotFoundException); + expect(spaceRepo.findBySlug).not.toHaveBeenCalled(); + expect(publicSpaceRepo.findBySpaceId).not.toHaveBeenCalled(); + expect(pageRepo.findById).not.toHaveBeenCalled(); + expect(pageRepo.getSpacePagesExcludingRestricted).not.toHaveBeenCalled(); + }, + ); + + it('directory 404s before any lookup even with the double opt-in on', async () => { + const findEnabledWithSpaceByWorkspaceId = jest.fn().mockResolvedValue([]); + const { service } = makeService({ + ...flagOff, + publicSpaceRepo: { findEnabledWithSpaceByWorkspaceId }, + }); + await expect( + service.getPublicSpaceDirectory( + makeWorkspace({ publicSpaces: { enabled: true, directory: true } }), + ), + ).rejects.toBeInstanceOf(NotFoundException); + expect(findEnabledWithSpaceByWorkspaceId).not.toHaveBeenCalled(); + }); + + it.each([true, false])( + 'publish rejects every write (enabled=%s) without touching the row', + async (enabled) => { + const { service, publicSpaceRepo } = makeService(flagOff); + await expect( + service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace(optInSettings), + authUserId: 'u1', + enabled, + }), + ).rejects.toBeInstanceOf(ForbiddenException); + expect(publicSpaceRepo.findBySpaceId).not.toHaveBeenCalled(); + expect(publicSpaceRepo.upsert).not.toHaveBeenCalled(); + }, + ); + }); + + describe('publish appearance', () => { + it('merges appearance into existing settings preserving unknown keys', async () => { + const { service, publicSpaceRepo } = makeService({ + publicSpaceRepo: { + findBySpaceId: jest.fn().mockResolvedValue({ + id: 'ps1', + spaceId: SPACE_ID, + enabled: true, + searchIndexing: false, + settings: { + future: 1, + appearance: { primaryColorLight: '#111111' }, + }, + }), + }, + }); + + await service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace(optInSettings), + authUserId: 'u1', + enabled: true, + appearance: { primaryColorDark: '#222222' }, + }); + + expect(publicSpaceRepo.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + settings: { + future: 1, + appearance: { + primaryColorLight: '#111111', + primaryColorDark: '#222222', + }, + }, + }), + ); + }); + + it('deletes an appearance key when its value is null', async () => { + const { service, publicSpaceRepo } = makeService({ + publicSpaceRepo: { + findBySpaceId: jest.fn().mockResolvedValue({ + id: 'ps1', + spaceId: SPACE_ID, + enabled: true, + searchIndexing: false, + settings: { + appearance: { + primaryColorLight: '#111111', + primaryColorDark: '#222222', + }, + }, + }), + }, + }); + + await service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace(optInSettings), + authUserId: 'u1', + enabled: true, + appearance: { primaryColorLight: null }, + }); + + expect(publicSpaceRepo.upsert.mock.calls[0][0].settings).toEqual({ + appearance: { primaryColorDark: '#222222' }, + }); + }); + + it('passes settings undefined when appearance is omitted', async () => { + const { service, publicSpaceRepo } = makeService(); + + await service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace(optInSettings), + authUserId: 'u1', + enabled: true, + }); + + expect(publicSpaceRepo.upsert.mock.calls[0][0].settings).toBeUndefined(); + }); + + it('rejects appearance when the workspace lacks the license feature', async () => { + const { service, publicSpaceRepo } = makeService({ + licenseCheckService: { resolveFeatures: jest.fn().mockReturnValue([]) }, + }); + + await expect( + service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace(optInSettings), + authUserId: 'u1', + enabled: true, + appearance: { primaryColorLight: '#111111' }, + }), + ).rejects.toBeInstanceOf(ForbiddenException); + + expect(publicSpaceRepo.upsert).not.toHaveBeenCalled(); + }); + }); + + describe('public appearance exposure', () => { + const storedSettings = { + future: 1, + appearance: { + primaryColorLight: '#111111', + primaryColorDark: '#222222', + internal: 'secret', + }, + }; + const publicAppearance = { + primaryColorLight: '#111111', + primaryColorDark: '#222222', + }; + const publicSpaceRepoWithSettings = { + findBySpaceId: jest.fn().mockResolvedValue({ + id: 'ps1', + spaceId: SPACE_ID, + enabled: true, + searchIndexing: false, + settings: storedSettings, + }), + }; + + it('whitelists appearance on getPublicSpaceInfo', async () => { + const { service } = makeService({ + publicSpaceRepo: publicSpaceRepoWithSettings, + }); + const result = await service.getPublicSpaceInfo( + 'handbook', + makeWorkspace(optInSettings), + ); + expect(result.appearance).toEqual(publicAppearance); + expect(result).not.toHaveProperty('settings'); + }); + + it('whitelists appearance on getPublicSpaceTree', async () => { + const { service } = makeService({ + publicSpaceRepo: publicSpaceRepoWithSettings, + }); + const result = await service.getPublicSpaceTree( + 'handbook', + makeWorkspace(optInSettings), + ); + expect(result.appearance).toEqual(publicAppearance); + expect(result).not.toHaveProperty('settings'); + }); + + it('whitelists appearance on the served page response', async () => { + const { service } = makeService({ + publicSpaceRepo: publicSpaceRepoWithSettings, + }); + const result = await service.getPublicPage( + 'handbook', + 'abc123XYZ0', + makeWorkspace(optInSettings), + ); + expect(result.appearance).toEqual(publicAppearance); + expect(result).not.toHaveProperty('settings'); + }); + + it('whitelists appearance on the empty-space page response', async () => { + const { service } = makeService({ + publicSpaceRepo: publicSpaceRepoWithSettings, + pageRepo: { + getFirstUnrestrictedRootPage: jest.fn().mockResolvedValue(undefined), + }, + }); + const result = await service.getPublicPage( + 'handbook', + undefined, + makeWorkspace(optInSettings), + ); + expect(result.page).toBeNull(); + expect(result.appearance).toEqual(publicAppearance); + expect(result).not.toHaveProperty('settings'); + }); + + it('strips appearance when the workspace lacks the license feature', async () => { + const { service } = makeService({ + publicSpaceRepo: publicSpaceRepoWithSettings, + licenseCheckService: { resolveFeatures: jest.fn().mockReturnValue([]) }, + }); + const result = await service.getPublicSpaceInfo( + 'handbook', + makeWorkspace(optInSettings), + ); + expect(result.appearance).toBeUndefined(); + }); + + it('omits appearance when none is stored', async () => { + const { service } = makeService(); + const result = await service.getPublicSpaceInfo( + 'handbook', + makeWorkspace(optInSettings), + ); + expect(result.appearance ?? {}).toEqual({}); + }); + }); + + describe('byline', () => { + function makeRepoWithByline(byline: any) { + return { + findBySpaceId: jest.fn().mockResolvedValue({ + id: 'ps1', + spaceId: SPACE_ID, + enabled: true, + searchIndexing: false, + settings: byline === null ? null : { byline }, + }), + }; + } + + it('defaults to a hidden author and a visible updated date', async () => { + const { service, pageRepo } = makeService({ + publicSpaceRepo: makeRepoWithByline(null), + }); + + const result = await service.getPublicPage( + 'handbook', + 'abc123XYZ0', + makeWorkspace(optInSettings), + ); + + expect(result.byline).toEqual({ author: false, updatedAt: true }); + expect(pageRepo.findById).toHaveBeenCalledWith('abc123XYZ0', { + includeContent: true, + includeCreator: false, + }); + }); + + it('requests the creator only when the author byline is enabled', async () => { + const { service, pageRepo } = makeService({ + publicSpaceRepo: makeRepoWithByline({ author: true }), + }); + + const result = await service.getPublicPage( + 'handbook', + 'abc123XYZ0', + makeWorkspace(optInSettings), + ); + + expect(result.byline.author).toBe(true); + expect(pageRepo.findById).toHaveBeenCalledWith('abc123XYZ0', { + includeContent: true, + includeCreator: true, + }); + }); + + it('strips a creator from the page when the author byline is off', async () => { + const { service } = makeService({ + publicSpaceRepo: makeRepoWithByline({ author: false }), + pageRepo: { + findById: jest.fn().mockResolvedValue({ + id: 'page1', + slugId: 'abc123XYZ0', + spaceId: SPACE_ID, + workspaceId: WORKSPACE_ID, + deletedAt: null, + content: null, + creatorId: 'u1', + creator: { id: 'u1', name: 'Jane', avatarUrl: null }, + }), + }, + }); + + const result = await service.getPublicPage( + 'handbook', + 'abc123XYZ0', + makeWorkspace(optInSettings), + ); + + expect(result.page.creatorId).toBe('u1'); + expect((result.page as any).creator).toBeUndefined(); + }); + + it('returns the byline for an empty published space', async () => { + const { service } = makeService({ + publicSpaceRepo: makeRepoWithByline({ author: true, updatedAt: false }), + pageRepo: { + getFirstUnrestrictedRootPage: jest.fn().mockResolvedValue(undefined), + }, + }); + + const result = await service.getPublicPage( + 'handbook', + undefined, + makeWorkspace(optInSettings), + ); + + expect(result.page).toBeNull(); + expect(result.byline).toEqual({ author: true, updatedAt: false }); + }); + + it('merges a single byline field into the stored byline', async () => { + const { service, publicSpaceRepo } = makeService({ + publicSpaceRepo: makeRepoWithByline({ author: true, updatedAt: true }), + }); + + await service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace(optInSettings), + authUserId: 'u1', + enabled: true, + bylineUpdatedAt: false, + }); + + expect(publicSpaceRepo.upsert.mock.calls[0][0].settings).toEqual({ + byline: { author: true, updatedAt: false }, + }); + }); + + it('leaves stored settings untouched when no byline field is sent', async () => { + const { service, publicSpaceRepo } = makeService({ + publicSpaceRepo: makeRepoWithByline({ author: true }), + }); + + await service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace(optInSettings), + authUserId: 'u1', + enabled: true, + }); + + expect(publicSpaceRepo.upsert.mock.calls[0][0].settings).toBeUndefined(); + }); + }); + + describe('publish', () => { + it('rejects enabling when the workspace opt-in is off', async () => { + const { service } = makeService(); + await expect( + service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace({}), + authUserId: 'u1', + enabled: true, + }), + ).rejects.toThrow(ForbiddenException); + }); + + it('allows enabling when public page sharing is disabled', async () => { + const { service, publicSpaceRepo } = makeService(); + await service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace({ + publicSpaces: { enabled: true }, + sharing: { disabled: true }, + }), + authUserId: 'u1', + enabled: true, + }); + + expect(publicSpaceRepo.upsert).toHaveBeenCalledWith( + expect.objectContaining({ enabled: true }), + ); + }); + + it('allows disabling even when the opt-in is off', async () => { + const { service, publicSpaceRepo } = makeService(); + await service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace({}), + authUserId: 'u1', + enabled: false, + }); + expect(publicSpaceRepo.upsert).toHaveBeenCalledWith( + expect.objectContaining({ enabled: false }), + ); + }); + + it('defaults every option on except the author byline on first publish', async () => { + const { service, publicSpaceRepo } = makeService({ + publicSpaceRepo: { + findBySpaceId: jest.fn().mockResolvedValue(undefined), + }, + }); + await service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace({ + publicSpaces: { enabled: true, directory: true }, + }), + authUserId: 'u1', + enabled: true, + }); + expect(publicSpaceRepo.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: true, + searchIndexing: true, + settings: { directory: true }, + }), + ); + }); + + it('skips the directory default when the workspace directory is off', async () => { + const { service, publicSpaceRepo } = makeService({ + publicSpaceRepo: { + findBySpaceId: jest.fn().mockResolvedValue(undefined), + }, + }); + await service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace(optInSettings), + authUserId: 'u1', + enabled: true, + }); + expect(publicSpaceRepo.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + searchIndexing: true, + settings: undefined, + }), + ); + }); + + it('keeps explicitly provided flags on first publish', async () => { + const { service, publicSpaceRepo } = makeService({ + publicSpaceRepo: { + findBySpaceId: jest.fn().mockResolvedValue(undefined), + }, + }); + await service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace({ + publicSpaces: { enabled: true, directory: true }, + }), + authUserId: 'u1', + enabled: true, + searchIndexing: false, + }); + expect(publicSpaceRepo.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + searchIndexing: false, + settings: { directory: true }, + }), + ); + }); + + it('republish preserves prior customization instead of re-applying defaults', async () => { + const { service, publicSpaceRepo } = makeService({ + publicSpaceRepo: { + findBySpaceId: jest.fn().mockResolvedValue({ + id: 'ps1', + spaceId: SPACE_ID, + enabled: false, + searchIndexing: false, + settings: { directory: false }, + }), + }, + }); + await service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace(optInSettings), + authUserId: 'u1', + enabled: true, + }); + expect(publicSpaceRepo.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + searchIndexing: undefined, + settings: undefined, + }), + ); + }); + }); + + describe('directory', () => { + const directorySettings = { + publicSpaces: { enabled: true, directory: true }, + }; + + function makeDirectoryRows() { + return [ + { + settings: { directory: true }, + searchIndexing: false, + name: 'Handbook', + slug: 'handbook', + description: 'All about us', + logo: null, + }, + { + settings: {}, + searchIndexing: true, + name: 'Hidden', + slug: 'hidden', + description: null, + logo: null, + }, + ]; + } + + it('404s when the workspace opt-in is off', async () => { + const { service } = makeService(); + await expect( + service.getPublicSpaceDirectory( + makeWorkspace({ publicSpaces: { enabled: false, directory: true } }), + ), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('404s when the directory flag is off', async () => { + const { service } = makeService(); + await expect( + service.getPublicSpaceDirectory(makeWorkspace(optInSettings)), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('lists only spaces opted into the directory with whitelisted fields', async () => { + const { service } = makeService({ + publicSpaceRepo: { + findEnabledWithSpaceByWorkspaceId: jest + .fn() + .mockResolvedValue(makeDirectoryRows()), + }, + }); + const result = await service.getPublicSpaceDirectory( + makeWorkspace(directorySettings), + ); + expect(result.spaces).toEqual([ + { + name: 'Handbook', + slug: 'handbook', + description: 'All about us', + logo: null, + }, + ]); + }); + + it('publish stores the directory flag while preserving other settings', async () => { + const { service, publicSpaceRepo } = makeService({ + publicSpaceRepo: { + findBySpaceId: jest.fn().mockResolvedValue({ + id: 'ps1', + spaceId: SPACE_ID, + enabled: true, + settings: { appearance: { primaryColorLight: '#111111' } }, + }), + }, + }); + await service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace(optInSettings), + authUserId: 'u1', + enabled: true, + directory: true, + }); + expect(publicSpaceRepo.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + settings: { + appearance: { primaryColorLight: '#111111' }, + directory: true, + }, + }), + ); + }); + + it('publish leaves settings untouched when directory is omitted', async () => { + const { service, publicSpaceRepo } = makeService(); + await service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace(optInSettings), + authUserId: 'u1', + enabled: true, + }); + expect(publicSpaceRepo.upsert).toHaveBeenCalledWith( + expect.objectContaining({ settings: undefined }), + ); + }); + }); + + describe('cross-space link resolution', () => { + const OTHER_SPACE_ID = '018f0000-0000-7000-8000-000000000004'; + + const crossSpacePage = { + id: 'page2', + slugId: 'crossSlug001', + spaceId: OTHER_SPACE_ID, + workspaceId: WORKSPACE_ID, + deletedAt: null, + content: null, + }; + + const otherSpace = { + id: OTHER_SPACE_ID, + slug: 'engineering', + name: 'Engineering', + workspaceId: WORKSPACE_ID, + deletedAt: null, + }; + + it('resolves a contentless probe into another published space', async () => { + const { service } = makeService({ + pageRepo: { findById: jest.fn().mockResolvedValue(crossSpacePage) }, + spaceRepo: { findById: jest.fn().mockResolvedValue(otherSpace) }, + }); + const result = await service.getPublicPage( + 'handbook', + 'crossSlug001', + makeWorkspace(optInSettings), + { includeContent: false }, + ); + expect(result.space.slug).toBe('engineering'); + expect(result.page.id).toBe('page2'); + }); + + it('404s a cross-space target when content is requested', async () => { + const { service } = makeService({ + pageRepo: { findById: jest.fn().mockResolvedValue(crossSpacePage) }, + spaceRepo: { findById: jest.fn().mockResolvedValue(otherSpace) }, + }); + await expect( + service.getPublicPage( + 'handbook', + 'crossSlug001', + makeWorkspace(optInSettings), + ), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('404s when the target space is not published', async () => { + const { service } = makeService({ + pageRepo: { findById: jest.fn().mockResolvedValue(crossSpacePage) }, + spaceRepo: { findById: jest.fn().mockResolvedValue(otherSpace) }, + publicSpaceRepo: { + findBySpaceId: jest + .fn() + .mockImplementation(async (spaceId: string) => + spaceId === SPACE_ID + ? { id: 'ps1', spaceId: SPACE_ID, enabled: true } + : { id: 'ps2', spaceId, enabled: false }, + ), + }, + }); + await expect( + service.getPublicPage( + 'handbook', + 'crossSlug001', + makeWorkspace(optInSettings), + { includeContent: false }, + ), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('404s when the target space is outside the workspace', async () => { + const { service } = makeService({ + pageRepo: { findById: jest.fn().mockResolvedValue(crossSpacePage) }, + spaceRepo: { findById: jest.fn().mockResolvedValue(undefined) }, + }); + await expect( + service.getPublicPage( + 'handbook', + 'crossSlug001', + makeWorkspace(optInSettings), + { includeContent: false }, + ), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('404s when the cross-space target has a restricted ancestor', async () => { + const { service } = makeService({ + pageRepo: { findById: jest.fn().mockResolvedValue(crossSpacePage) }, + spaceRepo: { findById: jest.fn().mockResolvedValue(otherSpace) }, + pagePermissionRepo: { + hasRestrictedAncestor: jest.fn().mockResolvedValue(true), + }, + }); + await expect( + service.getPublicPage( + 'handbook', + 'crossSlug001', + makeWorkspace(optInSettings), + { includeContent: false }, + ), + ).rejects.toBeInstanceOf(NotFoundException); + }); + }); +}); diff --git a/apps/server/src/core/public-space/public-space.service.ts b/apps/server/src/core/public-space/public-space.service.ts new file mode 100644 index 000000000..d524ddf2a --- /dev/null +++ b/apps/server/src/core/public-space/public-space.service.ts @@ -0,0 +1,397 @@ +import { + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { PublicSpaceRepo } from '@docmost/db/repos/public-space/public-space.repo'; +import { SpaceRepo } from '@docmost/db/repos/space/space.repo'; +import { PageRepo } from '@docmost/db/repos/page/page.repo'; +import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo'; +import { ShareService } from '../share/share.service'; +import { TransclusionService } from '../page/transclusion/transclusion.service'; +import { TransclusionLookup } from '../page/transclusion/transclusion.types'; +import { + Page, + PublicSpace, + Space, + Workspace, +} from '@docmost/db/types/entity.types'; +import { PublicSpaceAppearanceDto } from './dto/public-space.dto'; +import { LicenseCheckService } from '../../integrations/environment/license-check.service'; +import { EnvironmentService } from '../../integrations/environment/environment.service'; +import { Feature, FeatureKey } from '../../common/features'; + +@Injectable() +export class PublicSpaceService { + constructor( + private readonly publicSpaceRepo: PublicSpaceRepo, + private readonly spaceRepo: SpaceRepo, + private readonly pageRepo: PageRepo, + private readonly pagePermissionRepo: PagePermissionRepo, + private readonly shareService: ShareService, + private readonly transclusionService: TransclusionService, + private readonly licenseCheckService: LicenseCheckService, + private readonly environmentService: EnvironmentService, + ) {} + + hasFeature(workspace: Workspace, feature: FeatureKey): boolean { + return this.licenseCheckService + .resolveFeatures(workspace.licenseKey, workspace.plan) + .includes(feature); + } + + isPublicSpacesAllowed(workspace: Workspace): boolean { + const settings = workspace.settings as any; + return ( + this.environmentService.isBetaPublicSpaces() && + settings?.publicSpaces?.enabled === true + ); + } + + private isDirectoryEnabled(workspace: Workspace): boolean { + return (workspace.settings as any)?.publicSpaces?.directory === true; + } + + async getPublicSpace(spaceSlug: string, workspace: Workspace) { + if (!this.isPublicSpacesAllowed(workspace)) { + throw new NotFoundException('Space not found'); + } + + const space = await this.spaceRepo.findBySlug(spaceSlug, workspace.id); + if (!space || space.deletedAt) { + throw new NotFoundException('Space not found'); + } + + const publicSpace = await this.publicSpaceRepo.findBySpaceId(space.id); + if (!publicSpace?.enabled) { + throw new NotFoundException('Space not found'); + } + + return { space, publicSpace }; + } + + async getPublicSpaceInfo(spaceSlug: string, workspace: Workspace) { + const { space, publicSpace } = await this.getPublicSpace( + spaceSlug, + workspace, + ); + return { + space: this.toPublicSpaceFields(space), + searchIndexing: publicSpace.searchIndexing, + appearance: this.toPublicAppearance(publicSpace, workspace), + }; + } + + async getPublicSpaceTree(spaceSlug: string, workspace: Workspace) { + const { space, publicSpace } = await this.getPublicSpace( + spaceSlug, + workspace, + ); + const pageTree = await this.pageRepo.getSpacePagesExcludingRestricted( + space.id, + ); + return { + space: this.toPublicSpaceFields(space), + pageTree, + appearance: this.toPublicAppearance(publicSpace, workspace), + }; + } + + async getPublicPage( + spaceSlug: string, + pageSlugId: string | undefined, + workspace: Workspace, + opts?: { includeContent?: boolean }, + ) { + const includeContent = opts?.includeContent !== false; + + const { space, publicSpace } = await this.getPublicSpace( + spaceSlug, + workspace, + ); + const byline = this.getBylineSettings(publicSpace); + + let pageId = pageSlugId; + if (!pageId) { + const firstRoot = await this.pageRepo.getFirstUnrestrictedRootPage( + space.id, + ); + if (!firstRoot) { + return { + page: null, + space: this.toPublicSpaceFields(space), + searchIndexing: publicSpace.searchIndexing, + appearance: this.toPublicAppearance(publicSpace, workspace), + byline, + }; + } + pageId = firstRoot.id; + } + + const page = includeContent + ? await this.pageRepo.findById(pageId, { + includeContent: true, + includeCreator: byline.author, + }) + : await this.pageRepo.findById(pageId); + if (!page || page.deletedAt) { + throw new NotFoundException('Page not found'); + } + + // cross-space targets resolve only as contentless link probes, and only + // into published spaces; content stays canonical under its own space URL + if (page.spaceId !== space.id) { + if (includeContent) { + throw new NotFoundException('Page not found'); + } + return this.resolveCrossSpacePublicPage(page, workspace); + } + + const isRestricted = await this.pagePermissionRepo.hasRestrictedAncestor( + page.id, + ); + if (isRestricted) { + throw new NotFoundException('Page not found'); + } + + // never ship creator details the space admin chose to hide + if (!byline.author && 'creator' in page) { + delete (page as any).creator; + } + + if (includeContent) { + page.content = await this.shareService.updatePublicAttachments(page); + } + + return { + page, + space: this.toPublicSpaceFields(space), + searchIndexing: publicSpace.searchIndexing, + appearance: this.toPublicAppearance(publicSpace, workspace), + byline, + }; + } + + /** Uniform 404 unless the target page's own space is published, not deleted, in this workspace, and unrestricted. */ + private async resolveCrossSpacePublicPage(page: Page, workspace: Workspace) { + const space = await this.spaceRepo.findById(page.spaceId, workspace.id); + if (!space || space.deletedAt) { + throw new NotFoundException('Page not found'); + } + + const publicSpace = await this.publicSpaceRepo.findBySpaceId(space.id); + if (!publicSpace?.enabled) { + throw new NotFoundException('Page not found'); + } + + const isRestricted = await this.pagePermissionRepo.hasRestrictedAncestor( + page.id, + ); + if (isRestricted) { + throw new NotFoundException('Page not found'); + } + + return { + page, + space: this.toPublicSpaceFields(space), + searchIndexing: publicSpace.searchIndexing, + appearance: this.toPublicAppearance(publicSpace, workspace), + byline: this.getBylineSettings(publicSpace), + }; + } + + /** Share-style transclusion resolution scoped to one public space; viewer permissions are never consulted. */ + async lookupTransclusionForPublicSpace( + spaceSlug: string, + references: Array<{ sourcePageId: string; transclusionId: string }>, + workspace: Workspace, + ): Promise<{ items: TransclusionLookup[] }> { + const { space } = await this.getPublicSpace(spaceSlug, workspace); + + const candidatePageIds = Array.from( + new Set(references.map((r) => r.sourcePageId)), + ); + + const accessibleResults = await Promise.all( + candidatePageIds.map(async (pageId) => { + const page = await this.pageRepo.findById(pageId); + if (!page || page.deletedAt || page.spaceId !== space.id) return null; + const restricted = + await this.pagePermissionRepo.hasRestrictedAncestor(page.id); + if (restricted) return null; + return page.id; + }), + ); + const accessibleSet = new Set( + accessibleResults.filter((id): id is string => id !== null), + ); + + const { items } = await this.transclusionService.lookupWithAccessSet( + references, + accessibleSet, + workspace.id, + ); + + return { + items: await this.shareService.sanitizeTransclusionItemsForPublic( + items, + workspace.id, + ), + }; + } + + async publish(opts: { + space: Space; + workspace: Workspace; + authUserId: string; + enabled: boolean; + searchIndexing?: boolean; + appearance?: PublicSpaceAppearanceDto; + bylineAuthor?: boolean; + bylineUpdatedAt?: boolean; + directory?: boolean; + }) { + const { + space, + workspace, + authUserId, + enabled, + appearance, + bylineAuthor, + bylineUpdatedAt, + } = opts; + let { searchIndexing, directory } = opts; + + if (!this.environmentService.isBetaPublicSpaces()) { + throw new ForbiddenException( + 'Public spaces are not enabled on this instance', + ); + } + + if (enabled && !this.isPublicSpacesAllowed(workspace)) { + throw new ForbiddenException( + 'Public spaces are not enabled for this workspace', + ); + } + + if (appearance && !this.hasFeature(workspace, Feature.PUBLIC_SPACE_APPEARANCE)) { + throw new ForbiddenException( + 'Public docs appearance requires a paid license', + ); + } + + const prev = await this.publicSpaceRepo.findBySpaceId(space.id); + + // first publish defaults every option on except the author byline; republish keeps prior customization + if (enabled && !prev) { + searchIndexing ??= true; + if (this.isDirectoryEnabled(workspace)) { + directory ??= true; + } + } + + const hasByline = + typeof bylineAuthor !== 'undefined' || + typeof bylineUpdatedAt !== 'undefined'; + + let settings: Record | undefined; + if (appearance || hasByline || typeof directory !== 'undefined') { + const prevSettings = (prev?.settings as Record) ?? {}; + settings = { ...prevSettings }; + + if (typeof directory !== 'undefined') { + settings.directory = directory; + } + + if (appearance) { + const nextAppearance: Record = { + ...(prevSettings.appearance ?? {}), + }; + for (const key of ['primaryColorLight', 'primaryColorDark'] as const) { + const value = appearance[key]; + if (value === null) delete nextAppearance[key]; + else if (typeof value !== 'undefined') nextAppearance[key] = value; + } + settings.appearance = nextAppearance; + } + + if (hasByline) { + const prevByline = this.getBylineSettings(prev); + settings.byline = { + author: bylineAuthor ?? prevByline.author, + updatedAt: bylineUpdatedAt ?? prevByline.updatedAt, + }; + } + } + + return this.publicSpaceRepo.upsert({ + spaceId: space.id, + workspaceId: space.workspaceId, + enabled, + searchIndexing, + creatorId: authUserId, + settings, + }); + } + + /** The /docs hub: listed public spaces only, behind the workspace double opt-in. */ + async getPublicSpaceDirectory(workspace: Workspace) { + if ( + !this.isPublicSpacesAllowed(workspace) || + !this.isDirectoryEnabled(workspace) + ) { + throw new NotFoundException('Not found'); + } + + const rows = await this.publicSpaceRepo.findEnabledWithSpaceByWorkspaceId( + workspace.id, + ); + const listed = rows.filter( + (row) => (row.settings as any)?.directory === true, + ); + + return { + spaces: listed.map((row) => ({ + name: row.name, + slug: row.slug, + description: row.description, + logo: row.logo, + })), + }; + } + + private toPublicSpaceFields(space: Space) { + return { + id: space.id, + name: space.name, + slug: space.slug, + description: space.description, + logo: space.logo, + }; + } + + private getBylineSettings(publicSpace: PublicSpace) { + const byline = (publicSpace?.settings as any)?.byline; + return { + author: byline?.author === true, + updatedAt: byline?.updatedAt !== false, + }; + } + + private toPublicAppearance(publicSpace: PublicSpace, workspace: Workspace) { + if (!this.hasFeature(workspace, Feature.PUBLIC_SPACE_APPEARANCE)) { + return undefined; + } + const appearance = (publicSpace?.settings as any)?.appearance; + if (!appearance) return undefined; + const result: { primaryColorLight?: string; primaryColorDark?: string } = + {}; + if (typeof appearance.primaryColorLight === 'string') { + result.primaryColorLight = appearance.primaryColorLight; + } + if (typeof appearance.primaryColorDark === 'string') { + result.primaryColorDark = appearance.primaryColorDark; + } + return Object.keys(result).length > 0 ? result : undefined; + } +} diff --git a/apps/server/src/core/search/dto/search.dto.ts b/apps/server/src/core/search/dto/search.dto.ts index 89fb1b289..b9d09d17d 100644 --- a/apps/server/src/core/search/dto/search.dto.ts +++ b/apps/server/src/core/search/dto/search.dto.ts @@ -53,6 +53,12 @@ export class SearchShareDTO extends SearchDTO { spaceId: string; } +export class SearchPublicSpaceDTO extends SearchDTO { + @IsNotEmpty() + @IsString() + spaceSlug: string; +} + export class SearchSuggestionDTO { @IsString() query: string; diff --git a/apps/server/src/core/search/search.controller.spec.ts b/apps/server/src/core/search/search.controller.spec.ts index 6d6bad58e..b04994764 100644 --- a/apps/server/src/core/search/search.controller.spec.ts +++ b/apps/server/src/core/search/search.controller.spec.ts @@ -1,4 +1,5 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; import { SearchController } from './search.controller'; describe('SearchController', () => { @@ -16,3 +17,77 @@ describe('SearchController', () => { expect(controller).toBeDefined(); }); }); + +describe('SearchController public-space-search gate', () => { + function makeController(overrides: any = {}) { + const searchService = { searchPage: jest.fn().mockResolvedValue([]) }; + const environmentService = { + getSearchDriver: jest.fn().mockReturnValue('postgres'), + }; + const publicSpaceService = { + getPublicSpace: jest + .fn() + .mockRejectedValue(new NotFoundException('Space not found')), + ...overrides.publicSpaceService, + }; + const pageRepo = { + getSpacePagesExcludingRestricted: jest + .fn() + .mockResolvedValue([{ id: 'p1' }, { id: 'p2' }]), + }; + const controller = new SearchController( + searchService as any, + {} as any, + environmentService as any, + publicSpaceService as any, + pageRepo as any, + {} as any, + ); + return { controller, searchService, publicSpaceService, pageRepo }; + } + + const workspace = { id: 'ws1' } as any; + + it('does not read pages or search when the public space gate rejects', async () => { + const { controller, searchService, pageRepo } = makeController(); + await expect( + controller.searchPublicSpace( + { query: 'roadmap', spaceSlug: 'handbook' } as any, + workspace, + ), + ).rejects.toBeInstanceOf(NotFoundException); + expect(pageRepo.getSpacePagesExcludingRestricted).not.toHaveBeenCalled(); + expect(searchService.searchPage).not.toHaveBeenCalled(); + }); + + it('searches only the unrestricted pages of the gated space, ignoring client filters', async () => { + const { controller, searchService, publicSpaceService, pageRepo } = + makeController({ + publicSpaceService: { + getPublicSpace: jest + .fn() + .mockResolvedValue({ space: { id: 's1' }, publicSpace: {} }), + }, + }); + await controller.searchPublicSpace( + { + query: 'roadmap', + spaceSlug: 'handbook', + spaceId: 'attacker-space', + shareId: 'share1', + creatorId: 'u1', + labelIds: ['l1'], + } as any, + workspace, + ); + expect(publicSpaceService.getPublicSpace).toHaveBeenCalledWith( + 'handbook', + workspace, + ); + expect(pageRepo.getSpacePagesExcludingRestricted).toHaveBeenCalledWith('s1'); + expect(searchService.searchPage).toHaveBeenCalledWith( + { query: 'roadmap', spaceSlug: 'handbook' }, + { workspaceId: 'ws1', publicPageIds: ['p1', 'p2'] }, + ); + }); +}); diff --git a/apps/server/src/core/search/search.controller.ts b/apps/server/src/core/search/search.controller.ts index e060d8a8c..469fa9c7e 100644 --- a/apps/server/src/core/search/search.controller.ts +++ b/apps/server/src/core/search/search.controller.ts @@ -12,6 +12,7 @@ import { import { SearchService } from './search.service'; import { SearchDTO, + SearchPublicSpaceDTO, SearchShareDTO, SearchSuggestionDTO, } from './dto/search.dto'; @@ -28,6 +29,8 @@ import { AuthUser } from '../../common/decorators/auth-user.decorator'; import { Public } from 'src/common/decorators/public.decorator'; import { EnvironmentService } from '../../integrations/environment/environment.service'; import { ModuleRef } from '@nestjs/core'; +import { PublicSpaceService } from '../public-space/public-space.service'; +import { PageRepo } from '@docmost/db/repos/page/page.repo'; @UseGuards(JwtAuthGuard) @Controller('search') @@ -38,6 +41,8 @@ export class SearchController { private readonly searchService: SearchService, private readonly spaceAbility: SpaceAbilityFactory, private readonly environmentService: EnvironmentService, + private readonly publicSpaceService: PublicSpaceService, + private readonly pageRepo: PageRepo, private moduleRef: ModuleRef, ) {} @@ -109,14 +114,51 @@ export class SearchController { }); } + @Public() + @HttpCode(HttpStatus.OK) + @Post('public-space-search') + async searchPublicSpace( + @Body() searchDto: SearchPublicSpaceDTO, + @AuthWorkspace() workspace: Workspace, + ) { + delete searchDto.spaceId; + delete searchDto.shareId; + // Member-facing filters stay internal: creator identities and label + // taxonomy must not become a grouping oracle on the anonymous surface. + delete searchDto.creatorId; + delete searchDto.labelIds; + + const { space } = await this.publicSpaceService.getPublicSpace( + searchDto.spaceSlug, + workspace, + ); + const pages = await this.pageRepo.getSpacePagesExcludingRestricted( + space.id, + ); + const publicPageIds = pages.map((page) => page.id); + + if (this.environmentService.getSearchDriver() === 'typesense') { + return this.searchTypesense(searchDto, { + workspaceId: workspace.id, + publicPageIds, + }); + } + + return this.searchService.searchPage(searchDto, { + workspaceId: workspace.id, + publicPageIds, + }); + } + async searchTypesense( searchParams: SearchDTO, opts: { userId?: string; workspaceId: string; + publicPageIds?: string[]; }, ) { - const { userId, workspaceId } = opts; + const { userId, workspaceId, publicPageIds } = opts; let TypesenseModule: any; try { // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -132,6 +174,7 @@ export class SearchController { return PageSearchService.searchPage(searchParams, { userId: userId, workspaceId, + publicPageIds, }); } catch (err) { this.logger.debug( diff --git a/apps/server/src/core/search/search.module.ts b/apps/server/src/core/search/search.module.ts index fa74573df..4bd944e27 100644 --- a/apps/server/src/core/search/search.module.ts +++ b/apps/server/src/core/search/search.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { SearchController } from './search.controller'; import { SearchService } from './search.service'; +import { PublicSpaceModule } from '../public-space/public-space.module'; @Module({ + imports: [PublicSpaceModule], controllers: [SearchController], providers: [SearchService], exports: [SearchService], diff --git a/apps/server/src/core/search/search.service.ts b/apps/server/src/core/search/search.service.ts index 32cedd7ee..bfb3ca918 100644 --- a/apps/server/src/core/search/search.service.ts +++ b/apps/server/src/core/search/search.service.ts @@ -27,6 +27,7 @@ export class SearchService { opts: { userId?: string; workspaceId: string; + publicPageIds?: string[]; }, ): Promise<{ items: SearchResponseDto[] }> { const query = searchParams.query?.trim() ?? ''; @@ -109,7 +110,7 @@ export class SearchService { .limit(searchParams.limit || 25) .offset(searchParams.offset || 0); - if (!searchParams.shareId) { + if (!searchParams.shareId && !opts.publicPageIds) { queryResults = queryResults.select((eb) => this.pageRepo.withSpace(eb)); } @@ -124,6 +125,15 @@ export class SearchService { this.spaceMemberRepo.getUserSpaceIdsQuery(opts.userId), ) .where('workspaceId', '=', opts.workspaceId); + } else if (opts.publicPageIds && !opts.userId) { + // Public space search: the allowed id set is computed from live DB + // state by the controller on every request. + if (opts.publicPageIds.length === 0) { + return { items: [] }; + } + queryResults = queryResults + .where('id', 'in', opts.publicPageIds) + .where('workspaceId', '=', opts.workspaceId); } else if (searchParams.shareId && !searchParams.spaceId && !opts.userId) { // search in shares const shareId = searchParams.shareId; diff --git a/apps/server/src/core/share/share-seo.controller.ts b/apps/server/src/core/share/share-seo.controller.ts index 51967ada5..d2cfdd975 100644 --- a/apps/server/src/core/share/share-seo.controller.ts +++ b/apps/server/src/core/share/share-seo.controller.ts @@ -85,8 +85,8 @@ export class ShareSeoController { const html = fs.readFileSync(indexFilePath, 'utf8'); const transformedHtml = html - .replace(/[\s\S]*?<\/title>/i, `<title>${metaTitle}`) - .replace(metaTagVar, metaTags); + .replace(/[\s\S]*?<\/title>/i, () => `<title>${metaTitle}`) + .replace(metaTagVar, () => metaTags); res.type('text/html').send(transformedHtml); } diff --git a/apps/server/src/core/share/share.service.ts b/apps/server/src/core/share/share.service.ts index e567e6caa..68acf48e8 100644 --- a/apps/server/src/core/share/share.service.ts +++ b/apps/server/src/core/share/share.service.ts @@ -365,35 +365,9 @@ export class ShareService { workspaceId, ); - // Sanitize each item's content for public delivery - // generate per-attachment tokens scoped to the source page - // and strip comment marks. - const tokenized = await Promise.all( - items.map(async (item) => { - if ('status' in item) return item; - const doc = await this.prepareContentForShare( - item.content, - item.sourcePageId, - workspaceId, - ); - return { ...item, content: doc?.toJSON() ?? item.content }; - }), - ); - - // Collapse `not_found` to `no_access` for share viewers so the response - // can't be used to tell "page is shared but transclusion id doesn't - // match" from "page isn't shared at all". - const sanitized = tokenized.map((item) => - 'status' in item && item.status === 'not_found' - ? { - sourcePageId: item.sourcePageId, - transclusionId: item.transclusionId, - status: 'no_access' as const, - } - : item, - ); - - return { items: sanitized }; + return { + items: await this.sanitizeTransclusionItemsForPublic(items, workspaceId), + }; } async isSharingAllowed( @@ -430,6 +404,38 @@ export class ShareService { return doc?.toJSON() ?? page.content; } + /** + * Sanitization tail shared by every public transclusion surface: tokenize + * each content item against its source page, then hide lookup misses. + */ + async sanitizeTransclusionItemsForPublic( + items: TransclusionLookup[], + workspaceId: string, + ): Promise { + const tokenized = await Promise.all( + items.map(async (item) => { + if ('status' in item) return item; + const doc = await this.prepareContentForShare( + item.content, + item.sourcePageId, + workspaceId, + ); + return { ...item, content: doc?.toJSON() ?? item.content }; + }), + ); + + // Collapse not_found to no_access so hidden sources are indistinguishable from missing ids. + return tokenized.map((item) => + 'status' in item && item.status === 'not_found' + ? { + sourcePageId: item.sourcePageId, + transclusionId: item.transclusionId, + status: 'no_access' as const, + } + : item, + ); + } + /** * Prepare a ProseMirror JSON doc for delivery to a public share viewer. * Performs the two transforms required by the share threat model: diff --git a/apps/server/src/core/workspace/dto/update-workspace.dto.ts b/apps/server/src/core/workspace/dto/update-workspace.dto.ts index 51b749516..eb5e44d29 100644 --- a/apps/server/src/core/workspace/dto/update-workspace.dto.ts +++ b/apps/server/src/core/workspace/dto/update-workspace.dto.ts @@ -39,6 +39,14 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) { @IsBoolean() disablePublicSharing: boolean; + @IsOptional() + @IsBoolean() + allowPublicSpaces: boolean; + + @IsOptional() + @IsBoolean() + publicSpacesDirectory: boolean; + @IsOptional() @IsBoolean() mcpEnabled: boolean; diff --git a/apps/server/src/core/workspace/services/workspace.service.spec.ts b/apps/server/src/core/workspace/services/workspace.service.spec.ts deleted file mode 100644 index 0f5443495..000000000 --- a/apps/server/src/core/workspace/services/workspace.service.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { WorkspaceService } from './workspace.service'; - -describe('WorkspaceService', () => { - let service: WorkspaceService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [WorkspaceService], - }).compile(); - - service = module.get(WorkspaceService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); -}); diff --git a/apps/server/src/core/workspace/services/workspace.service.ts b/apps/server/src/core/workspace/services/workspace.service.ts index 1f5db0fec..c110bb8d1 100644 --- a/apps/server/src/core/workspace/services/workspace.service.ts +++ b/apps/server/src/core/workspace/services/workspace.service.ts @@ -42,6 +42,7 @@ import { import { isPageEmbeddingsTableExists } from '@docmost/db/helpers/helpers'; import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination'; import { ShareRepo } from '@docmost/db/repos/share/share.repo'; +import { PublicSpaceRepo } from '@docmost/db/repos/public-space/public-space.repo'; import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo'; import { FavoriteRepo } from '@docmost/db/repos/favorite/favorite.repo'; import { AuditEvent, AuditResource } from '../../../common/events/audit-events'; @@ -65,6 +66,7 @@ export class WorkspaceService { private domainService: DomainService, private licenseCheckService: LicenseCheckService, private shareRepo: ShareRepo, + private readonly publicSpaceRepo: PublicSpaceRepo, private watcherRepo: WatcherRepo, private favoriteRepo: FavoriteRepo, @InjectKysely() private readonly db: KyselyDB, @@ -504,6 +506,47 @@ export class WorkspaceService { } } + if ( + !this.environmentService.isBetaPublicSpaces() && + (typeof updateWorkspaceDto.allowPublicSpaces !== 'undefined' || + typeof updateWorkspaceDto.publicSpacesDirectory !== 'undefined') + ) { + throw new ForbiddenException( + 'Public spaces are not enabled on this instance', + ); + } + + if (typeof updateWorkspaceDto.allowPublicSpaces !== 'undefined') { + const prev = settingsBefore?.publicSpaces?.enabled ?? false; + if (prev !== updateWorkspaceDto.allowPublicSpaces) { + before.allowPublicSpaces = prev; + after.allowPublicSpaces = updateWorkspaceDto.allowPublicSpaces; + } + await this.workspaceRepo.updatePublicSpacesSettings( + workspaceId, + 'enabled', + updateWorkspaceDto.allowPublicSpaces, + trx, + ); + if (!updateWorkspaceDto.allowPublicSpaces) { + await this.publicSpaceRepo.disableByWorkspaceId(workspaceId, trx); + } + } + + if (typeof updateWorkspaceDto.publicSpacesDirectory !== 'undefined') { + const prev = settingsBefore?.publicSpaces?.directory ?? false; + if (prev !== updateWorkspaceDto.publicSpacesDirectory) { + before.publicSpacesDirectory = prev; + after.publicSpacesDirectory = updateWorkspaceDto.publicSpacesDirectory; + } + await this.workspaceRepo.updatePublicSpacesSettings( + workspaceId, + 'directory', + updateWorkspaceDto.publicSpacesDirectory, + trx, + ); + } + if (typeof updateWorkspaceDto.mcpEnabled !== 'undefined') { const prev = settingsBefore?.ai?.mcp ?? false; if (prev !== updateWorkspaceDto.mcpEnabled) { @@ -620,6 +663,8 @@ export class WorkspaceService { delete updateWorkspaceDto.aiSearch; delete updateWorkspaceDto.generativeAi; delete updateWorkspaceDto.disablePublicSharing; + delete updateWorkspaceDto.allowPublicSpaces; + delete updateWorkspaceDto.publicSpacesDirectory; delete updateWorkspaceDto.mcpEnabled; delete updateWorkspaceDto.allowMemberTemplates; delete updateWorkspaceDto.aiChat; diff --git a/apps/server/src/database/database.module.ts b/apps/server/src/database/database.module.ts index d5ecb4ac9..89164acbe 100644 --- a/apps/server/src/database/database.module.ts +++ b/apps/server/src/database/database.module.ts @@ -22,6 +22,7 @@ import { UserTokenRepo } from './repos/user-token/user-token.repo'; import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo'; import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo'; import { ShareRepo } from '@docmost/db/repos/share/share.repo'; +import { PublicSpaceRepo } from '@docmost/db/repos/public-space/public-space.repo'; import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo'; import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo'; import { LabelRepo } from '@docmost/db/repos/label/label.repo'; @@ -88,6 +89,7 @@ import { normalizePostgresUrl } from '../common/helpers'; UserSessionRepo, BacklinkRepo, ShareRepo, + PublicSpaceRepo, NotificationRepo, WatcherRepo, LabelRepo, @@ -113,6 +115,7 @@ import { normalizePostgresUrl } from '../common/helpers'; UserSessionRepo, BacklinkRepo, ShareRepo, + PublicSpaceRepo, NotificationRepo, WatcherRepo, LabelRepo, diff --git a/apps/server/src/database/migrations/20260904T171920-public-spaces.ts b/apps/server/src/database/migrations/20260904T171920-public-spaces.ts new file mode 100644 index 000000000..2408faadf --- /dev/null +++ b/apps/server/src/database/migrations/20260904T171920-public-spaces.ts @@ -0,0 +1,38 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('public_spaces') + .addColumn('id', 'uuid', (col) => + col.primaryKey().defaultTo(sql`gen_uuid_v7()`), + ) + .addColumn('space_id', 'uuid', (col) => + col.references('spaces.id').onDelete('cascade').notNull().unique(), + ) + .addColumn('workspace_id', 'uuid', (col) => + col.references('workspaces.id').onDelete('cascade').notNull(), + ) + .addColumn('enabled', 'boolean', (col) => col.notNull().defaultTo(false)) + .addColumn('search_indexing', 'boolean', (col) => + col.notNull().defaultTo(false), + ) + .addColumn('settings', 'jsonb', (col) => col) + .addColumn('creator_id', 'uuid', (col) => col.references('users.id')) + .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('public_spaces_workspace_id_idx') + .on('public_spaces') + .column('workspace_id') + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('public_spaces').execute(); +} diff --git a/apps/server/src/database/repos/page/page.repo.ts b/apps/server/src/database/repos/page/page.repo.ts index 9eb9f3a50..389314236 100644 --- a/apps/server/src/database/repos/page/page.repo.ts +++ b/apps/server/src/database/repos/page/page.repo.ts @@ -605,4 +605,83 @@ export class PageRepo { .execute() ); } + + /** + * All pages of a space excluding restricted subtrees. + * Used by public spaces; a restricted page hides its whole subtree. + */ + async getSpacePagesExcludingRestricted(spaceId: string) { + return this.db + .withRecursive('page_hierarchy', (db) => + db + .selectFrom('pages') + .leftJoin('pageAccess', 'pageAccess.pageId', 'pages.id') + .select([ + 'pages.id', + 'pages.slugId', + 'pages.title', + 'pages.icon', + 'pages.position', + 'pages.parentPageId', + 'pages.spaceId', + 'pages.workspaceId', + sql`page_access.id IS NOT NULL`.as('isRestricted'), + ]) + .where('pages.spaceId', '=', spaceId) + .where('pages.parentPageId', 'is', null) + .where('pages.deletedAt', 'is', null) + .unionAll((exp) => + exp + .selectFrom('pages as p') + .innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id') + .leftJoin('pageAccess', 'pageAccess.pageId', 'p.id') + .select([ + 'p.id', + 'p.slugId', + 'p.title', + 'p.icon', + 'p.position', + 'p.parentPageId', + 'p.spaceId', + 'p.workspaceId', + sql`page_access.id IS NOT NULL`.as('isRestricted'), + ]) + .where('p.deletedAt', 'is', null) + .where('ph.isRestricted', '=', false), + ), + ) + .selectFrom('page_hierarchy') + .select([ + 'id', + 'slugId', + 'title', + 'icon', + 'position', + 'parentPageId', + 'spaceId', + 'workspaceId', + ]) + .where('isRestricted', '=', false) + .execute(); + } + + async getFirstUnrestrictedRootPage(spaceId: string) { + return this.db + .selectFrom('pages') + .select(['id', 'slugId']) + .where('spaceId', '=', spaceId) + .where('parentPageId', 'is', null) + .where('deletedAt', 'is', null) + .where(({ not, exists, selectFrom }) => + not( + exists( + selectFrom('pageAccess') + .select('pageAccess.id') + .whereRef('pageAccess.pageId', '=', 'pages.id'), + ), + ), + ) + .orderBy('position', (ob) => ob.collate('C').asc()) + .executeTakeFirst(); + } } diff --git a/apps/server/src/database/repos/public-space/public-space.repo.ts b/apps/server/src/database/repos/public-space/public-space.repo.ts new file mode 100644 index 000000000..e6b122fd2 --- /dev/null +++ b/apps/server/src/database/repos/public-space/public-space.repo.ts @@ -0,0 +1,196 @@ +import { Injectable } from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; +import { ExpressionBuilder, sql } from 'kysely'; +import { jsonObjectFrom } from 'kysely/helpers/postgres'; +import { KyselyDB, KyselyTransaction } from '../../types/kysely.types'; +import { dbOrTx } from '../../utils'; +import { PublicSpace } from '@docmost/db/types/entity.types'; +import { DB, Json } from '@docmost/db/types/db'; +import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; +import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination'; + +@Injectable() +export class PublicSpaceRepo { + constructor(@InjectKysely() private readonly db: KyselyDB) {} + + async findBySpaceId( + spaceId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + return db + .selectFrom('publicSpaces') + .selectAll() + .where('spaceId', '=', spaceId) + .executeTakeFirst(); + } + + async upsert(opts: { + spaceId: string; + workspaceId: string; + enabled: boolean; + searchIndexing?: boolean; + creatorId: string; + settings?: Record; + }): Promise { + const settingsColumn = + typeof opts.settings !== 'undefined' + ? { settings: sql`${JSON.stringify(opts.settings)}::text::jsonb` } + : {}; + + return this.db + .insertInto('publicSpaces') + .values({ + spaceId: opts.spaceId, + workspaceId: opts.workspaceId, + enabled: opts.enabled, + searchIndexing: opts.searchIndexing ?? false, + creatorId: opts.creatorId, + ...settingsColumn, + }) + .onConflict((oc) => + oc.column('spaceId').doUpdateSet({ + enabled: opts.enabled, + updatedAt: new Date(), + ...(typeof opts.searchIndexing !== 'undefined' + ? { searchIndexing: opts.searchIndexing } + : {}), + ...settingsColumn, + }), + ) + .returningAll() + .executeTakeFirst(); + } + + // Published spaces are public by definition, so the list spans the whole + // workspace; userId only resolves the viewer's per-space role. + async getPublishedSpaces( + userId: string, + workspaceId: string, + pagination: PaginationOptions, + ) { + const query = this.db + .selectFrom('publicSpaces') + .select([ + 'id', + 'spaceId', + 'workspaceId', + 'searchIndexing', + 'settings', + 'createdAt', + 'updatedAt', + ]) + .select((eb) => this.withSpace(eb, userId)) + .select((eb) => this.withCreator(eb)) + .where('workspaceId', '=', workspaceId) + .where('enabled', '=', true) + .where(({ exists, selectFrom }) => + exists( + selectFrom('spaces') + .select('spaces.id') + .whereRef('spaces.id', '=', 'publicSpaces.spaceId') + .where('spaces.deletedAt', 'is', null), + ), + ); + + return executeWithCursorPagination(query, { + perPage: pagination.limit, + cursor: pagination.cursor, + beforeCursor: pagination.beforeCursor, + fields: [ + { expression: 'updatedAt', direction: 'desc' }, + { expression: 'id', direction: 'desc' }, + ], + parseCursor: (cursor) => ({ + updatedAt: new Date(cursor.updatedAt), + id: cursor.id, + }), + }); + } + + withSpace(eb: ExpressionBuilder, userId: string) { + return jsonObjectFrom( + eb + .selectFrom('spaces') + .select(['spaces.id', 'spaces.name', 'spaces.slug', 'spaces.logo']) + .select((eb) => this.withUserSpaceRole(eb, userId)) + .whereRef('spaces.id', '=', 'publicSpaces.spaceId'), + ).as('space'); + } + + withUserSpaceRole(eb: ExpressionBuilder, userId: string) { + return eb + .selectFrom( + eb + .selectFrom('spaceMembers') + .select(['spaceMembers.role']) + .whereRef('spaceMembers.spaceId', '=', 'spaces.id') + .where('spaceMembers.userId', '=', userId) + .unionAll( + eb + .selectFrom('spaceMembers') + .innerJoin( + 'groupUsers', + 'groupUsers.groupId', + 'spaceMembers.groupId', + ) + .select(['spaceMembers.role']) + .whereRef('spaceMembers.spaceId', '=', 'spaces.id') + .where('groupUsers.userId', '=', userId), + ) + .as('roles_union'), + ) + .select('roles_union.role') + .orderBy( + sql`CASE roles_union.role + WHEN 'admin' THEN 3 + WHEN 'writer' THEN 2 + WHEN 'reader' THEN 1 + ELSE 0 + END`, + 'desc', + ) + .limit(1) + .as('userRole'); + } + + withCreator(eb: ExpressionBuilder) { + return jsonObjectFrom( + eb + .selectFrom('users') + .select(['users.id', 'users.name', 'users.avatarUrl']) + .whereRef('users.id', '=', 'publicSpaces.creatorId'), + ).as('creator'); + } + + async findEnabledWithSpaceByWorkspaceId(workspaceId: string) { + return this.db + .selectFrom('publicSpaces') + .innerJoin('spaces', 'spaces.id', 'publicSpaces.spaceId') + .select([ + 'publicSpaces.settings', + 'publicSpaces.searchIndexing', + 'spaces.name', + 'spaces.slug', + 'spaces.description', + 'spaces.logo', + ]) + .where('publicSpaces.workspaceId', '=', workspaceId) + .where('publicSpaces.enabled', '=', true) + .where('spaces.deletedAt', 'is', null) + .orderBy('spaces.name', 'asc') + .execute(); + } + + async disableByWorkspaceId( + workspaceId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + await db + .updateTable('publicSpaces') + .set({ enabled: false, updatedAt: new Date() }) + .where('workspaceId', '=', workspaceId) + .execute(); + } +} diff --git a/apps/server/src/database/repos/space/space.repo.ts b/apps/server/src/database/repos/space/space.repo.ts index 82fc170bd..f91aae55e 100644 --- a/apps/server/src/database/repos/space/space.repo.ts +++ b/apps/server/src/database/repos/space/space.repo.ts @@ -33,6 +33,7 @@ export class SpaceRepo { .selectFrom('spaces') .selectAll('spaces') .$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount)) + .select((eb) => this.withIsPublished(eb)) .where('workspaceId', '=', workspaceId); if (isValidUUID(spaceId)) { @@ -52,6 +53,7 @@ export class SpaceRepo { .selectFrom('spaces') .selectAll('spaces') .$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount)) + .select((eb) => this.withIsPublished(eb)) .where(sql`LOWER(slug)`, '=', sql`LOWER(${slug})`) .where('workspaceId', '=', workspaceId) .executeTakeFirst(); @@ -170,6 +172,7 @@ export class SpaceRepo { .selectFrom('spaces') .selectAll('spaces') .select((eb) => [this.withMemberCount(eb)]) + .select((eb) => this.withIsPublished(eb)) .where('workspaceId', '=', workspaceId); if (pagination.query) { @@ -221,6 +224,18 @@ export class SpaceRepo { .as('memberCount'); } + withIsPublished(eb: ExpressionBuilder) { + return eb + .exists( + eb + .selectFrom('publicSpaces') + .select('publicSpaces.id') + .whereRef('publicSpaces.spaceId', '=', 'spaces.id') + .where('publicSpaces.enabled', '=', true), + ) + .as('isPublished'); + } + async deleteSpace(spaceId: string, workspaceId: string): Promise { await this.db .deleteFrom('spaces') diff --git a/apps/server/src/database/repos/workspace/workspace.repo.ts b/apps/server/src/database/repos/workspace/workspace.repo.ts index a8323d7ce..98608be5c 100644 --- a/apps/server/src/database/repos/workspace/workspace.repo.ts +++ b/apps/server/src/database/repos/workspace/workspace.repo.ts @@ -249,6 +249,26 @@ export class WorkspaceRepo { .executeTakeFirst(); } + async updatePublicSpacesSettings( + workspaceId: string, + prefKey: string, + prefValue: string | boolean, + trx?: KyselyTransaction, + ) { + const db = dbOrTx(this.db, trx); + return db + .updateTable('workspaces') + .set({ + settings: sql`COALESCE(settings, '{}'::jsonb) + || jsonb_build_object('publicSpaces', COALESCE(settings->'publicSpaces', '{}'::jsonb) + || jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`, + updatedAt: new Date(), + }) + .where('id', '=', workspaceId) + .returning(this.baseFields) + .executeTakeFirst(); + } + async updateTemplateSettings( workspaceId: string, prefKey: string, diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index 373eadbf5..0050e32d3 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -336,6 +336,18 @@ export interface Pages { ydoc: Buffer | null; } +export interface PublicSpaces { + createdAt: Generated; + creatorId: string | null; + enabled: Generated; + id: Generated; + searchIndexing: Generated; + settings: Json | null; + spaceId: string; + updatedAt: Generated; + workspaceId: string; +} + export interface Shares { createdAt: Generated; creatorId: string | null; @@ -747,6 +759,7 @@ export interface DB { pageVerifications: PageVerifications; pageVerifiers: PageVerifiers; pages: Pages; + publicSpaces: PublicSpaces; scimTokens: ScimTokens; shares: Shares; siemDestinations: SiemDestinations; diff --git a/apps/server/src/database/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts index 6fd09a963..1458eef54 100644 --- a/apps/server/src/database/types/entity.types.ts +++ b/apps/server/src/database/types/entity.types.ts @@ -31,6 +31,7 @@ import { AuthProviders, AuthAccounts, Shares, + PublicSpaces, Favorites, FileTasks, UserMfa as _UserMFA, @@ -152,6 +153,11 @@ export type Share = Selectable; export type InsertableShare = Insertable; export type UpdatableShare = Updateable>; +// PublicSpace +export type PublicSpace = Selectable; +export type InsertablePublicSpace = Insertable; +export type UpdatablePublicSpace = Updateable>; + // Favorite export type Favorite = Selectable; export type InsertableFavorite = Insertable; diff --git a/apps/server/src/ee b/apps/server/src/ee index 32f166454..039bd87f8 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 32f16645436afd60ac92e8e39cd341eda2557f9f +Subproject commit 039bd87f8a8181ae2d0c2d0dd39f3aa2cfac9029 diff --git a/apps/server/src/integrations/environment/environment.service.ts b/apps/server/src/integrations/environment/environment.service.ts index bb11eafe2..96d174012 100644 --- a/apps/server/src/integrations/environment/environment.service.ts +++ b/apps/server/src/integrations/environment/environment.service.ts @@ -248,6 +248,13 @@ export class EnvironmentService { return disable === 'true'; } + isBetaPublicSpaces(): boolean { + const enabled = this.configService + .get('BETA_PUBLIC_SPACES', 'false') + .toLowerCase(); + return enabled === 'true'; + } + getPostHogHost(): string { return this.configService.get('POSTHOG_HOST'); } diff --git a/apps/server/src/integrations/static/static.module.ts b/apps/server/src/integrations/static/static.module.ts index 5914a3e07..16e3f4cf2 100644 --- a/apps/server/src/integrations/static/static.module.ts +++ b/apps/server/src/integrations/static/static.module.ts @@ -47,6 +47,7 @@ export class StaticModule implements OnModuleInit { BILLING_TRIAL_DAYS: this.environmentService.isCloud() ? this.environmentService.getBillingTrialDays() : undefined, + BETA_PUBLIC_SPACES: this.environmentService.isBetaPublicSpaces(), POSTHOG_HOST: this.environmentService.getPostHogHost(), POSTHOG_KEY: this.environmentService.getPostHogKey(), AI_VECTOR_DRIVER: diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index b6db64627..b51d02941 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -49,6 +49,9 @@ async function bootstrap() { '.well-known/oauth-authorization-server', '.well-known/oauth-protected-resource', '.well-known/oauth-protected-resource/mcp', + 'docs', + 'docs/:spaceSlug', + 'docs/:spaceSlug/:pageSlug', ], }); @@ -70,7 +73,8 @@ async function bootstrap() { // Skipped routes: // /api/files/ - attachment controller sets its own CSP we'd overwrite // /share/ - public share pages are safe to embed - const frameHeaderSkippedPrefixes = ['/api/files/', '/share/']; + // /docs/ - public space pages are safe to embed + const frameHeaderSkippedPrefixes = ['/api/files/', '/share/', '/docs/']; app .getHttpAdapter() .getInstance() diff --git a/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts b/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts index 0377ab7f7..d51a54be3 100644 --- a/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts +++ b/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts @@ -61,6 +61,11 @@ export function markdownToHtml( .trimStart(); resetFootnotes(); - const html = marked.parse(markdown).toString(); + // marked always closes fenced code with a newline that the editor keeps as + // an empty trailing line inside the code block. + const html = marked + .parse(markdown) + .toString() + .replace(/\n<\/code><\/pre>/g, ""); return html + renderFootnotesList(); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 109214fe8..ca7e2d3d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -268,6 +268,9 @@ importers: '@excalidraw/excalidraw': specifier: 0.18.0-3a5ef40 version: 0.18.0-3a5ef40(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@fontsource-variable/inter': + specifier: 5.3.0 + version: 5.3.0 '@mantine/core': specifier: 9.3.2 version: 9.3.2(@mantine/hooks@9.3.2(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -2225,6 +2228,9 @@ packages: '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@fontsource-variable/inter@5.3.0': + resolution: {integrity: sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==} + '@hocuspocus/common@4.5.0': resolution: {integrity: sha512-hz6IBLLNKOWrWf+8r236CFFhpkcjEVXtZH1XRrkY0b5dYoQgR5gmuV5/5dJm1Vvyc8I70D+SHCvOch2AsRXonA==} @@ -12026,6 +12032,8 @@ snapshots: '@floating-ui/utils@0.2.11': {} + '@fontsource-variable/inter@5.3.0': {} + '@hocuspocus/common@4.5.0': dependencies: lib0: 0.2.117 From ac7935eff975c799386ebdef06ed7e23b2e9cf90 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:42:55 +0100 Subject: [PATCH 26/27] chore: package updates (#2474) --- Dockerfile | 2 +- package.json | 58 +-- pnpm-lock.yaml | 900 ++++++++++++++++++++++---------------------- pnpm-workspace.yaml | 7 +- 4 files changed, 491 insertions(+), 476 deletions(-) diff --git a/Dockerfile b/Dockerfile index 8975beab5..33cac2225 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ FROM node:26-slim AS base LABEL org.opencontainers.image.source="https://github.com/docmost/docmost" -RUN npm install -g pnpm@11.23.0 +RUN npm install -g pnpm@11.25.0 FROM base AS builder diff --git a/package.json b/package.json index 7f6ecdd31..342ec5001 100644 --- a/package.json +++ b/package.json @@ -31,34 +31,34 @@ "@joplin/turndown": "4.0.82", "@joplin/turndown-plugin-gfm": "1.0.64", "@sindresorhus/slugify": "3.0.0", - "@tiptap/core": "3.29.2", - "@tiptap/extension-audio": "3.29.2", - "@tiptap/extension-code-block": "3.29.2", - "@tiptap/extension-collaboration": "3.29.2", - "@tiptap/extension-collaboration-caret": "3.29.2", - "@tiptap/extension-color": "3.29.2", - "@tiptap/extension-document": "3.29.2", - "@tiptap/extension-heading": "3.29.2", - "@tiptap/extension-highlight": "3.29.2", - "@tiptap/extension-history": "3.29.2", - "@tiptap/extension-image": "3.29.2", - "@tiptap/extension-link": "3.29.2", - "@tiptap/extension-list": "3.29.2", - "@tiptap/extension-placeholder": "3.29.2", - "@tiptap/extension-subscript": "3.29.2", - "@tiptap/extension-superscript": "3.29.2", - "@tiptap/extension-table": "3.29.2", - "@tiptap/extension-text": "3.29.2", - "@tiptap/extension-text-align": "3.29.2", - "@tiptap/extension-text-style": "3.29.2", - "@tiptap/extension-typography": "3.29.2", - "@tiptap/extension-unique-id": "3.29.2", - "@tiptap/extension-youtube": "3.29.2", - "@tiptap/html": "3.29.2", - "@tiptap/pm": "3.29.2", - "@tiptap/react": "3.29.2", - "@tiptap/starter-kit": "3.29.2", - "@tiptap/suggestion": "3.29.2", + "@tiptap/core": "3.31.3", + "@tiptap/extension-audio": "3.31.3", + "@tiptap/extension-code-block": "3.31.3", + "@tiptap/extension-collaboration": "3.31.3", + "@tiptap/extension-collaboration-caret": "3.31.3", + "@tiptap/extension-color": "3.31.3", + "@tiptap/extension-document": "3.31.3", + "@tiptap/extension-heading": "3.31.3", + "@tiptap/extension-highlight": "3.31.3", + "@tiptap/extension-history": "3.31.3", + "@tiptap/extension-image": "3.31.3", + "@tiptap/extension-link": "3.31.3", + "@tiptap/extension-list": "3.31.3", + "@tiptap/extension-placeholder": "3.31.3", + "@tiptap/extension-subscript": "3.31.3", + "@tiptap/extension-superscript": "3.31.3", + "@tiptap/extension-table": "3.31.3", + "@tiptap/extension-text": "3.31.3", + "@tiptap/extension-text-align": "3.31.3", + "@tiptap/extension-text-style": "3.31.3", + "@tiptap/extension-typography": "3.31.3", + "@tiptap/extension-unique-id": "3.31.3", + "@tiptap/extension-youtube": "3.31.3", + "@tiptap/html": "3.31.3", + "@tiptap/pm": "3.31.3", + "@tiptap/react": "3.31.3", + "@tiptap/starter-kit": "3.31.3", + "@tiptap/suggestion": "3.31.3", "@tiptap/y-tiptap": "3.0.7", "bytes": "3.1.2", "cross-env": "10.1.0", @@ -95,5 +95,5 @@ "packages/*" ] }, - "packageManager": "pnpm@11.23.0" + "packageManager": "pnpm@11.25.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca7e2d3d8..1bb461d7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,11 +17,12 @@ overrides: express-rate-limit: 8.2.2 flatted: 3.4.2 find-my-way: 9.7.0 + fastify: 5.12.3 yaml@>=2.0.0 <2.8.3: 2.8.3 brace-expansion@^5: 5.0.9 axios: 1.18.1 ip-address: 10.3.1 - fast-uri: 3.1.5 + fast-uri: 3.1.7 form-data@>=4.0.0 <4.0.6: 4.0.6 nanoid@>=4.0.0 <5.1.16: 5.1.16 esbuild@>=0.27.3 <0.28.1: 0.28.1 @@ -62,7 +63,7 @@ importers: version: 4.5.0(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30) '@hocuspocus/transformer': specifier: 4.5.0 - version: 4.5.0(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)(y-prosemirror@1.3.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30) + version: 4.5.0(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(y-prosemirror@1.3.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.3)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30) '@joplin/turndown': specifier: 4.0.82 version: 4.0.82(supports-color@7.2.0) @@ -73,92 +74,92 @@ importers: specifier: 3.0.0 version: 3.0.0 '@tiptap/core': - specifier: 3.29.2 - version: 3.29.2(@tiptap/pm@3.29.2) + specifier: 3.31.3 + version: 3.31.3(@tiptap/pm@3.31.3) '@tiptap/extension-audio': - specifier: 3.29.2 - version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) '@tiptap/extension-code-block': - specifier: 3.29.2 - version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/extension-collaboration': - specifier: 3.29.2 - version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)(@tiptap/y-tiptap@3.0.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@tiptap/y-tiptap@3.0.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.3)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30) '@tiptap/extension-collaboration-caret': - specifier: 3.29.2 - version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)(@tiptap/y-tiptap@3.0.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@tiptap/y-tiptap@3.0.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.3)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30)) '@tiptap/extension-color': - specifier: 3.29.2 - version: 3.29.2(@tiptap/extension-text-style@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))) + specifier: 3.31.3 + version: 3.31.3(@tiptap/extension-text-style@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))) '@tiptap/extension-document': - specifier: 3.29.2 - version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) '@tiptap/extension-heading': - specifier: 3.29.2 - version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) '@tiptap/extension-highlight': - specifier: 3.29.2 - version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) '@tiptap/extension-history': - specifier: 3.29.2 - version: 3.29.2(@tiptap/extensions@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) '@tiptap/extension-image': - specifier: 3.29.2 - version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) '@tiptap/extension-link': - specifier: 3.29.2 - version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/extension-list': - specifier: 3.29.2 - version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/extension-placeholder': - specifier: 3.29.2 - version: 3.29.2(@tiptap/extensions@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) '@tiptap/extension-subscript': - specifier: 3.29.2 - version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/extension-superscript': - specifier: 3.29.2 - version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/extension-table': - specifier: 3.29.2 - version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/extension-text': - specifier: 3.29.2 - version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) '@tiptap/extension-text-align': - specifier: 3.29.2 - version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) '@tiptap/extension-text-style': - specifier: 3.29.2 - version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) '@tiptap/extension-typography': - specifier: 3.29.2 - version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) '@tiptap/extension-unique-id': - specifier: 3.29.2 - version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/extension-youtube': - specifier: 3.29.2 - version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) '@tiptap/html': - specifier: 3.29.2 - version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)(happy-dom@20.8.9) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(happy-dom@20.8.9) '@tiptap/pm': - specifier: 3.29.2 - version: 3.29.2 + specifier: 3.31.3 + version: 3.31.3 '@tiptap/react': - specifier: 3.29.2 - version: 3.29.2(@floating-ui/dom@1.7.3)(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + specifier: 3.31.3 + version: 3.31.3(@floating-ui/dom@1.7.3)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@tiptap/starter-kit': - specifier: 3.29.2 - version: 3.29.2 + specifier: 3.31.3 + version: 3.31.3 '@tiptap/suggestion': - specifier: 3.29.2 - version: 3.29.2(@floating-ui/dom@1.7.3)(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) + specifier: 3.31.3 + version: 3.31.3(@floating-ui/dom@1.7.3)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/y-tiptap': specifier: 3.0.7 - version: 3.0.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30) + version: 3.0.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.3)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30) bytes: specifier: 3.1.2 version: 3.1.2 @@ -212,7 +213,7 @@ importers: version: 9.0.12(yjs@13.6.30) y-prosemirror: specifier: 1.3.7 - version: 1.3.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30) + version: 1.3.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.3)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30) yjs: specifier: ^13.6.30 version: 13.6.30 @@ -2268,22 +2269,22 @@ packages: peerDependencies: hono: ^4 - '@humanfs/core@0.19.1': - resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} - '@humanfs/node@0.16.6': - resolution: {integrity: sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==} + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} engines: {node: '>=18.18.0'} '@humanwhocodes/module-importer@1.0.1': resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} engines: {node: '>=12.22'} - '@humanwhocodes/retry@0.3.0': - resolution: {integrity: sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew==} - engines: {node: '>=18.18'} - '@humanwhocodes/retry@0.4.3': resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} @@ -4119,258 +4120,258 @@ packages: '@types/react-dom': optional: true - '@tiptap/core@3.29.2': - resolution: {integrity: sha512-oKUkiPUB7noilVYxI9lNzUD4rX17sHub+PYjMfHMWHG9A3nvIy+FdePIVIIhThKWF7ijhr3eIqHY51Bn+GAFtw==} + '@tiptap/core@3.31.3': + resolution: {integrity: sha512-Cz50pvciQrxdSxgTkHOVz0uD0Yl/8Xt0QatGD6ILm47jW8EzyHR9RkUGs/D5IqzXKuVPntfw1ttaT926vXfiRg==} peerDependencies: - '@tiptap/pm': 3.29.2 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-audio@3.29.2': - resolution: {integrity: sha512-awpsK0X+oWVoTDN8ZZQ6DUSSV+sPEQYeq6nbYJuqlmde0NDv9u9chgqOYLN5xOsk26YqCkVuWL40D+6rWkh89Q==} + '@tiptap/extension-audio@3.31.3': + resolution: {integrity: sha512-ebkcG4s1j4AQJozXaW+FQuoHCP0B2KysJd8GYJyFeIXt6IT7f40+AyVGqD9//CjdmjEbU4JlmLCzB3J+mZJKUw==} peerDependencies: - '@tiptap/core': 3.29.2 + '@tiptap/core': 3.31.3 - '@tiptap/extension-blockquote@3.29.2': - resolution: {integrity: sha512-ca4OzKDh0yaxg2+Z56bC2QnWsNsFp2YMRfVig1PDXyMVFMNJpLcnhxgq/9btn+xYAlYrj8RymOeCTYREOR6Zjg==} + '@tiptap/extension-blockquote@3.31.3': + resolution: {integrity: sha512-fyY2XMbyDDDfOTQ1Qdrnqa1qwC9DWE4n7AfE0EKQI0G8MfLV8RaDlLDcOZDJ9JbMPY7/Gx7EjyK4NKxSW9n2hQ==} peerDependencies: - '@tiptap/core': 3.29.2 - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-bold@3.29.2': - resolution: {integrity: sha512-elYbGxJsYnBb4leqrcjdIJuiG380BcOgN+UUzvOv+qEjfGVzHodFOMBl3qnmD6urYHNu5/qQK2S0qSSRXKCLNQ==} + '@tiptap/extension-bold@3.31.3': + resolution: {integrity: sha512-dIuYhKk8TitKU/FeDpoTeWZhU42YgDN5npgWNjAmMmRktPdoxnH3/wGSiwXlqZgJWjehN7kPWDePWpJeAImGpQ==} peerDependencies: - '@tiptap/core': 3.29.2 + '@tiptap/core': 3.31.3 - '@tiptap/extension-bubble-menu@3.29.2': - resolution: {integrity: sha512-kzcWarcpr031rZu+R3Hzut5uxb3Qj/xxMDEYZIQ3uiq1yb5vEMXj8/SIyWautk6Nu8Rr1leV3rGdR4NzhzyFAA==} + '@tiptap/extension-bubble-menu@3.31.3': + resolution: {integrity: sha512-EV6ZnwKc++2OM/OcD54n8s1B7C9LP7GKAtdEPwu0t3BYJf3sG8Ueoinf7SgGPO9oMPg96GneOkDNm1urMV167g==} peerDependencies: - '@tiptap/core': 3.29.2 - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-bullet-list@3.29.2': - resolution: {integrity: sha512-3bWcCUPbCHv0XttlMdnAtXLNYWx2pblByMgxmGsaP9FU0QnslGXty6A6gHCqI33ygRg1vrA6U5Wtpwbi5aKu5g==} + '@tiptap/extension-bullet-list@3.31.3': + resolution: {integrity: sha512-qEyyoPapPef4LO8XKaN83bxtaNzkJ4kFn/IxLnEKd4BJ3Mvi4MH2yJYlyDqwFnMoev4pMA1zBHRAt/C0THRmZw==} peerDependencies: - '@tiptap/extension-list': 3.29.2 + '@tiptap/extension-list': 3.31.3 - '@tiptap/extension-code-block@3.29.2': - resolution: {integrity: sha512-w153ct8g6dLiPTdXQ6SOIMxX4SEo5Q50AmjdEEEcJ7ZcYUcde/ScSskLHfOYmyt5ZFAiyEwr121+pux+p3/oAQ==} + '@tiptap/extension-code-block@3.31.3': + resolution: {integrity: sha512-nvknt4FhyJQjYcvxptmeUlFsIAc8ibua3E5BN4Pim374/9RWepH4cdE9X0/qUTtguHElLx0iKtSIY3rW2qGTFA==} peerDependencies: - '@tiptap/core': 3.29.2 - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-code@3.29.2': - resolution: {integrity: sha512-c6W5UGuB7WNLpYocsgRzpO2OOTI4QjaI9jjHRMuty9z+s9DtaYM/HrRLNwVh6MopkHb+i/89Wkv8gCS34fftig==} + '@tiptap/extension-code@3.31.3': + resolution: {integrity: sha512-SzxOqchrD2AcN3uT67PjKmRFEMOU3vNNiwNaamZJUbZrI6Hmy+bvdHJrc3jrIddyyCrAtsnAfRI0cmorW1jGfg==} peerDependencies: - '@tiptap/core': 3.29.2 + '@tiptap/core': 3.31.3 - '@tiptap/extension-collaboration-caret@3.29.2': - resolution: {integrity: sha512-Mjq4RRgN4rCn+HAkX2lEH7PcY1vnKf4qQetn7Y+7q15IwM/UXStbmhT3YW49AoIWMrA2FApyYlu1dtXGefIa2w==} + '@tiptap/extension-collaboration-caret@3.31.3': + resolution: {integrity: sha512-ub2SsYwmEcmTbqk4aC55M6AnU5/jJcr85lk0jQTqoo1h4ADGYcVXKaJY/CgDCxJgPxr6e/+IiNSYTLjW0nOEgg==} peerDependencies: - '@tiptap/core': 3.29.2 - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 '@tiptap/y-tiptap': ^3.0.7 - '@tiptap/extension-collaboration@3.29.2': - resolution: {integrity: sha512-KVKwIofMzraf0MxCXUkYd6fNmz0oGKaHDkjhNT6HGHrVzTFDJlZTwHHwSndQS2xKGmZbhWe8C32hvUrFsZXTPw==} + '@tiptap/extension-collaboration@3.31.3': + resolution: {integrity: sha512-JAwOXjeeDYghrPcK57dtvxwn06zcz50mxSSk4PaSdLxM8tkw8+udx+51ewqKKd3WlFof4zyMwUEbO/DGaUdEvw==} peerDependencies: - '@tiptap/core': 3.29.2 - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 '@tiptap/y-tiptap': ^3.0.7 yjs: ^13 - '@tiptap/extension-color@3.29.2': - resolution: {integrity: sha512-DMeyDRGZXIe2m2/QlrBoN15R4Ufwz3ERiG//OM7bjcYpiTZiO+pLC6tyabI1+QcJrYUGTu5zVah583xXzt89Kw==} + '@tiptap/extension-color@3.31.3': + resolution: {integrity: sha512-rsZ/HlCYagMoyqn8kKxLYaZwHvowGUTeHVVxEuMJqTantIrnnQMSQUA3a7+X22l2eD7ec/LlG4bBGPjw+gf3tw==} peerDependencies: - '@tiptap/extension-text-style': 3.29.2 + '@tiptap/extension-text-style': 3.31.3 - '@tiptap/extension-document@3.29.2': - resolution: {integrity: sha512-YUamvefLnsqu6124GavVTI7nqcFlQJ12ROB0oSwG69eSBZYNjg1tIs05LFrBxkwf4Xgqd6YzfJ9+FeG428RvzQ==} + '@tiptap/extension-document@3.31.3': + resolution: {integrity: sha512-EexgmqnyDNyGlISxo7SMrp5MygpJYmqD+0cY5jB6L1U6L4CpKKRWUt8OO1sWzEHDE1+TTvwt+WIFoIWAziOtEA==} peerDependencies: - '@tiptap/core': 3.29.2 + '@tiptap/core': 3.31.3 - '@tiptap/extension-dropcursor@3.29.2': - resolution: {integrity: sha512-KKno7cU9r1HdR48CRrsDu69/1UjZdoslq/UcE+Kx+tdhAv/aljXMkRSNzGMrBNOBDmHRgS1+58zm21WQWdQzwA==} + '@tiptap/extension-dropcursor@3.31.3': + resolution: {integrity: sha512-NWomSfu5CSC7VacnMSDzKT8qm66SzMfZwVPEtwY5bPpRTJgTiT1rNK0neDrrzfMN27MfylGyKWWf7Q5Qf8w/fg==} peerDependencies: - '@tiptap/extensions': 3.29.2 + '@tiptap/extensions': 3.31.3 - '@tiptap/extension-floating-menu@3.29.2': - resolution: {integrity: sha512-CBTq4Xs5aGWr65uFCIkKBms796OhmirWf/ax//iJ4fl9IfwqjwFuc2vQs9PmuwpKn9ctDHo2sMTtvyeCvIt5LQ==} + '@tiptap/extension-floating-menu@3.31.3': + resolution: {integrity: sha512-rd4VJ9PGSP9Eop8ZTEwaLZcMzMXLuJKe3hUNf58rq+zANpwM+9fI+vB7g9MAc3eXwUNDxNDVACFIL2oeBqpQ3A==} peerDependencies: '@floating-ui/dom': ^1.0.0 - '@tiptap/core': 3.29.2 - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-gapcursor@3.29.2': - resolution: {integrity: sha512-8Q39UR4/Tit759IeW9xZIe3NMwN11GsuA3FLheDyyGn7RrW02HD3HhUDlazE54Ki4HoosjFmChPlN4Ik2ubdRQ==} + '@tiptap/extension-gapcursor@3.31.3': + resolution: {integrity: sha512-EBXKb1FrVStsNYCcRGtd9jmzveCvR+eqgg1rVqoONrqFK6U7bga6LN+1dMKroP1kliDVgveYkP3vRYxqw+rFqg==} peerDependencies: - '@tiptap/extensions': 3.29.2 + '@tiptap/extensions': 3.31.3 - '@tiptap/extension-hard-break@3.29.2': - resolution: {integrity: sha512-eUW3LN3fq8rXnjEUeI3D2QONYdLsU3yYQm4jxlErs2h4cfwrFjgf19VSUFVmm6LrFbbQ0OnDVPeVLL6iOwDw2w==} + '@tiptap/extension-hard-break@3.31.3': + resolution: {integrity: sha512-QAdCvNO4+yW9ATwsrej11NTkDYFqPLIEQr3ARNrKOK1qaiS7A0fia2SEukb/hrkP3A6mbozhoQt2r2RGUf/DpQ==} peerDependencies: - '@tiptap/core': 3.29.2 + '@tiptap/core': 3.31.3 - '@tiptap/extension-heading@3.29.2': - resolution: {integrity: sha512-6W4aIy70Mh7BNlbG9zZ5FBLhJhU2UUEzgZJ/jwYSCcB30o8McLxJSEjhtoHiX8R78Ah2/JzBGvIe5olZlbeE4A==} + '@tiptap/extension-heading@3.31.3': + resolution: {integrity: sha512-rk5VHMAeQcg06SLauN6EGdD2jc0O2qY8QkZYPd0LxNvLblw2BxBx+lxUQSYwLAT9Ie5914gKIK2YbRyO2Ts3ig==} peerDependencies: - '@tiptap/core': 3.29.2 + '@tiptap/core': 3.31.3 - '@tiptap/extension-highlight@3.29.2': - resolution: {integrity: sha512-bSeZ1C8OlthyK7BzgtqOGfQymdap9V38W2hbr9KGlhVOIAmTqneOwjm27iFcu7jKqkMArVu6WBYN18Lom6umHQ==} + '@tiptap/extension-highlight@3.31.3': + resolution: {integrity: sha512-97m1nDzTAjJaJZWe5hVOBMC2rjBZvEwTXA39zhjnaE/snb8Cy4H9A3+/sw9HQJBXMoG7tdOPG9kUlM3weYtLcQ==} peerDependencies: - '@tiptap/core': 3.29.2 + '@tiptap/core': 3.31.3 - '@tiptap/extension-history@3.29.2': - resolution: {integrity: sha512-AMpvoJxz/gqTMAiUo2LOujR44FoICe9ADaSTJMMV+mc+GxAw3YF3Q6frxIKRZkMGMHYDnHLFei7J2fLvCa4ecA==} + '@tiptap/extension-history@3.31.3': + resolution: {integrity: sha512-aHbCh9UyyuesRCXmoeynxa789aEjx/xCbZfx6bzXaSfLuLtnhW/LeUJ6fs2SIejhXgGC7qgUSxcCnbzZGJWBRA==} peerDependencies: - '@tiptap/extensions': 3.29.2 + '@tiptap/extensions': 3.31.3 - '@tiptap/extension-horizontal-rule@3.29.2': - resolution: {integrity: sha512-8/ZPzbB9X85Mc9/7xVLZupQKBr2UVcQTGr512xtqMW+XkCQRHCph46tRo828YE13IMWI5fWn/FaNCqXG9cULSw==} + '@tiptap/extension-horizontal-rule@3.31.3': + resolution: {integrity: sha512-YnHGy2KShRwvCseAmmxl9VP7R0qaj8QMp3DA6DJWZqp7r5gLGvDkAqhedxqqefqsE4Y43hjkBdjtB9Ce78LkIw==} peerDependencies: - '@tiptap/core': 3.29.2 - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-image@3.29.2': - resolution: {integrity: sha512-F+S4iru247mNVZdkNwNDZHeb281DkjsBJinaOGsOyJTvXY+KU5Uq7vY1LQTn493dTfU48Z0yMBUXwJffCT92Mg==} + '@tiptap/extension-image@3.31.3': + resolution: {integrity: sha512-wWNG9BOtx2Cg4vwxPVGDIJ5DGX+ETkldeoaFJLB1NXUL4OPUiVTf8cHZ+hdYgJWP704BEB7jQgjlpvdi6VigTg==} peerDependencies: - '@tiptap/core': 3.29.2 + '@tiptap/core': 3.31.3 - '@tiptap/extension-italic@3.29.2': - resolution: {integrity: sha512-iH63V/5wsaMnY4Jz0+meaAGhaec4AiOzOduzl6ZZr5IyGhZ1kthyW84ELt0dyLI3hNceAUhaNWc+I7+vX0aoXA==} + '@tiptap/extension-italic@3.31.3': + resolution: {integrity: sha512-ibGvdvAPyfxBMUVNRI43eb9h2/Jka1MRG5GtnGqbcCX/2+Y/y0EOfFrPQPkuYphiWQYyu9+FzPKMB/pjuaLuKQ==} peerDependencies: - '@tiptap/core': 3.29.2 + '@tiptap/core': 3.31.3 - '@tiptap/extension-link@3.29.2': - resolution: {integrity: sha512-DcVer5SqrexKCEP6Ip1UPxJUMvcRCCItSv0wxoGytanrimBh2smvcg6X0DWnjlsi5H0updhyl+atYCmmQXUIXA==} + '@tiptap/extension-link@3.31.3': + resolution: {integrity: sha512-986wOQzTL9Zr5lf84LCLpm+YOms8A0K39/8DVoqRfebqcOe0/eq4bnztmAlfabOd+kJY92g3AgZERFUx/w+dcw==} peerDependencies: - '@tiptap/core': 3.29.2 - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-list-item@3.29.2': - resolution: {integrity: sha512-s8vBVHHFT0Qpu7CzAZ7S1kYmSiVaDvUNvMNcZUWnxj6VPfiwmx0eXd9FsjePrRCMoM5tFmPnhFTHDxY3D/eZeQ==} + '@tiptap/extension-list-item@3.31.3': + resolution: {integrity: sha512-4QlKOriJJMvg95QJTEsy9BUYPBQ6UvJyb8WURRwdUtQUkkqb8h32lg/eyQUv2FzW9IT1AdUyNZfVaxH64kRfQA==} peerDependencies: - '@tiptap/extension-list': 3.29.2 + '@tiptap/extension-list': 3.31.3 - '@tiptap/extension-list-keymap@3.29.2': - resolution: {integrity: sha512-R+3k8OLnxdCH7Xy9ieOwUt5m2Je74u8mikothGmsYVO2Zyq48fIbmZ+X6RBPCu7DBOI2FIUhHEFbKQeDWvDNmA==} + '@tiptap/extension-list-keymap@3.31.3': + resolution: {integrity: sha512-If8UOEdDZbPJU6iYTvLtH6DOp2KBy6BKxg9UELL1AevVetGHEF/7lW8hP50Gn1tHMVpPRqmhSzVrJRpEJJgb/Q==} peerDependencies: - '@tiptap/extension-list': 3.29.2 + '@tiptap/extension-list': 3.31.3 - '@tiptap/extension-list@3.29.2': - resolution: {integrity: sha512-WPZ9BHAPT6QeIm1vdVkuoOWvy9a8/EZeJwV2VhU8LXyTAttvzyj4rsbbHyJWvYWlUSTt/QF2AZ2zhKo7u1w3/A==} + '@tiptap/extension-list@3.31.3': + resolution: {integrity: sha512-LoveGnC0FVdCV4jUNBaG1ZA+KWE07+adzV3kGy6uUYFcJEjbVUHTnPDrBOob3IwOSO3sCwIkvQb6EVYeXn/4yg==} peerDependencies: - '@tiptap/core': 3.29.2 - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-ordered-list@3.29.2': - resolution: {integrity: sha512-ndCunC+UsYOpkOtL7vGnDz21UNa45WUlcO9wMT1fbuYow2QnRhsuMlCWENXI52YPPARWuQ0RDgN7q6TaxPERBg==} + '@tiptap/extension-ordered-list@3.31.3': + resolution: {integrity: sha512-mp3g11NgA/PYu8rj7J7Ez3l4qBy6WfTSmHIG4PZvEGG5w2oUAIkgb9DU7nPPzjmeme27oazFYZw+AtZA0+u4tw==} peerDependencies: - '@tiptap/extension-list': 3.29.2 + '@tiptap/extension-list': 3.31.3 - '@tiptap/extension-paragraph@3.29.2': - resolution: {integrity: sha512-7qJj5YTr11vvjNgjDN1ypOfwTovc0QOCYcit/rskeuVgnmQZOZQzC/BbyKLLG7UGnpRLemU/mEGbW9pAqjAXkQ==} + '@tiptap/extension-paragraph@3.31.3': + resolution: {integrity: sha512-+iPku7wJfy5hbNDNLX8dveFtYVsZMmh7vztjuq8hT3mipSC4IByDehDbU8fzUCjfXiEzmI7mQn7c8LGmHULuzA==} peerDependencies: - '@tiptap/core': 3.29.2 + '@tiptap/core': 3.31.3 - '@tiptap/extension-placeholder@3.29.2': - resolution: {integrity: sha512-/BlpPIq2JZk6zLaeYVOXazi6dmd0iRriTymNEnFn1doManN1y5HED9LsMeb/AhNhauL5AgmcHgpvAjbsN9k4SA==} + '@tiptap/extension-placeholder@3.31.3': + resolution: {integrity: sha512-9jYtR8ELEw7GVaruyrm4oFkPcjig9Q+crc+dpmarhBNXUmxagCdlhVzNwCJ2WJRzvBAtx59sEYqNTU38Wx8S3A==} peerDependencies: - '@tiptap/extensions': 3.29.2 + '@tiptap/extensions': 3.31.3 - '@tiptap/extension-strike@3.29.2': - resolution: {integrity: sha512-aEvLAbddUQZ+FukCreV3q4G2HfNI+odE7E9U+wbq6XsSWKyo8/pDu1muz+TFKNre4blSMOQ3JQmw5UeHDKy+fg==} + '@tiptap/extension-strike@3.31.3': + resolution: {integrity: sha512-G29bhKttYwcKHT+BI6emWVFol3RO/gUXxQVcmr/iT8LXXy7j8J6HFUnsKM+Kg5YlP1rxMRgsa65dbrQVahZi0A==} peerDependencies: - '@tiptap/core': 3.29.2 + '@tiptap/core': 3.31.3 - '@tiptap/extension-subscript@3.29.2': - resolution: {integrity: sha512-3fpp1B2kZWBfmVYZ1ak3yFWiKR3eV9GR/vYGPDMrkZoTdG9LKylRAJs3Fk2gDgTtOPmbrtilGQjl2o55RuoGxw==} + '@tiptap/extension-subscript@3.31.3': + resolution: {integrity: sha512-jUMMtg4QF7wIXIvsgBmgJjdQMETOX4KMF+Nl/0nuCKs59lEi1StOJzkiEdm+UbFhxvvY9B30uLVgzogGIlHHyg==} peerDependencies: - '@tiptap/core': 3.29.2 - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-superscript@3.29.2': - resolution: {integrity: sha512-/bPgdY7zUHtvToHizvzR6Yf4uIWO35cKj9EZsl/vyFb2mXyJ3a2mysCZgZq+5uvhZS/16eNNKOcezlkayhhkMA==} + '@tiptap/extension-superscript@3.31.3': + resolution: {integrity: sha512-WzM84fb8akg1Ni2b1DcE1pE2kkWLbkEVxN2DKElzCVk/kuSNp+VKqOjunHGTnO+kJhXdcmXTVqAO4Vp2kiDxMQ==} peerDependencies: - '@tiptap/core': 3.29.2 - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-table@3.29.2': - resolution: {integrity: sha512-HY3JWD8i/F8W1oWw56DMqEMqxScIAZFRrhL3rUwiuowHG8LLDqDdQjBV3w1DY8lUZOtNY9grpuMtGlPUFrOVAA==} + '@tiptap/extension-table@3.31.3': + resolution: {integrity: sha512-7cnVPHhdiGGeauYqca6JVyPLTqZbqFEEk9nn2e2E8+fBo6zVtV07AktJBqth/XEzjZxcUmGxjoeuWYAisWjUHg==} peerDependencies: - '@tiptap/core': 3.29.2 - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-text-align@3.29.2': - resolution: {integrity: sha512-205FF87zjUEh79n6dgVy9SMj1P+rNserHMqPd8lF7YS2TOWikXPG4PFuQHWXXCYzaCvQXAiATSAyPXXmUWUvaQ==} + '@tiptap/extension-text-align@3.31.3': + resolution: {integrity: sha512-26BJ7c28EyvHzJovNpuenKiIgfRI4KsT/OjBB00y58MyZ0kRXc6dwZVclw8y66clupKTLTWYCDUsy0P/xHj+cA==} peerDependencies: - '@tiptap/core': 3.29.2 + '@tiptap/core': 3.31.3 - '@tiptap/extension-text-style@3.29.2': - resolution: {integrity: sha512-aQad9V9ROaEi3hE4g5z9wQ6lv5FSnzlnWFMVJxRjNlwXgm7H0fymxb4rGfca+pAwYkiRwpKYh1LatpJw2ECQAA==} + '@tiptap/extension-text-style@3.31.3': + resolution: {integrity: sha512-wgjWWrjZwRZHiaDTQPX2am1y/4ePgRgGWF/2MOfSb7g4d5229p4aVtbT1JT7wu9z8OC482pEqcTlhdvgftvW7A==} peerDependencies: - '@tiptap/core': 3.29.2 + '@tiptap/core': 3.31.3 - '@tiptap/extension-text@3.29.2': - resolution: {integrity: sha512-Ubko45JWWHe8glBt2PiGNF8hcbys/JNalFhiR7Y1X4iOOtAxAKJJxh3+eq+//NTlGuBPdWGp7zw8EEUp7anjKA==} + '@tiptap/extension-text@3.31.3': + resolution: {integrity: sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg==} peerDependencies: - '@tiptap/core': 3.29.2 + '@tiptap/core': 3.31.3 - '@tiptap/extension-typography@3.29.2': - resolution: {integrity: sha512-FqgDa9kDfytbh3cSngbU1O9GSYBZQKazqePqJQL6TpjNtt4l9MdnKMZwOSMVsCPJEdFwqymRfE6O7Wtl2bTnZQ==} + '@tiptap/extension-typography@3.31.3': + resolution: {integrity: sha512-Ec2bsAR63dmiLD/5ph8ky3ptHWrBethbPG3a6p2FuB1VJ8LtlocCsDaXR5TvjZkUx2YQIaS2A+yzT5v7cumLgg==} peerDependencies: - '@tiptap/core': 3.29.2 + '@tiptap/core': 3.31.3 - '@tiptap/extension-underline@3.29.2': - resolution: {integrity: sha512-K7XwH/xS/5AIREWQ00VTEf/W5U0olp7j6wwit7cdd/8nHv6h6AGr1+iEApHKoLXWQZLfGQKzJlT9W61LAl+fHA==} + '@tiptap/extension-underline@3.31.3': + resolution: {integrity: sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw==} peerDependencies: - '@tiptap/core': 3.29.2 + '@tiptap/core': 3.31.3 - '@tiptap/extension-unique-id@3.29.2': - resolution: {integrity: sha512-PboSI7+dxqcAJiWQatPOy70Qzju7eIBqQb+m4Bre9jLpH9KYbEXx+oAWSj3MVCPh0qk0GiHQ8nCQhOPqdEs1Dg==} + '@tiptap/extension-unique-id@3.31.3': + resolution: {integrity: sha512-ztX6wWurh8PLseQ+CfaX+QNKM6ARUSMxkGXUmQPWBiOwwRLGctGc4rhaFIaTsCyQlDl3tXA8Po8H2PQZ9AsqDQ==} peerDependencies: - '@tiptap/core': 3.29.2 - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-youtube@3.29.2': - resolution: {integrity: sha512-uROfBCyb0BIY0qxm2FdOAGFYpye7frZAPtfY80lKUB9uUdjJuDTs7cRBvPcnaa8+fOTNwC5wUOMhbPQmOUiLSA==} + '@tiptap/extension-youtube@3.31.3': + resolution: {integrity: sha512-pfl3HkmZEOo4zn4As+CfpyH9hNPwm2tneCtAxd9bd6k8jIkHDfbPhJNepaGOfhNiU06j/ckv3pULm3+GSCszhA==} peerDependencies: - '@tiptap/core': 3.29.2 + '@tiptap/core': 3.31.3 - '@tiptap/extensions@3.29.2': - resolution: {integrity: sha512-BCz+FCAChSYtUe4BFj97HEO+nSK+J7GxbJgZG4Hg7DT/gI+hRyeNndU8efiQAx3WGdzsFi3UxRpcF1tTQM7iMQ==} + '@tiptap/extensions@3.31.3': + resolution: {integrity: sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA==} peerDependencies: - '@tiptap/core': 3.29.2 - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/html@3.29.2': - resolution: {integrity: sha512-hgl2JscI407BxcJG/PUMgyrvW5jNz40wtsZNHiYuVPNYzhSAuC4kFFa7dK7MegZ7OnkDppRy30cr9q5hdAtfqA==} + '@tiptap/html@3.31.3': + resolution: {integrity: sha512-kE0/bIopSdECaNX4f/Z+TNgAqWTACFcExJmzhevE7Kx1zsDKcl9rW6poMbOEWdq2HSYxowm3wGbtTHXu6RXZHA==} peerDependencies: - '@tiptap/core': 3.29.2 - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 happy-dom: ^20.8.9 - '@tiptap/pm@3.29.2': - resolution: {integrity: sha512-GCOme7xHaS+DSoaA4CDcAD3l6JyBlvZhvCyfsy2Vp6j8tEoBkZWio7soYVosmlyn7zq8/64VeFZP5s47yfG7fQ==} + '@tiptap/pm@3.31.3': + resolution: {integrity: sha512-sZime0SWsz/k62W2WvHx5Ig7G2h7kVhrrmnqy+wEgIHfDwEfOlelRjaWCiBCFlF7dxGUntJusCh9FxlLhni0Ag==} - '@tiptap/react@3.29.2': - resolution: {integrity: sha512-ZMKgteYmZXnq7C22U9fbCTC6jAF8XlQM5n2K2FLWCdYAlP4FNV3XeQ9hY2a13KSQ0Q3yltWXZlcWBpt5ClxScg==} + '@tiptap/react@3.31.3': + resolution: {integrity: sha512-QiwQqvaLFLm5EMFu5tg7nAgXJxCUiUTLD8EsK+TqVV5P4bqOoMOCM39khbhXTJyahCuYpiFWx5YOSDtC/JiPtg==} peerDependencies: - '@tiptap/core': 3.29.2 - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 '@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tiptap/starter-kit@3.29.2': - resolution: {integrity: sha512-oTu0tysiqk4zgjEtxRHjAQgxUKaAevZwueOWwSWubHdokqp7SpcbE5n9USJv89HKuTUDm3GjnQH6q8HNn/2DsA==} + '@tiptap/starter-kit@3.31.3': + resolution: {integrity: sha512-WKof9RewdmGHvWJ1wn0/HVNG2mV+HOgVRyJkKekuM9fgr6BZAAH/xZsWE1eon+94JnQ+KtK2ThydXQM/qc6b2A==} - '@tiptap/suggestion@3.29.2': - resolution: {integrity: sha512-ZEhRm0gnRCwCScR9IrnIBhm9sr6U8vpR9oeznYk3hiedZ48zsgohqvgoIYpWcwYWrT2Pb0NrfhCT8IuSzdJHzQ==} + '@tiptap/suggestion@3.31.3': + resolution: {integrity: sha512-z1OQ/Yx6seMZehi1AcmNkyJ8qkHQzybArmCyRdmreS4YL9C4bPMBe5lbMfFsArYs+KD5JyHwps5j7J/YE5BIuw==} peerDependencies: '@floating-ui/dom': ^1.0.0 - '@tiptap/core': 3.29.2 - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 '@tiptap/y-tiptap@3.0.7': resolution: {integrity: sha512-3VG01F7i2JDghWsBZSBKi7ypMiN5UVVSyD18IwoXx7ilm0ZynrTS+XNpmgxfuiSBjdauWk7tUlP04xfM8Bw4Vw==} @@ -5004,10 +5005,9 @@ packages: resolution: {integrity: sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q==} engines: {node: '>= 16'} - '@xmldom/xmldom@0.8.13': - resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} + '@xmldom/xmldom@0.8.15': + resolution: {integrity: sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==} engines: {node: '>=10.0.0'} - deprecated: this version has critical issues, please update to the latest version '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -5312,8 +5312,9 @@ packages: resolution: {integrity: sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==} engines: {node: '>=6.0.0'} - baseline-browser-mapping@2.9.19: - resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} + baseline-browser-mapping@2.11.20: + resolution: {integrity: sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==} + engines: {node: '>=6.0.0'} hasBin: true bcrypt@6.0.0: @@ -5364,8 +5365,8 @@ packages: browser-fs-access@0.29.1: resolution: {integrity: sha512-LSvVX5e21LRrXqVMhqtAwj5xPgDb+fXAIH80NsnCQ9xuZPs2xWsOREi24RKgZa1XOiQRbcmVrv87+ulOKsgjxw==} - browserslist@4.28.1: - resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + browserslist@4.28.8: + resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -5436,8 +5437,8 @@ packages: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} - caniuse-lite@1.0.30001769: - resolution: {integrity: sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==} + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} canvas-roundrect-polyfill@0.0.1: resolution: {integrity: sha512-yWq+R3U3jE+coOeEb3a3GgE2j/0MMiDKM/QpLb6h9ihf5fGY9UXtvK9o4vNqjWXoZz7/3EaSVU3IX53TvFFUOw==} @@ -6156,8 +6157,8 @@ packages: engines: {node: '>=0.12.18'} hasBin: true - electron-to-chromium@1.5.286: - resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} + electron-to-chromium@1.5.420: + resolution: {integrity: sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==} emittery@0.13.1: resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} @@ -6461,8 +6462,8 @@ packages: fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} - fast-uri@3.1.5: - resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} + fast-uri@3.1.7: + resolution: {integrity: sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==} fast-xml-builder@1.2.0: resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} @@ -6481,8 +6482,8 @@ packages: fastify-plugin@6.0.0: resolution: {integrity: sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==} - fastify@5.11.3: - resolution: {integrity: sha512-W6hzDP8s0iSeL7LGwY6Oc/ZxuXWOvFEMs6p2L0Si415YRo27W5pBKdOTXxhemBDeSTAcpYf5evRA9onF2OYhPA==} + fastify@5.12.3: + resolution: {integrity: sha512-reZ8wce5VNCcufIt9AVtzZa3L4u1j8esikn7OEgHWLVpRpL5R7Y2+Xzj70OUkv5zDfzUAxXZT6cu4Rt0zr3EKA==} fastq@1.17.1: resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} @@ -6499,8 +6500,8 @@ packages: picomatch: optional: true - fflate@0.4.8: - resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==} + fflate@0.4.9: + resolution: {integrity: sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==} figures@3.2.0: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} @@ -7975,8 +7976,9 @@ packages: node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} - node-releases@2.0.27: - resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + node-releases@2.0.54: + resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} + engines: {node: '>=18'} nodemailer@9.0.1: resolution: {integrity: sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==} @@ -8408,8 +8410,8 @@ packages: peerDependencies: postcss: '>=8.0.0' - postcss-selector-parser@7.1.1: - resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} + postcss-selector-parser@7.1.6: + resolution: {integrity: sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==} engines: {node: '>=4'} postcss-simple-vars@7.0.1: @@ -8481,6 +8483,9 @@ packages: process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + process-warning@5.1.0: + resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==} + prompts@2.4.2: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} @@ -8524,8 +8529,8 @@ packages: prosemirror-transform@1.12.0: resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==} - prosemirror-view@1.41.9: - resolution: {integrity: sha512-clTunTX+eaLbr87L1V1QPheRlEQJyTlL3gXe9x3jQIk3rL0RVWxviDGz8tFaydwIVm+hKhYCyr+R/zBtWr9s6A==} + prosemirror-view@1.42.3: + resolution: {integrity: sha512-oTN7EtH+CpwxU9NrwEYWd0UZ4JUx7l048l5A2Xppm4p/60isZYLnth9QVQmC3VRIvdrIWCxwZSd+Uz791G31/w==} proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} @@ -8556,8 +8561,8 @@ packages: engines: {node: '>=10.13.0'} hasBin: true - qs@6.15.3: - resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} engines: {node: '>=0.6'} query-selector-shadow-dom@1.0.1: @@ -9596,8 +9601,8 @@ packages: unrs-resolver@1.11.1: resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + update-browserslist-db@1.3.2: + resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -10790,7 +10795,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.7 '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.1 + browserslist: 4.28.8 lru-cache: 5.1.1 semver: 6.3.1 @@ -11938,7 +11943,7 @@ snapshots: dependencies: ajv: 8.18.0 ajv-formats: 3.0.1(ajv@8.18.0) - fast-uri: 3.1.5 + fast-uri: 3.1.7 '@fastify/busboy@3.1.1': {} @@ -12064,29 +12069,32 @@ snapshots: transitivePeerDependencies: - srvx - '@hocuspocus/transformer@4.5.0(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)(y-prosemirror@1.3.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30)': + '@hocuspocus/transformer@4.5.0(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(y-prosemirror@1.3.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.3)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30)': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) - '@tiptap/pm': 3.29.2 - '@tiptap/starter-kit': 3.29.2 - y-prosemirror: 1.3.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + '@tiptap/starter-kit': 3.31.3 + y-prosemirror: 1.3.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.3)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30) yjs: 13.6.30 '@hono/node-server@2.0.12(hono@4.12.34)': dependencies: hono: 4.12.34 - '@humanfs/core@0.19.1': {} - - '@humanfs/node@0.16.6': + '@humanfs/core@0.19.2': dependencies: - '@humanfs/core': 0.19.1 - '@humanwhocodes/retry': 0.3.0 + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} '@humanwhocodes/module-importer@1.0.1': {} - '@humanwhocodes/retry@0.3.0': {} - '@humanwhocodes/retry@0.4.3': {} '@iconify/types@2.0.0': {} @@ -12779,7 +12787,7 @@ snapshots: '@nestjs/common': 11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) '@nestjs/core': 11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/websockets@11.2.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) fast-querystring: 1.1.2 - fastify: 5.11.3 + fastify: 5.12.3 fastify-plugin: 6.0.0 find-my-way: 9.7.0 light-my-request: 6.6.0 @@ -12864,7 +12872,7 @@ snapshots: '@types/xml-encryption': 1.2.4 '@types/xml2js': 0.4.14 '@xmldom/is-dom-node': 1.0.1 - '@xmldom/xmldom': 0.8.13 + '@xmldom/xmldom': 0.8.15 debug: 4.4.3(supports-color@10.2.2) xml-crypto: 6.1.2 xml-encryption: 3.1.0 @@ -14061,200 +14069,200 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@tiptap/core@3.29.2(@tiptap/pm@3.29.2)': + '@tiptap/core@3.31.3(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/pm': 3.29.2 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-audio@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))': + '@tiptap/extension-audio@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-blockquote@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)': + '@tiptap/extension-blockquote@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/extension-bold@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))': + '@tiptap/extension-bold@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-bubble-menu@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)': + '@tiptap/extension-bubble-menu@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: '@floating-ui/dom': 1.7.3 - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 optional: true - '@tiptap/extension-bullet-list@3.29.2(@tiptap/extension-list@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2))': + '@tiptap/extension-bullet-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extension-list': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-code-block@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)': + '@tiptap/extension-code-block@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/extension-code@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))': + '@tiptap/extension-code@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-collaboration-caret@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)(@tiptap/y-tiptap@3.0.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30))': + '@tiptap/extension-collaboration-caret@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@tiptap/y-tiptap@3.0.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.3)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30))': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) - '@tiptap/pm': 3.29.2 - '@tiptap/y-tiptap': 3.0.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + '@tiptap/y-tiptap': 3.0.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.3)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30) - '@tiptap/extension-collaboration@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)(@tiptap/y-tiptap@3.0.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30)': + '@tiptap/extension-collaboration@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@tiptap/y-tiptap@3.0.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.3)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30)': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) - '@tiptap/pm': 3.29.2 - '@tiptap/y-tiptap': 3.0.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + '@tiptap/y-tiptap': 3.0.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.3)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30) yjs: 13.6.30 - '@tiptap/extension-color@3.29.2(@tiptap/extension-text-style@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)))': + '@tiptap/extension-color@3.31.3(@tiptap/extension-text-style@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)))': dependencies: - '@tiptap/extension-text-style': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)) + '@tiptap/extension-text-style': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) - '@tiptap/extension-document@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))': + '@tiptap/extension-document@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-dropcursor@3.29.2(@tiptap/extensions@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2))': + '@tiptap/extension-dropcursor@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extensions': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) + '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-floating-menu@3.29.2(@floating-ui/dom@1.7.3)(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)': + '@tiptap/extension-floating-menu@3.31.3(@floating-ui/dom@1.7.3)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: '@floating-ui/dom': 1.7.3 - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 optional: true - '@tiptap/extension-gapcursor@3.29.2(@tiptap/extensions@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2))': + '@tiptap/extension-gapcursor@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extensions': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) + '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-hard-break@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))': + '@tiptap/extension-hard-break@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-heading@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))': + '@tiptap/extension-heading@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-highlight@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))': + '@tiptap/extension-highlight@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-history@3.29.2(@tiptap/extensions@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2))': + '@tiptap/extension-history@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extensions': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) + '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-horizontal-rule@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)': + '@tiptap/extension-horizontal-rule@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/extension-image@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))': + '@tiptap/extension-image@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-italic@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))': + '@tiptap/extension-italic@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-link@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)': + '@tiptap/extension-link@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 linkifyjs: 4.3.3 - '@tiptap/extension-list-item@3.29.2(@tiptap/extension-list@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2))': + '@tiptap/extension-list-item@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extension-list': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-list-keymap@3.29.2(@tiptap/extension-list@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2))': + '@tiptap/extension-list-keymap@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extension-list': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-list@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)': + '@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/extension-ordered-list@3.29.2(@tiptap/extension-list@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2))': + '@tiptap/extension-ordered-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extension-list': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-paragraph@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))': + '@tiptap/extension-paragraph@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-placeholder@3.29.2(@tiptap/extensions@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2))': + '@tiptap/extension-placeholder@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extensions': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) + '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-strike@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))': + '@tiptap/extension-strike@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-subscript@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)': + '@tiptap/extension-subscript@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/extension-superscript@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)': + '@tiptap/extension-superscript@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/extension-table@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)': + '@tiptap/extension-table@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/extension-text-align@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))': + '@tiptap/extension-text-align@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-text-style@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))': + '@tiptap/extension-text-style@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-text@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))': + '@tiptap/extension-text@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-typography@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))': + '@tiptap/extension-typography@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-underline@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))': + '@tiptap/extension-underline@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-unique-id@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)': + '@tiptap/extension-unique-id@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 uuid: 14.0.0 - '@tiptap/extension-youtube@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))': + '@tiptap/extension-youtube@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extensions@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)': + '@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/html@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)(happy-dom@20.8.9)': + '@tiptap/html@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(happy-dom@20.8.9)': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 happy-dom: 20.8.9 - '@tiptap/pm@3.29.2': + '@tiptap/pm@3.31.3': dependencies: prosemirror-changeset: 2.4.0 prosemirror-commands: 1.7.1 @@ -14268,12 +14276,12 @@ snapshots: prosemirror-state: 1.4.4 prosemirror-tables: 1.8.5 prosemirror-transform: 1.12.0 - prosemirror-view: 1.41.9 + prosemirror-view: 1.42.3 - '@tiptap/react@3.29.2(@floating-ui/dom@1.7.3)(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@tiptap/react@3.31.3(@floating-ui/dom@1.7.3)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) '@types/use-sync-external-store': 0.0.6 @@ -14282,50 +14290,50 @@ snapshots: react-dom: 19.2.7(react@19.2.7) use-sync-external-store: 1.6.0(react@19.2.7) optionalDependencies: - '@tiptap/extension-bubble-menu': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) - '@tiptap/extension-floating-menu': 3.29.2(@floating-ui/dom@1.7.3)(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) + '@tiptap/extension-bubble-menu': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-floating-menu': 3.31.3(@floating-ui/dom@1.7.3)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) transitivePeerDependencies: - '@floating-ui/dom' - '@tiptap/starter-kit@3.29.2': + '@tiptap/starter-kit@3.31.3': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) - '@tiptap/extension-blockquote': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) - '@tiptap/extension-bold': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)) - '@tiptap/extension-bullet-list': 3.29.2(@tiptap/extension-list@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)) - '@tiptap/extension-code': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)) - '@tiptap/extension-code-block': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) - '@tiptap/extension-document': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)) - '@tiptap/extension-dropcursor': 3.29.2(@tiptap/extensions@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)) - '@tiptap/extension-gapcursor': 3.29.2(@tiptap/extensions@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)) - '@tiptap/extension-hard-break': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)) - '@tiptap/extension-heading': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)) - '@tiptap/extension-horizontal-rule': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) - '@tiptap/extension-italic': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)) - '@tiptap/extension-link': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) - '@tiptap/extension-list': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) - '@tiptap/extension-list-item': 3.29.2(@tiptap/extension-list@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)) - '@tiptap/extension-list-keymap': 3.29.2(@tiptap/extension-list@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)) - '@tiptap/extension-ordered-list': 3.29.2(@tiptap/extension-list@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)) - '@tiptap/extension-paragraph': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)) - '@tiptap/extension-strike': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)) - '@tiptap/extension-text': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)) - '@tiptap/extension-underline': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)) - '@tiptap/extensions': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/extension-blockquote': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-bold': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-bullet-list': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-code': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-code-block': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-document': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-dropcursor': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-gapcursor': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-hard-break': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-heading': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-horizontal-rule': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-italic': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-link': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-list-item': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-list-keymap': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-ordered-list': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-paragraph': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-strike': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-text': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-underline': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/suggestion@3.29.2(@floating-ui/dom@1.7.3)(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)': + '@tiptap/suggestion@3.31.3(@floating-ui/dom@1.7.3)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: '@floating-ui/dom': 1.7.3 - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/y-tiptap@3.0.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30)': + '@tiptap/y-tiptap@3.0.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.3)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30)': dependencies: lib0: 0.2.117 prosemirror-model: 1.25.11 prosemirror-state: 1.4.4 - prosemirror-view: 1.41.9 + prosemirror-view: 1.42.3 y-protocols: 1.0.6(yjs@13.6.30) yjs: 13.6.30 @@ -15045,7 +15053,7 @@ snapshots: '@xmldom/is-dom-node@1.0.1': {} - '@xmldom/xmldom@0.8.13': {} + '@xmldom/xmldom@0.8.15': {} '@xtuc/ieee754@1.2.0': {} @@ -15142,7 +15150,7 @@ snapshots: ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.5 + fast-uri: 3.1.7 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -15418,7 +15426,7 @@ snapshots: base64url@3.0.1: {} - baseline-browser-mapping@2.9.19: {} + baseline-browser-mapping@2.11.20: {} bcrypt@6.0.0: dependencies: @@ -15447,7 +15455,7 @@ snapshots: http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 - qs: 6.15.3 + qs: 6.16.0 raw-body: 3.0.2 type-is: 2.1.0 transitivePeerDependencies: @@ -15483,13 +15491,13 @@ snapshots: browser-fs-access@0.29.1: {} - browserslist@4.28.1: + browserslist@4.28.8: dependencies: - baseline-browser-mapping: 2.9.19 - caniuse-lite: 1.0.30001769 - electron-to-chromium: 1.5.286 - node-releases: 2.0.27 - update-browserslist-db: 1.2.3(browserslist@4.28.1) + baseline-browser-mapping: 2.11.20 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.420 + node-releases: 2.0.54 + update-browserslist-db: 1.3.2(browserslist@4.28.8) bs-logger@0.2.6: dependencies: @@ -15560,7 +15568,7 @@ snapshots: camelcase@6.3.0: {} - caniuse-lite@1.0.30001769: {} + caniuse-lite@1.0.30001810: {} canvas-roundrect-polyfill@0.0.1: {} @@ -15784,7 +15792,7 @@ snapshots: core-js-compat@3.35.0: dependencies: - browserslist: 4.28.1 + browserslist: 4.28.8 core-js@3.43.0: {} @@ -16280,7 +16288,7 @@ snapshots: ejs@5.0.1: {} - electron-to-chromium@1.5.286: {} + electron-to-chromium@1.5.420: {} emittery@0.13.1: {} @@ -16569,7 +16577,7 @@ snapshots: '@eslint/eslintrc': 3.3.5(supports-color@10.2.2) '@eslint/js': 9.28.0 '@eslint/plugin-kit': 0.3.5 - '@humanfs/node': 0.16.6 + '@humanfs/node': 0.16.8 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 '@types/estree': 1.0.9 @@ -16693,7 +16701,7 @@ snapshots: once: 1.4.0 parseurl: 1.3.3 proxy-addr: 2.0.7 - qs: 6.15.3 + qs: 6.16.0 range-parser: 1.2.1 router: 2.2.0(supports-color@10.2.2) send: 1.2.1(supports-color@10.2.2) @@ -16724,7 +16732,7 @@ snapshots: '@fastify/merge-json-schemas': 0.2.1 ajv: 8.18.0 ajv-formats: 3.0.1(ajv@8.18.0) - fast-uri: 3.1.5 + fast-uri: 3.1.7 json-schema-ref-resolver: 2.0.1 rfdc: 1.3.1 @@ -16733,7 +16741,7 @@ snapshots: '@fastify/merge-json-schemas': 0.2.1 ajv: 8.18.0 ajv-formats: 3.0.1(ajv@8.18.0) - fast-uri: 3.1.5 + fast-uri: 3.1.7 json-schema-ref-resolver: 3.0.0 rfdc: 1.3.1 @@ -16745,7 +16753,7 @@ snapshots: fast-safe-stringify@2.1.1: {} - fast-uri@3.1.5: {} + fast-uri@3.1.7: {} fast-xml-builder@1.2.0: dependencies: @@ -16767,7 +16775,7 @@ snapshots: fastify-plugin@6.0.0: {} - fastify@5.11.3: + fastify@5.12.3: dependencies: '@fastify/ajv-compiler': 4.0.5 '@fastify/error': 4.0.0 @@ -16779,7 +16787,7 @@ snapshots: find-my-way: 9.7.0 light-my-request: 6.6.0 pino: 10.1.0 - process-warning: 5.0.0 + process-warning: 5.1.0 rfdc: 1.3.1 secure-json-parse: 4.0.0 semver: 7.7.4 @@ -16797,7 +16805,7 @@ snapshots: optionalDependencies: picomatch: 4.0.4 - fflate@0.4.8: {} + fflate@0.4.9: {} figures@3.2.0: dependencies: @@ -18200,7 +18208,7 @@ snapshots: mammoth@1.12.0: dependencies: - '@xmldom/xmldom': 0.8.13 + '@xmldom/xmldom': 0.8.15 argparse: 1.0.10 base64-js: 1.5.1 bluebird: 3.4.7 @@ -18401,7 +18409,7 @@ snapshots: node-int64@0.4.0: {} - node-releases@2.0.27: {} + node-releases@2.0.54: {} nodemailer@9.0.1: {} @@ -18906,7 +18914,7 @@ snapshots: on-exit-leak-free: 2.1.2 pino-abstract-transport: 2.0.0 pino-std-serializers: 7.0.0 - process-warning: 5.0.0 + process-warning: 5.1.0 quick-format-unescaped: 4.0.4 real-require: 0.2.0 safe-stable-stringify: 2.4.3 @@ -18971,7 +18979,7 @@ snapshots: postcss-nested@7.0.2(postcss@8.5.25): dependencies: postcss: 8.5.25 - postcss-selector-parser: 7.1.1 + postcss-selector-parser: 7.1.6 postcss-preset-mantine@1.18.0(postcss@8.5.25): dependencies: @@ -18979,7 +18987,7 @@ snapshots: postcss-mixins: 12.1.2(postcss@8.5.25) postcss-nested: 7.0.2(postcss@8.5.25) - postcss-selector-parser@7.1.1: + postcss-selector-parser@7.1.6: dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 @@ -19016,7 +19024,7 @@ snapshots: '@posthog/types': 1.390.2 core-js: 3.43.0 dompurify: 3.4.13 - fflate: 0.4.8 + fflate: 0.4.9 preact: 10.29.2 query-selector-shadow-dom: 1.0.1 web-vitals: 5.3.0 @@ -19054,6 +19062,8 @@ snapshots: process-warning@5.0.0: {} + process-warning@5.1.0: {} + prompts@2.4.2: dependencies: kleur: 3.0.3 @@ -19079,20 +19089,20 @@ snapshots: dependencies: prosemirror-state: 1.4.4 prosemirror-transform: 1.12.0 - prosemirror-view: 1.41.9 + prosemirror-view: 1.42.3 prosemirror-gapcursor@1.4.1: dependencies: prosemirror-keymap: 1.2.3 prosemirror-model: 1.25.11 prosemirror-state: 1.4.4 - prosemirror-view: 1.41.9 + prosemirror-view: 1.42.3 prosemirror-history@1.5.0: dependencies: prosemirror-state: 1.4.4 prosemirror-transform: 1.12.0 - prosemirror-view: 1.41.9 + prosemirror-view: 1.42.3 rope-sequence: 1.3.4 prosemirror-inputrules@1.5.1: @@ -19119,7 +19129,7 @@ snapshots: dependencies: prosemirror-model: 1.25.11 prosemirror-transform: 1.12.0 - prosemirror-view: 1.41.9 + prosemirror-view: 1.42.3 prosemirror-tables@1.8.5: dependencies: @@ -19127,13 +19137,13 @@ snapshots: prosemirror-model: 1.25.11 prosemirror-state: 1.4.4 prosemirror-transform: 1.12.0 - prosemirror-view: 1.41.9 + prosemirror-view: 1.42.3 prosemirror-transform@1.12.0: dependencies: prosemirror-model: 1.25.11 - prosemirror-view@1.41.9: + prosemirror-view@1.42.3: dependencies: prosemirror-model: 1.25.11 prosemirror-state: 1.4.4 @@ -19167,7 +19177,7 @@ snapshots: pngjs: 5.0.0 yargs: 15.4.1 - qs@6.15.3: + qs@6.16.0: dependencies: es-define-property: 1.0.1 side-channel: 1.1.1 @@ -19949,7 +19959,7 @@ snapshots: stripe@17.7.0: dependencies: '@types/node': 25.5.0 - qs: 6.15.3 + qs: 6.16.0 strnum@2.2.3: {} @@ -19979,7 +19989,7 @@ snapshots: formidable: 3.5.4 methods: 1.1.2 mime: 2.6.0 - qs: 6.15.3 + qs: 6.16.0 transitivePeerDependencies: - supports-color @@ -20366,9 +20376,9 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 - update-browserslist-db@1.2.3(browserslist@4.28.1): + update-browserslist-db@1.3.2(browserslist@4.28.8): dependencies: - browserslist: 4.28.1 + browserslist: 4.28.8 escalade: 3.2.0 picocolors: 1.1.1 @@ -20525,7 +20535,7 @@ snapshots: '@webassemblyjs/wasm-parser': 1.14.1 acorn: 8.16.0 acorn-import-phases: 1.0.4(acorn@8.16.0) - browserslist: 4.28.1 + browserslist: 4.28.8 chrome-trace-event: 1.0.3 enhanced-resolve: 5.20.1 es-module-lexer: 2.1.0 @@ -20663,12 +20673,12 @@ snapshots: xml-crypto@6.1.2: dependencies: '@xmldom/is-dom-node': 1.0.1 - '@xmldom/xmldom': 0.8.13 + '@xmldom/xmldom': 0.8.15 xpath: 0.0.33 xml-encryption@3.1.0: dependencies: - '@xmldom/xmldom': 0.8.13 + '@xmldom/xmldom': 0.8.15 escape-html: 1.0.3 xpath: 0.0.32 @@ -20711,12 +20721,12 @@ snapshots: lib0: 0.2.117 yjs: 13.6.30 - y-prosemirror@1.3.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30): + y-prosemirror@1.3.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.3)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30): dependencies: lib0: 0.2.117 prosemirror-model: 1.25.11 prosemirror-state: 1.4.4 - prosemirror-view: 1.41.9 + prosemirror-view: 1.42.3 y-protocols: 1.0.6(yjs@13.6.30) yjs: 13.6.30 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 817e408d5..b624cff00 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -16,11 +16,12 @@ overrides: express-rate-limit: 8.2.2 flatted: 3.4.2 find-my-way: 9.7.0 + fastify: 5.12.3 yaml@>=2.0.0 <2.8.3: 2.8.3 brace-expansion@^5: 5.0.9 axios: 1.18.1 ip-address: 10.3.1 - fast-uri: 3.1.5 + fast-uri: 3.1.7 form-data@>=4.0.0 <4.0.6: 4.0.6 nanoid@>=4.0.0 <5.1.16: 5.1.16 esbuild@>=0.27.3 <0.28.1: 0.28.1 @@ -29,6 +30,10 @@ overrides: js-yaml@>=4.0.0 <4.3.1: 4.3.1 shamefullyHoist: true minimumReleaseAge: 4320 +minimumReleaseAgeExclude: + - '@tiptap/*' + - fastify + - postcss-selector-parser allowBuilds: '@swc/core': true bcrypt: true From 6ff8c7cdfe8aff5ff5e601a53edbd59ef6566b75 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:40:10 +0100 Subject: [PATCH 27/27] refactor(favorites): move favorite filtering into the repository query (#2475) * refactor(favorites): move favorite filtering into the repository query * perf: resolve cleanup targets by primary key --- .../src/core/favorite/favorite.controller.ts | 4 +- .../favorite/services/favorite.service.ts | 29 ++----- .../database/repos/favorite/favorite.repo.ts | 82 +++++++++++++++++-- apps/server/src/ee | 2 +- 4 files changed, 83 insertions(+), 34 deletions(-) diff --git a/apps/server/src/core/favorite/favorite.controller.ts b/apps/server/src/core/favorite/favorite.controller.ts index 47e63aace..2d6c19398 100644 --- a/apps/server/src/core/favorite/favorite.controller.ts +++ b/apps/server/src/core/favorite/favorite.controller.ts @@ -48,9 +48,9 @@ export class FavoriteController { await this.favoriteService.addFavorite(user.id, workspace.id, { type: dto.type, - pageId: dto.pageId, + pageId: dto.type === 'page' ? dto.pageId : undefined, spaceId: dto.type === 'space' ? resolved.spaceId : undefined, - templateId: dto.templateId, + templateId: dto.type === 'template' ? dto.templateId : undefined, }); } diff --git a/apps/server/src/core/favorite/services/favorite.service.ts b/apps/server/src/core/favorite/services/favorite.service.ts index 79902e645..ff10cb80a 100644 --- a/apps/server/src/core/favorite/services/favorite.service.ts +++ b/apps/server/src/core/favorite/services/favorite.service.ts @@ -6,14 +6,12 @@ import { import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { InsertableFavorite } from '@docmost/db/types/entity.types'; import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo'; -import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; @Injectable() export class FavoriteService { constructor( private readonly favoriteRepo: FavoriteRepo, private readonly pagePermissionRepo: PagePermissionRepo, - private readonly spaceMemberRepo: SpaceMemberRepo, ) {} async getFavoriteIds( @@ -43,12 +41,6 @@ export class FavoriteService { result.items = result.items.filter((id) => accessibleSet.has(id)); } - if (type === FavoriteType.SPACE) { - const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId); - const spaceSet = new Set(userSpaceIds); - result.items = result.items.filter((id) => spaceSet.has(id)); - } - return result; } @@ -111,9 +103,6 @@ export class FavoriteService { return result; } - const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId); - const spaceSet = new Set(userSpaceIds); - const pageFavorites = result.items.filter( (f) => f.type === FavoriteType.PAGE && f.pageId, ); @@ -129,19 +118,11 @@ export class FavoriteService { accessiblePageSet = new Set(accessibleIds); } - result.items = result.items.filter((f) => { - if (f.type === FavoriteType.PAGE) { - return f.pageId && accessiblePageSet?.has(f.pageId); - } - if (f.type === FavoriteType.SPACE) { - return f.spaceId && spaceSet.has(f.spaceId); - } - if (f.type === FavoriteType.TEMPLATE) { - const templateSpaceId = (f as any).template?.spaceId; - return !templateSpaceId || spaceSet.has(templateSpaceId); - } - return true; - }); + result.items = result.items.filter( + (f) => + f.type !== FavoriteType.PAGE || + (f.pageId && accessiblePageSet?.has(f.pageId)), + ); return result; } diff --git a/apps/server/src/database/repos/favorite/favorite.repo.ts b/apps/server/src/database/repos/favorite/favorite.repo.ts index 7b7aee302..464c49920 100644 --- a/apps/server/src/database/repos/favorite/favorite.repo.ts +++ b/apps/server/src/database/repos/favorite/favorite.repo.ts @@ -8,6 +8,7 @@ import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { ExpressionBuilder, SelectQueryBuilder, sql } from 'kysely'; import { DB } from '@docmost/db/types/db'; import { dbOrTx } from '@docmost/db/utils'; +import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; export const FavoriteType = { PAGE: 'page', @@ -19,7 +20,10 @@ export type FavoriteType = (typeof FavoriteType)[keyof typeof FavoriteType]; @Injectable() export class FavoriteRepo { - constructor(@InjectKysely() private readonly db: KyselyDB) {} + constructor( + @InjectKysely() private readonly db: KyselyDB, + private readonly spaceMemberRepo: SpaceMemberRepo, + ) {} async insert(favorite: InsertableFavorite): Promise { try { @@ -82,6 +86,8 @@ export class FavoriteRepo { .where('favorites.workspaceId', '=', workspaceId) .where('favorites.type', '=', type); + query = this.applyMembershipFilter(query, userId); + if (spaceId) { query = this.applySpaceFilter(query, type, spaceId); } @@ -113,6 +119,8 @@ export class FavoriteRepo { .where('favorites.userId', '=', userId) .where('favorites.workspaceId', '=', workspaceId); + query = this.applyMembershipFilter(query, userId); + if (type) { query = query.where('favorites.type', '=', type); } @@ -155,7 +163,7 @@ export class FavoriteRepo { ): Promise { if (userIds.length === 0) return; - const { trx } = opts; + const { trx } = opts ?? {}; const db = dbOrTx(this.db, trx); const usersWithAccess = db @@ -174,7 +182,25 @@ export class FavoriteRepo { await db .deleteFrom('favorites') .where('userId', 'in', userIds) - .where('spaceId', '=', spaceId) + .where((eb) => + eb.or([ + eb('spaceId', '=', spaceId), + eb.exists( + eb + .selectFrom('pages') + .select(sql`1`.as('one')) + .whereRef('pages.id', '=', 'favorites.pageId') + .where('pages.spaceId', '=', spaceId), + ), + eb.exists( + eb + .selectFrom('templates') + .select(sql`1`.as('one')) + .whereRef('templates.id', '=', 'favorites.templateId') + .where('templates.spaceId', '=', spaceId), + ), + ]), + ) .where('userId', 'not in', usersWithAccess) .execute(); } @@ -194,6 +220,46 @@ export class FavoriteRepo { .execute(); } + private applyMembershipFilter>( + query: Q, + userId: string, + ): Q { + const spaceIds = this.spaceMemberRepo.getUserSpaceIdsQuery(userId); + return query.where((eb: any) => + eb.or([ + eb.and([ + eb('favorites.type', '=', FavoriteType.SPACE), + eb('favorites.spaceId', 'in', spaceIds), + ]), + eb.and([ + eb('favorites.type', '=', FavoriteType.PAGE), + eb.exists( + eb + .selectFrom('pages') + .select(sql`1`.as('one')) + .whereRef('pages.id', '=', 'favorites.pageId') + .where('pages.spaceId', 'in', spaceIds), + ), + ]), + eb.and([ + eb('favorites.type', '=', FavoriteType.TEMPLATE), + eb.exists( + eb + .selectFrom('templates') + .select(sql`1`.as('one')) + .whereRef('templates.id', '=', 'favorites.templateId') + .where((e: any) => + e.or([ + e('templates.spaceId', 'is', null), + e('templates.spaceId', 'in', spaceIds), + ]), + ), + ), + ]), + ]), + ) as Q; + } + private applySpaceFilter>( query: Q, type: FavoriteType | undefined, @@ -239,7 +305,8 @@ export class FavoriteRepo { 'pages.isBase', 'pages.spaceId', ]) - .whereRef('pages.id', '=', 'favorites.pageId'), + .whereRef('pages.id', '=', 'favorites.pageId') + .where(sql.ref('favorites.type'), '=', FavoriteType.PAGE), ).as('page'); } @@ -269,8 +336,8 @@ export class FavoriteRepo { .select(['spaces.id', 'spaces.name', 'spaces.slug', 'spaces.logo']) .where(({ or, ref }) => or([ - sql`${ref('spaces.id')} = ${ref('favorites.spaceId')}`, - sql`${ref('spaces.id')} = (SELECT pages.space_id FROM pages WHERE pages.id = ${ref('favorites.pageId')})`, + sql`${ref('favorites.type')} = ${FavoriteType.SPACE} and ${ref('spaces.id')} = ${ref('favorites.spaceId')}`, + sql`${ref('favorites.type')} = ${FavoriteType.PAGE} and ${ref('spaces.id')} = (SELECT pages.space_id FROM pages WHERE pages.id = ${ref('favorites.pageId')})`, ]), ), ).as('space'); @@ -287,7 +354,8 @@ export class FavoriteRepo { 'templates.icon', 'templates.spaceId', ]) - .whereRef('templates.id', '=', 'favorites.templateId'), + .whereRef('templates.id', '=', 'favorites.templateId') + .where(sql.ref('favorites.type'), '=', FavoriteType.TEMPLATE), ).as('template'); } } diff --git a/apps/server/src/ee b/apps/server/src/ee index 039bd87f8..5b873a53c 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 039bd87f8a8181ae2d0c2d0dd39f3aa2cfac9029 +Subproject commit 5b873a53c835f81bdd2f2e4ce827288417ffa26b