diff --git a/apps/client/src/features/editor/components/page-embed/page-embed-ancestry-context.tsx b/apps/client/src/features/editor/components/page-embed/page-embed-ancestry-context.tsx new file mode 100644 index 00000000..c989ee21 --- /dev/null +++ b/apps/client/src/features/editor/components/page-embed/page-embed-ancestry-context.tsx @@ -0,0 +1,53 @@ +import React, { createContext, useContext, useMemo } from "react"; + +/** Hard cap on nesting depth for whole-page embeds (cycle/runaway guard). */ +export const PAGE_EMBED_MAX_DEPTH = 5; + +type AncestryValue = { + /** sourcePageIds of every ancestor pageEmbed up the render tree. */ + chain: string[]; + /** Includes the host page id so a top-level self-embed is also caught. */ + hostPageId: string | null; +}; + +const PageEmbedAncestryContext = createContext({ + chain: [], + hostPageId: null, +}); + +/** + * Carries the ancestor `sourcePageId` chain down the nested read-only editors. + * The node view reads it to detect cycles (current id already in the chain) and + * to enforce a hard depth limit before mounting a deeper nested editor. + */ +export function PageEmbedAncestryProvider({ + sourcePageId, + hostPageId, + children, +}: { + sourcePageId?: string | null; + hostPageId?: string | null; + children: React.ReactNode; +}) { + const parent = useContext(PageEmbedAncestryContext); + const value = useMemo(() => { + const nextHost = parent.hostPageId ?? hostPageId ?? null; + if (!sourcePageId) { + return { chain: parent.chain, hostPageId: nextHost }; + } + return { + chain: [...parent.chain, sourcePageId], + hostPageId: nextHost, + }; + }, [parent, sourcePageId, hostPageId]); + + return ( + + {children} + + ); +} + +export function usePageEmbedAncestry() { + return useContext(PageEmbedAncestryContext); +} diff --git a/apps/client/src/features/editor/components/page-embed/page-embed-content.tsx b/apps/client/src/features/editor/components/page-embed/page-embed-content.tsx new file mode 100644 index 00000000..a9c173f6 --- /dev/null +++ b/apps/client/src/features/editor/components/page-embed/page-embed-content.tsx @@ -0,0 +1,49 @@ +import { EditorProvider } from "@tiptap/react"; +import { useMemo } from "react"; +import { mainExtensions } from "@/features/editor/extensions/extensions"; +import { UniqueID } from "@docmost/editor-ext"; + +type Props = { + content: unknown; +}; + +/** + * Read-only nested renderer for embedded whole-page content. Same pattern as + * the transclusion read-only renderer: drop uniqueID/globalDragHandle, never + * write back, and isolate pointer/drag events from the host editor. Nested + * `pageEmbed`/`transclusionReference` nodes inside the content render with + * their own views (the cycle/depth guard lives in the node view itself). + */ +export default function PageEmbedContent({ content }: Props) { + const extensions = useMemo(() => { + const filtered = mainExtensions.filter( + (e: any) => e.name !== "uniqueID" && e.name !== "globalDragHandle", + ); + return [ + ...filtered, + UniqueID.configure({ + types: ["heading", "paragraph", "transclusionSource"], + updateDocument: false, + }), + ]; + }, []); + + const stop = (e: React.SyntheticEvent) => e.stopPropagation(); + + return ( +
+ +
+ ); +} diff --git a/apps/client/src/features/editor/components/page-embed/page-embed-lookup-context.tsx b/apps/client/src/features/editor/components/page-embed/page-embed-lookup-context.tsx new file mode 100644 index 00000000..aa2a8caf --- /dev/null +++ b/apps/client/src/features/editor/components/page-embed/page-embed-lookup-context.tsx @@ -0,0 +1,162 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { lookupTemplate } from "@/features/page-embed/services/page-embed-api"; +import type { PageTemplateLookup } from "@/features/page-embed/types/page-embed.types"; + +type ContextValue = { + subscribe: (s: { + sourcePageId: string; + setResult: (r: PageTemplateLookup) => void; + }) => () => void; + refresh: (sourcePageId: string) => Promise; +}; + +const PageEmbedLookupContext = createContext(null); + +/** + * Batching/de-dup lookup context for whole-page embeds (pageEmbed). Mirrors the + * transclusion lookup context but keys purely on `sourcePageId`. On public + * shares there is no lookup in MVP, so the context simply isn't mounted (the + * node view renders a placeholder when the context is absent). + */ +export function PageEmbedLookupProvider({ + children, +}: { + children: React.ReactNode; +}) { + const subscribersRef = useRef(new Map void>>()); + const queueRef = useRef(new Set()); + const tickRef = useRef | null>(null); + const resultCacheRef = useRef(new Map()); + const inFlightRef = useRef(new Set()); + const pendingRef = useRef(new Map void>>()); + + const flush = useCallback(async () => { + tickRef.current = null; + const ids = Array.from(queueRef.current); + queueRef.current.clear(); + if (ids.length === 0) return; + + for (const id of ids) inFlightRef.current.add(id); + + const resolveWaiters = (id: string) => { + const waiters = pendingRef.current.get(id); + if (!waiters) return; + pendingRef.current.delete(id); + for (const w of waiters) w(); + }; + + try { + const { items } = await lookupTemplate({ sourcePageIds: ids }); + for (const r of items) { + resultCacheRef.current.set(r.sourcePageId, r); + inFlightRef.current.delete(r.sourcePageId); + const subs = subscribersRef.current.get(r.sourcePageId); + if (subs) { + for (const set of subs) set(r); + } + resolveWaiters(r.sourcePageId); + } + } catch (err) { + // Surface the failure: errors must never be swallowed silently. + console.error("[pageEmbed] template lookup failed", err); + for (const id of ids) { + inFlightRef.current.delete(id); + resolveWaiters(id); + } + } + }, []); + + const enqueue = useCallback( + (id: string) => { + queueRef.current.add(id); + if (tickRef.current === null) { + tickRef.current = setTimeout(flush, 10); + } + }, + [flush], + ); + + const subscribe = useCallback( + ({ sourcePageId, setResult }) => { + const list = subscribersRef.current.get(sourcePageId) ?? []; + list.push(setResult); + subscribersRef.current.set(sourcePageId, list); + + const cached = resultCacheRef.current.get(sourcePageId); + if (cached) { + setResult(cached); + } else if (!inFlightRef.current.has(sourcePageId)) { + enqueue(sourcePageId); + } + + return () => { + const cur = subscribersRef.current.get(sourcePageId) ?? []; + const next = cur.filter((x) => x !== setResult); + if (next.length === 0) subscribersRef.current.delete(sourcePageId); + else subscribersRef.current.set(sourcePageId, next); + }; + }, + [enqueue], + ); + + const refresh = useCallback( + (sourcePageId) => + new Promise((resolve) => { + resultCacheRef.current.delete(sourcePageId); + inFlightRef.current.delete(sourcePageId); + const waiters = pendingRef.current.get(sourcePageId) ?? []; + waiters.push(resolve); + pendingRef.current.set(sourcePageId, waiters); + enqueue(sourcePageId); + }), + [enqueue], + ); + + useEffect( + () => () => { + if (tickRef.current) clearTimeout(tickRef.current); + }, + [], + ); + + const value = useMemo( + () => ({ subscribe, refresh }), + [subscribe, refresh], + ); + + return ( + + {children} + + ); +} + +export function usePageEmbedLookup(sourcePageId: string | null | undefined): { + result: PageTemplateLookup | null; + refresh: () => Promise; + available: boolean; +} { + const ctx = useContext(PageEmbedLookupContext); + const [result, setResult] = useState(null); + + useEffect(() => { + if (!ctx || !sourcePageId) return; + const unsubscribe = ctx.subscribe({ sourcePageId, setResult }); + return unsubscribe; + }, [ctx, sourcePageId]); + + const refresh = useCallback(async () => { + if (!ctx || !sourcePageId) return; + await ctx.refresh(sourcePageId); + }, [ctx, sourcePageId]); + + return { result, refresh, available: Boolean(ctx) }; +} diff --git a/apps/client/src/features/editor/components/page-embed/page-embed-picker.tsx b/apps/client/src/features/editor/components/page-embed/page-embed-picker.tsx new file mode 100644 index 00000000..7648b05e --- /dev/null +++ b/apps/client/src/features/editor/components/page-embed/page-embed-picker.tsx @@ -0,0 +1,117 @@ +import { useEffect, useRef, useState } from "react"; +import { Modal, ScrollArea, TextInput, Text, UnstyledButton, Group } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { useQuery } from "@tanstack/react-query"; +import { IconFileText, IconSearch } from "@tabler/icons-react"; +import type { Editor, Range } from "@tiptap/core"; +import { searchSuggestions } from "@/features/search/services/search-service"; +import type { IPage } from "@/features/page/types/page.types"; + +export const PAGE_EMBED_PICKER_EVENT = "open-page-embed-picker"; + +type PickerDetail = { + editor: Editor; + range: Range; + /** Host page id, used to forbid self-embed in the picker. */ + hostPageId?: string; +}; + +/** + * Modal page picker for inserting a `pageEmbed`. Queries search-suggestions + * with `onlyTemplates` so only template-flagged pages are offered. Forbids + * selecting the current (host) page (self-embed guard at insertion time). + * Mounted once per editor; opened via a CustomEvent dispatched by the slash + * command item. + */ +export default function PageEmbedPicker() { + const { t } = useTranslation(); + const [opened, setOpened] = useState(false); + const [query, setQuery] = useState(""); + const detailRef = useRef(null); + + useEffect(() => { + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (!detail?.editor) return; + detailRef.current = detail; + setQuery(""); + setOpened(true); + }; + document.addEventListener(PAGE_EMBED_PICKER_EVENT, handler); + return () => document.removeEventListener(PAGE_EMBED_PICKER_EVENT, handler); + }, []); + + const { data, isFetching } = useQuery({ + queryKey: ["page-embed-template-picker", query], + queryFn: () => + searchSuggestions({ + query, + includePages: true, + onlyTemplates: true, + limit: 20, + }), + enabled: opened, + staleTime: 30 * 1000, + }); + + const hostPageId = detailRef.current?.hostPageId; + const pages = ((data?.pages ?? []) as IPage[]).filter( + (p) => p && p.id !== hostPageId, + ); + + const handleSelect = (page: IPage) => { + const detail = detailRef.current; + if (!detail) return; + const { editor, range } = detail; + editor + .chain() + .focus() + .deleteRange(range) + .insertPageEmbed({ sourcePageId: page.id }) + .run(); + setOpened(false); + }; + + return ( + setOpened(false)} + title={t("Embed page")} + size="md" + > + } + value={query} + onChange={(e) => setQuery(e.currentTarget.value)} + autoFocus + mb="sm" + /> + + {pages.length === 0 && !isFetching && ( + + {t("No templates found")} + + )} + {pages.map((page) => ( + handleSelect(page)} + style={{ display: "block", width: "100%", padding: "8px 4px" }} + > + + {page.icon ? ( + {page.icon} + ) : ( + + )} + + {page.title || t("Untitled")} + + + + ))} + + + ); +} diff --git a/apps/client/src/features/editor/components/page-embed/page-embed-view.tsx b/apps/client/src/features/editor/components/page-embed/page-embed-view.tsx new file mode 100644 index 00000000..63890eec --- /dev/null +++ b/apps/client/src/features/editor/components/page-embed/page-embed-view.tsx @@ -0,0 +1,253 @@ +import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; +import { ActionIcon, Menu, Tooltip } from "@mantine/core"; +import { + IconAlertTriangle, + IconArrowsMaximize, + IconDots, + IconExternalLink, + IconEyeOff, + IconInfoCircle, + IconRefresh, + IconRepeat, + IconTrash, +} from "@tabler/icons-react"; +import { useState } from "react"; +import { Link } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { ErrorBoundary } from "react-error-boundary"; +import { buildPageUrl } from "@/features/page/page.utils.ts"; +import classes from "../transclusion/transclusion.module.css"; +import { usePageEmbedLookup } from "./page-embed-lookup-context"; +import { + PageEmbedAncestryProvider, + usePageEmbedAncestry, + PAGE_EMBED_MAX_DEPTH, +} from "./page-embed-ancestry-context"; +import PageEmbedContent from "./page-embed-content"; + +function Placeholder({ + icon, + label, +}: { + icon: React.ReactNode; + label: string; +}) { + return ( +
+ {icon} + {label} +
+ ); +} + +export default function PageEmbedView(props: NodeViewProps) { + const isEditable = props.editor.isEditable; + const sourcePageId: string | null = props.node.attrs.sourcePageId ?? null; + const [openMenus, setOpenMenus] = useState(0); + const trackOpen = (open: boolean) => + setOpenMenus((n) => Math.max(0, n + (open ? 1 : -1))); + + return ( + 0 ? "true" : "false"} + contentEditable={false} + > + + // Never swallow: log the full error with the offending source id. + console.error("[pageEmbed] render error", { sourcePageId, err }) + } + fallback={ + } + label="Failed to load this embedded page" + /> + } + > + + + + ); +} + +function PageEmbedBody({ + editor, + node, + deleteNode, + trackOpen, +}: NodeViewProps & { trackOpen: (open: boolean) => void }) { + const { t } = useTranslation(); + const sourcePageId: string | null = node.attrs.sourcePageId ?? null; + const isEditable = editor.isEditable; + const ancestry = usePageEmbedAncestry(); + + // @ts-ignore - editor.storage.pageId is set by the host editor + const hostPageId: string | undefined = editor.storage?.pageId; + + const { result, refresh, available } = usePageEmbedLookup(sourcePageId); + const [refreshing, setRefreshing] = useState(false); + const handleRefresh = async () => { + setRefreshing(true); + try { + await refresh(); + } finally { + setRefreshing(false); + } + }; + + // --- Cycle / depth guard (evaluated before any lookup is rendered) --------- + // Self-embed or a source already present in the ancestor chain → cycle. + const isCycle = + !!sourcePageId && + (ancestry.chain.includes(sourcePageId) || + ancestry.hostPageId === sourcePageId); + const isTooDeep = ancestry.chain.length >= PAGE_EMBED_MAX_DEPTH; + + const sourceTitle = + result && !("status" in result) ? result.title : null; + const sourceIcon = result && !("status" in result) ? result.icon : null; + // The app routes pages by slugId, not the raw UUID. Build the link from the + // resolved slugId (the `/p/:pageSlug` route redirects to the full URL). + const sourceSlugId = + result && !("status" in result) ? result.slugId : null; + const sourceHref = sourceSlugId + ? buildPageUrl(undefined, sourceSlugId, sourceTitle ?? undefined) + : null; + + const controls = isEditable ? ( +
e.preventDefault()} + > + + + + + + {sourceHref && ( + + + + + + )} + + + + + + + + } + onClick={() => deleteNode()} + > + {t("Remove from page")} + + + +
+ ) : null; + + const header = + sourceTitle || sourceIcon ? ( +
+ {sourceIcon ? `${sourceIcon} ` : } + {sourceHref ? ( + + {sourceTitle || t("Untitled")} + + ) : ( + sourceTitle || t("Untitled") + )} +
+ ) : null; + + let body: React.ReactNode; + if (!sourcePageId) { + body = ( + } + label={t("No page selected")} + /> + ); + } else if (isCycle) { + body = ( + } + label={t("Circular embed: this page is already shown above")} + /> + ); + } else if (isTooDeep) { + body = ( + } + label={t("Embed nesting limit reached")} + /> + ); + } else if (!available) { + // No lookup context (e.g. public share) → placeholder, no fetch in MVP. + body = ( + } + label={t("Embedded page is not available here")} + /> + ); + } else if (!result) { + body =
; + } else if (!("status" in result)) { + body = ( + + + + ); + } else if (result.status === "no_access") { + body = ( + } + label={t("You don't have access to this page")} + /> + ); + } else { + body = ( + } + label={t("The embedded page no longer exists")} + /> + ); + } + + return ( + <> + {controls} + {header} + {body} + + ); +} diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.ts index 7f856755..7400fa14 100644 --- a/apps/client/src/features/editor/components/slash-menu/menu-items.ts +++ b/apps/client/src/features/editor/components/slash-menu/menu-items.ts @@ -28,7 +28,9 @@ import { IconTag, IconMoodSmile, IconRotate2, + IconArrowsMaximize, } from "@tabler/icons-react"; +import { PAGE_EMBED_PICKER_EVENT } from "@/features/editor/components/page-embed/page-embed-picker"; import { CommandProps, SlashMenuGroupedItemsType, @@ -535,6 +537,29 @@ const CommandGroups: SlashMenuGroupedItemsType = { .run(); }, }, + { + title: "Embed page", + description: "Insert a live, read-only copy of another page.", + searchTerms: [ + "template", + "embed", + "embed page", + "page", + "live", + "include", + "reuse", + ], + icon: IconArrowsMaximize, + command: ({ editor, range }: CommandProps) => { + // @ts-ignore - editor.storage.pageId is set by the host editor + const hostPageId: string | undefined = editor.storage?.pageId; + document.dispatchEvent( + new CustomEvent(PAGE_EMBED_PICKER_EVENT, { + detail: { editor, range, hostPageId }, + }), + ); + }, + }, { title: "2 Columns", description: "Split content into two columns.", diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index 87c7b9e5..c411c568 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -60,6 +60,7 @@ import { Status, TransclusionSource, TransclusionReference, + PageEmbed, TableView, } from "@docmost/editor-ext"; import { @@ -91,6 +92,7 @@ import PdfView from "@/features/editor/components/pdf/pdf-view.tsx"; import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx"; import TransclusionView from "@/features/editor/components/transclusion/transclusion-view.tsx"; import TransclusionReferenceView from "@/features/editor/components/transclusion/transclusion-reference-view.tsx"; +import PageEmbedView from "@/features/editor/components/page-embed/page-embed-view.tsx"; import { common, createLowlight } from "lowlight"; import plaintext from "highlight.js/lib/languages/plaintext"; import powershell from "highlight.js/lib/languages/powershell"; @@ -230,7 +232,7 @@ export const mainExtensions = [ Typography, TrailingNode, GlobalDragHandle.configure({ - customNodes: ["transclusionSource", "transclusionReference"], + customNodes: ["transclusionSource", "transclusionReference", "pageEmbed"], }), TextStyle, Color, @@ -381,6 +383,9 @@ export const mainExtensions = [ TransclusionReference.configure({ view: TransclusionReferenceView, }), + PageEmbed.configure({ + view: PageEmbedView, + }), MarkdownClipboard.configure({ transformPastedText: true, }), @@ -420,7 +425,8 @@ const TEMPLATE_EXCLUDED_SLASH_ITEMS = new Set([ "Draw.io (diagrams.net)", "Excalidraw (Whiteboard)", "Audio", - "Synced block" + "Synced block", + "Embed page" ]); const TemplateSlashCommand = Command.configure({ diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index 2851d22a..41b6538e 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -73,6 +73,9 @@ import { useEditorScroll } from "./hooks/use-editor-scroll"; import { EditorLinkMenu } from "@/features/editor/components/link/link-menu"; import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx"; import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context"; +import { PageEmbedLookupProvider } from "@/features/editor/components/page-embed/page-embed-lookup-context"; +import { PageEmbedAncestryProvider } from "@/features/editor/components/page-embed/page-embed-ancestry-context"; +import PageEmbedPicker from "@/features/editor/components/page-embed/page-embed-picker"; import { useTranslation } from "react-i18next"; interface PageEditorProps { @@ -407,6 +410,8 @@ export default function PageEditor({ return ( + + {showStatic ? ( )} + {editor && editorIsEditable && }
editor.commands.focus("end")} @@ -461,6 +467,8 @@ export default function PageEditor({ >
)} + + ); } diff --git a/apps/client/src/features/page-embed/queries/page-embed-query.ts b/apps/client/src/features/page-embed/queries/page-embed-query.ts new file mode 100644 index 00000000..cbd9f937 --- /dev/null +++ b/apps/client/src/features/page-embed/queries/page-embed-query.ts @@ -0,0 +1,20 @@ +import { useMutation } from "@tanstack/react-query"; +import { notifications } from "@mantine/notifications"; +import { toggleTemplate } from "@/features/page-embed/services/page-embed-api"; +import type { ToggleTemplateResponse } from "@/features/page-embed/types/page-embed.types"; + +export function useToggleTemplateMutation() { + return useMutation< + ToggleTemplateResponse, + Error, + { pageId: string; isTemplate?: boolean } + >({ + mutationFn: (data) => toggleTemplate(data), + onError: (err: any) => { + notifications.show({ + message: err?.response?.data?.message || "Failed to update template", + color: "red", + }); + }, + }); +} diff --git a/apps/client/src/features/page-embed/services/page-embed-api.ts b/apps/client/src/features/page-embed/services/page-embed-api.ts new file mode 100644 index 00000000..be203c2c --- /dev/null +++ b/apps/client/src/features/page-embed/services/page-embed-api.ts @@ -0,0 +1,20 @@ +import api from "@/lib/api-client"; +import type { + PageTemplateLookup, + ToggleTemplateResponse, +} from "../types/page-embed.types"; + +export async function lookupTemplate(params: { + sourcePageIds: string[]; +}): Promise<{ items: PageTemplateLookup[] }> { + const r = await api.post("/pages/template/lookup", params); + return r.data; +} + +export async function toggleTemplate(params: { + pageId: string; + isTemplate?: boolean; +}): Promise { + const r = await api.post("/pages/toggle-template", params); + return r.data; +} diff --git a/apps/client/src/features/page-embed/types/page-embed.types.ts b/apps/client/src/features/page-embed/types/page-embed.types.ts new file mode 100644 index 00000000..63ed1a38 --- /dev/null +++ b/apps/client/src/features/page-embed/types/page-embed.types.ts @@ -0,0 +1,16 @@ +export type PageTemplateLookup = + | { + sourcePageId: string; + slugId: string; + title: string | null; + icon: string | null; + content: unknown; + sourceUpdatedAt: string; + } + | { sourcePageId: string; status: "not_found" } + | { sourcePageId: string; status: "no_access" }; + +export type ToggleTemplateResponse = { + pageId: string; + isTemplate: boolean; +}; diff --git a/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx b/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx index 6a33445d..d21305a3 100644 --- a/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx +++ b/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx @@ -12,6 +12,7 @@ import { IconLink, IconStar, IconStarFilled, + IconTemplate, IconTrash, } from "@tabler/icons-react"; @@ -30,6 +31,7 @@ import { useRemoveFavoriteMutation, } from "@/features/favorite/queries/favorite-query"; +import { useToggleTemplateMutation } from "@/features/page-embed/queries/page-embed-query"; import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts"; import { treeModel } from "@/features/page/tree/model/tree-model"; import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts"; @@ -63,6 +65,26 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) { const addFavorite = useAddFavoriteMutation(); const removeFavorite = useRemoveFavoriteMutation(); const isFavorited = favoriteIds.has(node.id); + const toggleTemplate = useToggleTemplateMutation(); + const isTemplate = !!node.isTemplate; + + const handleToggleTemplate = async () => { + const next = !isTemplate; + try { + await toggleTemplate.mutateAsync({ pageId: node.id, isTemplate: next }); + // Reflect the new flag locally so the menu label updates immediately. + setData((prev) => + treeModel.update(prev, node.id, { isTemplate: next } as any), + ); + notifications.show({ + message: next + ? t("Page marked as template") + : t("Page is no longer a template"), + }); + } catch { + // mutation surfaces the error via notifications + } + }; const handleCopyLink = () => { const pageUrl = @@ -217,6 +239,17 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) { {t("Copy to space")} + } + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleToggleTemplate(); + }} + > + {isTemplate ? t("Unset as template") : t("Make template")} + + this.pageRepo.withHasChildren(eb)) @@ -665,6 +666,18 @@ export class PageService { } } + // Remap whole-page embeds (pageEmbed) the same way: if the embedded + // source page is also part of the copied set, point at its new copy; + // otherwise leave it pointing at the original (live embed of original). + if (node.type.name === 'pageEmbed') { + const sourcePageId = node.attrs.sourcePageId; + if (sourcePageId && pageMap.has(sourcePageId)) { + const mappedPage = pageMap.get(sourcePageId); + //@ts-ignore + node.attrs.sourcePageId = mappedPage.newPageId; + } + } + // Update internal page links in link marks for (const mark of node.marks) { if ( @@ -757,6 +770,21 @@ export class PageService { ); } + try { + await this.transclusionService.insertTemplateReferencesForPages( + insertablePages.map((p) => ({ + id: p.id, + workspaceId: p.workspaceId, + content: p.content, + })), + ); + } catch (err) { + this.logger.error( + 'Failed to insert page template references for duplicated pages', + err, + ); + } + const insertedPageIds = insertablePages.map((page) => page.id); // `spaceId` is the single destination space for the whole copy/duplicate // (every inserted page above gets `spaceId: spaceId`). It lets the WS diff --git a/apps/server/src/core/page/transclusion/dto/template-lookup.dto.ts b/apps/server/src/core/page/transclusion/dto/template-lookup.dto.ts new file mode 100644 index 00000000..8267d2aa --- /dev/null +++ b/apps/server/src/core/page/transclusion/dto/template-lookup.dto.ts @@ -0,0 +1,12 @@ +import { + ArrayMaxSize, + IsArray, + IsUUID, +} from 'class-validator'; + +export class TemplateLookupDto { + @IsArray() + @ArrayMaxSize(50) + @IsUUID('all', { each: true }) + sourcePageIds!: string[]; +} diff --git a/apps/server/src/core/page/transclusion/dto/toggle-template.dto.ts b/apps/server/src/core/page/transclusion/dto/toggle-template.dto.ts new file mode 100644 index 00000000..8bdc6b50 --- /dev/null +++ b/apps/server/src/core/page/transclusion/dto/toggle-template.dto.ts @@ -0,0 +1,11 @@ +import { IsBoolean, IsOptional, IsUUID } from 'class-validator'; + +export class ToggleTemplateDto { + @IsUUID() + pageId!: string; + + /** When omitted, the flag is toggled relative to its current value. */ + @IsOptional() + @IsBoolean() + isTemplate?: boolean; +} diff --git a/apps/server/src/core/page/transclusion/page-template.controller.ts b/apps/server/src/core/page/transclusion/page-template.controller.ts new file mode 100644 index 00000000..555a487f --- /dev/null +++ b/apps/server/src/core/page/transclusion/page-template.controller.ts @@ -0,0 +1,79 @@ +import { + Body, + Controller, + HttpCode, + HttpStatus, + NotFoundException, + Post, + UseGuards, +} from '@nestjs/common'; +import { Throttle } from '@nestjs/throttler'; +import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard'; +import { AuthUser } from '../../../common/decorators/auth-user.decorator'; +import { User } from '@docmost/db/types/entity.types'; +import { TransclusionService } from './transclusion.service'; +import { TemplateLookupDto } from './dto/template-lookup.dto'; +import { PageRepo } from '@docmost/db/repos/page/page.repo'; +import { PageAccessService } from '../page-access/page-access.service'; +import { ToggleTemplateDto } from './dto/toggle-template.dto'; +import { UserThrottlerGuard } from '../../../integrations/throttle/user-throttler.guard'; +import { PAGE_TEMPLATE_THROTTLER } from '../../../integrations/throttle/throttler-names'; + +@UseGuards(JwtAuthGuard) +@Controller('pages') +export class PageTemplateController { + constructor( + private readonly transclusionService: TransclusionService, + private readonly pageRepo: PageRepo, + private readonly pageAccessService: PageAccessService, + ) {} + + /** + * Whole-page live embed lookup for authenticated viewers. Returns current + * content (comment marks stripped) for accessible source pages. + * + * DoS note: the embed cycle/depth cap (PAGE_EMBED_MAX_DEPTH=5) is enforced + * CLIENT-side only — a scripted client could otherwise drive heavy full-doc + * fan-out. The server bounds the cost with this per-user throttle plus the + * DTO's ArrayMaxSize(50) cap; server-side recursive expansion is out of scope. + */ + @UseGuards(JwtAuthGuard, UserThrottlerGuard) + @Throttle({ [PAGE_TEMPLATE_THROTTLER]: { limit: 30, ttl: 60000 } }) + @HttpCode(HttpStatus.OK) + @Post('template/lookup') + async lookup(@Body() dto: TemplateLookupDto, @AuthUser() user: User) { + return this.transclusionService.lookupTemplate( + dto.sourcePageIds, + user.id, + user.workspaceId, + ); + } + + /** + * Flip `pages.is_template`. Requires Edit on the page/space (CASL is enforced + * inside `validateCanEdit`). The flag only affects template picker discovery; + * it does not restrict editing or embedding. + */ + @UseGuards(JwtAuthGuard, UserThrottlerGuard) + @Throttle({ [PAGE_TEMPLATE_THROTTLER]: { limit: 30, ttl: 60000 } }) + @HttpCode(HttpStatus.OK) + @Post('toggle-template') + async toggleTemplate( + @Body() dto: ToggleTemplateDto, + @AuthUser() user: User, + ) { + const page = await this.pageRepo.findById(dto.pageId); + if (!page || page.deletedAt) { + throw new NotFoundException('Page not found'); + } + + await this.pageAccessService.validateCanEdit(page, user); + + const isTemplate = + typeof dto.isTemplate === 'boolean' ? dto.isTemplate : !page.isTemplate; + + await this.pageRepo.updatePage({ isTemplate }, page.id); + + return { pageId: page.id, isTemplate }; + } +} diff --git a/apps/server/src/core/page/transclusion/spec/page-embed.util.spec.ts b/apps/server/src/core/page/transclusion/spec/page-embed.util.spec.ts new file mode 100644 index 00000000..2bdca7b7 --- /dev/null +++ b/apps/server/src/core/page/transclusion/spec/page-embed.util.spec.ts @@ -0,0 +1,102 @@ +import { collectPageEmbedsFromPmJson } from '../utils/transclusion-prosemirror.util'; +import { + htmlToJson, + jsonToHtml, +} from '../../../../collaboration/collaboration.util'; + +describe('collectPageEmbedsFromPmJson', () => { + it('returns [] for null/undefined doc', () => { + expect(collectPageEmbedsFromPmJson(null)).toEqual([]); + expect(collectPageEmbedsFromPmJson(undefined)).toEqual([]); + }); + + it('returns [] for a doc with no pageEmbed nodes', () => { + const doc = { + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hi' }] }], + }; + expect(collectPageEmbedsFromPmJson(doc)).toEqual([]); + }); + + it('extracts a top-level pageEmbed', () => { + const doc = { + type: 'doc', + content: [{ type: 'pageEmbed', attrs: { sourcePageId: 'p1' } }], + }; + expect(collectPageEmbedsFromPmJson(doc)).toEqual([{ sourcePageId: 'p1' }]); + }); + + it('skips pageEmbed nodes missing sourcePageId', () => { + const doc = { + type: 'doc', + content: [ + { type: 'pageEmbed', attrs: {} }, + { type: 'pageEmbed', attrs: { sourcePageId: '' } }, + ], + }; + expect(collectPageEmbedsFromPmJson(doc)).toEqual([]); + }); + + it('dedupes identical sourcePageIds, first-seen order preserved', () => { + const doc = { + type: 'doc', + content: [ + { type: 'pageEmbed', attrs: { sourcePageId: 'p1' } }, + { type: 'pageEmbed', attrs: { sourcePageId: 'p2' } }, + { type: 'pageEmbed', attrs: { sourcePageId: 'p1' } }, + ], + }; + expect(collectPageEmbedsFromPmJson(doc)).toEqual([ + { sourcePageId: 'p1' }, + { sourcePageId: 'p2' }, + ]); + }); + + it('finds pageEmbed nested in other block containers (column)', () => { + const doc = { + type: 'doc', + content: [ + { + type: 'column', + content: [{ type: 'pageEmbed', attrs: { sourcePageId: 'nested' } }], + }, + ], + }; + expect(collectPageEmbedsFromPmJson(doc)).toEqual([ + { sourcePageId: 'nested' }, + ]); + }); + + it('does not descend into a transclusion source', () => { + const doc = { + type: 'doc', + content: [ + { + type: 'transclusionSource', + attrs: { id: 'src' }, + content: [{ type: 'pageEmbed', attrs: { sourcePageId: 'hidden' } }], + }, + ], + }; + expect(collectPageEmbedsFromPmJson(doc)).toEqual([]); + }); +}); + +describe('pageEmbed HTML <-> JSON round-trip (server schema)', () => { + it('preserves sourcePageId across jsonToHtml -> htmlToJson', () => { + const doc = { + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'before' }] }, + { type: 'pageEmbed', attrs: { sourcePageId: 'abc-123' } }, + ], + }; + + const html = jsonToHtml(doc); + expect(html).toContain('data-source-page-id="abc-123"'); + + const back = htmlToJson(html); + const embeds = collectPageEmbedsFromPmJson(back); + expect(embeds).toEqual([{ sourcePageId: 'abc-123' }]); + }); +}); diff --git a/apps/server/src/core/page/transclusion/spec/page-template-access.spec.ts b/apps/server/src/core/page/transclusion/spec/page-template-access.spec.ts new file mode 100644 index 00000000..3c497d80 --- /dev/null +++ b/apps/server/src/core/page/transclusion/spec/page-template-access.spec.ts @@ -0,0 +1,267 @@ +import { TransclusionService } from '../transclusion.service'; + +/** + * Exercises the REAL security core of the whole-page template feature rather + * than mocking it away: + * - `filterViewerAccessiblePageIds` runs for real (space-visibility query + + * page-permission filter are stubbed, but the branching/AND-ing is real), so + * `lookupTemplate` actually maps no_access vs content based on it. + * - the workspace scoping of `page_template_references` writes is verified to + * drop cross-workspace source ids before they are persisted. + */ +describe('TransclusionService — template access core (real filter)', () => { + /** + * Build a chainable kysely `db` stub. `selectFrom(...).select(...).where(...)` + * all return the same builder; `.execute()` resolves the supplied rows. The + * `where('spaceId','in', getUserSpaceIdsQuery(...))` sub-query argument is + * ignored — space visibility is decided by what `execute()` returns. + */ + function makeDb(executeRows: Array<{ id: string }>) { + const builder: any = {}; + builder.selectFrom = jest.fn(() => builder); + builder.select = jest.fn(() => builder); + builder.where = jest.fn(() => builder); + builder.execute = jest.fn(async () => executeRows); + return builder; + } + + function makeService(opts: { + /** rows returned by the space-visibility query (workspace + space scoped) */ + spaceVisibleRows: Array<{ id: string }>; + /** ids that survive page-level permission filtering */ + permissionAccessibleIds: string[]; + pages?: Array<{ + id: string; + slugId?: string; + title: string | null; + icon: string | null; + content: unknown; + updatedAt: Date; + }>; + }) { + const db = makeDb(opts.spaceVisibleRows); + + const spaceMemberRepo = { + // The real code only passes this query object into `.where(...)`; our db + // stub ignores it, so a sentinel is fine. + getUserSpaceIdsQuery: jest.fn(() => ({ __subquery: true })), + }; + + const pagePermissionRepo = { + filterAccessiblePageIds: jest + .fn() + .mockResolvedValue(opts.permissionAccessibleIds), + }; + + const pageRepo = { + findManyByIds: jest.fn().mockResolvedValue(opts.pages ?? []), + }; + + const service = new TransclusionService( + db as any, + {} as any, // pageTransclusionsRepo + {} as any, // pageTransclusionReferencesRepo + {} as any, // pageTemplateReferencesRepo + pageRepo as any, + pagePermissionRepo as any, + spaceMemberRepo as any, + {} as any, // attachmentRepo + {} as any, // storageService + {} as any, // pageAccessService + ); + + return { service, db, pageRepo, spaceMemberRepo, pagePermissionRepo }; + } + + const now = new Date('2026-06-20T00:00:00.000Z'); + + it('returns no_access when the viewer fails the page-permission filter (real filter runs)', async () => { + // Space-visible, but page-permission filter rejects it. + const { service, pagePermissionRepo } = makeService({ + spaceVisibleRows: [{ id: 'p1' }], + permissionAccessibleIds: [], + }); + + const { items } = await service.lookupTemplate(['p1'], 'u1', 'w1'); + expect(items).toEqual([{ sourcePageId: 'p1', status: 'no_access' }]); + // proves the real filter executed and consulted page permissions + expect(pagePermissionRepo.filterAccessiblePageIds).toHaveBeenCalledWith({ + pageIds: ['p1'], + userId: 'u1', + }); + }); + + it('returns no_access for a cross-workspace id (space-visibility query excludes it)', async () => { + // The workspace/space-scoped query returns nothing → permission filter is + // never reached and the id is not returned as accessible. + const { service, pagePermissionRepo } = makeService({ + spaceVisibleRows: [], + permissionAccessibleIds: ['cross-ws'], + }); + + const { items } = await service.lookupTemplate(['cross-ws'], 'u1', 'w1'); + expect(items).toEqual([{ sourcePageId: 'cross-ws', status: 'no_access' }]); + // short-circuited before page-permission filtering + expect(pagePermissionRepo.filterAccessiblePageIds).not.toHaveBeenCalled(); + }); + + it('returns content with comment marks stripped for an accessible page', async () => { + const content = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'hello', + marks: [{ type: 'comment', attrs: { commentId: 'c1' } }], + }, + ], + }, + ], + }; + + const { service } = makeService({ + spaceVisibleRows: [{ id: 'p1' }], + permissionAccessibleIds: ['p1'], + pages: [ + { + id: 'p1', + slugId: 's1', + title: 'Tmpl', + icon: '📄', + content, + updatedAt: now, + }, + ], + }); + + const { items } = await service.lookupTemplate(['p1'], 'u1', 'w1'); + const item = items[0] as any; + expect(item.status).toBeUndefined(); + expect(item.title).toBe('Tmpl'); + const json = JSON.stringify(item.content); + expect(json).not.toContain('comment'); + expect(json).toContain('hello'); + }); + + it('mixes accessible and inaccessible ids in one batch positionally', async () => { + const { service } = makeService({ + spaceVisibleRows: [{ id: 'ok' }, { id: 'denied' }], + permissionAccessibleIds: ['ok'], + pages: [ + { + id: 'ok', + slugId: 's', + title: 'A', + icon: null, + content: { type: 'doc', content: [] }, + updatedAt: now, + }, + ], + }); + + const { items } = await service.lookupTemplate( + ['denied', 'ok', 'cross'], + 'u1', + 'w1', + ); + expect((items[0] as any).status).toBe('no_access'); // space-visible but no perm + expect((items[1] as any).status).toBeUndefined(); // accessible + expect((items[2] as any).status).toBe('no_access'); // not space-visible + }); + + it('honours the DTO-level ≤50 cap by deduping ids passed to the filter', async () => { + // The DTO enforces ArrayMaxSize(50); the service dedupes before filtering. + const ids = ['a', 'a', 'b']; + const { service, db } = makeService({ + spaceVisibleRows: [], + permissionAccessibleIds: [], + }); + + await service.lookupTemplate(ids, 'u1', 'w1'); + // db.where('id','in', ) — verify the in-clause got deduped ids + const inCall = db.where.mock.calls.find((c: any[]) => c[0] === 'id'); + expect(inCall?.[2]).toEqual(['a', 'b']); + }); +}); + +describe('TransclusionService.syncPageTemplateReferences — workspace scoping', () => { + function makeService(opts: { inWorkspaceIds: string[] }) { + // db stub: the in-workspace existence query returns only allowed ids. + const builder: any = {}; + builder.selectFrom = jest.fn(() => builder); + builder.select = jest.fn(() => builder); + builder.where = jest.fn(() => builder); + builder.execute = jest.fn(async () => + opts.inWorkspaceIds.map((id) => ({ id })), + ); + + const insertMany = jest.fn().mockResolvedValue(undefined); + const deleteByReferenceAndSources = jest.fn().mockResolvedValue(undefined); + const pageTemplateReferencesRepo = { + findByReferencePageId: jest.fn().mockResolvedValue([]), + insertMany, + deleteByReferenceAndSources, + }; + + const service = new TransclusionService( + builder as any, + {} as any, + {} as any, + pageTemplateReferencesRepo as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + ); + + return { service, insertMany, pageTemplateReferencesRepo }; + } + + function docWithEmbeds(sourceIds: string[]) { + return { + type: 'doc', + content: sourceIds.map((id) => ({ + type: 'pageEmbed', + attrs: { sourcePageId: id }, + })), + }; + } + + it('does NOT write a row for a cross-workspace sourcePageId, but writes the in-workspace one', async () => { + const { service, insertMany } = makeService({ + // only the in-workspace id survives the existence query + inWorkspaceIds: ['in-ws'], + }); + + const result = await service.syncPageTemplateReferences( + 'host', + 'w1', + docWithEmbeds(['in-ws', 'cross-ws']), + ); + + expect(result.inserted).toBe(1); + expect(insertMany).toHaveBeenCalledTimes(1); + const rows = insertMany.mock.calls[0][0]; + expect(rows).toEqual([ + { workspaceId: 'w1', referencePageId: 'host', sourcePageId: 'in-ws' }, + ]); + }); + + it('inserts nothing when every embed points at a cross-workspace source', async () => { + const { service, insertMany } = makeService({ inWorkspaceIds: [] }); + + const result = await service.syncPageTemplateReferences( + 'host', + 'w1', + docWithEmbeds(['cross-a', 'cross-b']), + ); + + expect(result.inserted).toBe(0); + expect(insertMany).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/core/page/transclusion/spec/page-template-lookup.spec.ts b/apps/server/src/core/page/transclusion/spec/page-template-lookup.spec.ts new file mode 100644 index 00000000..f62a047c --- /dev/null +++ b/apps/server/src/core/page/transclusion/spec/page-template-lookup.spec.ts @@ -0,0 +1,113 @@ +import { TransclusionService } from '../transclusion.service'; + +/** + * Exercises the pure access/mapping logic of `lookupTemplate`: + * - accessible + present -> content (comments stripped) + meta + * - accessible + missing -> not_found + * - inaccessible -> no_access + * The access decision is taken from `filterViewerAccessiblePageIds`, which we + * stub; DB/repo internals are mocked. + */ +describe('TransclusionService.lookupTemplate (access mapping)', () => { + function makeService(opts: { + accessibleIds: string[]; + pages: Array<{ + id: string; + title: string | null; + icon: string | null; + content: unknown; + updatedAt: Date; + }>; + }) { + const pageRepo = { + findManyByIds: jest.fn().mockResolvedValue(opts.pages), + }; + + const service = new TransclusionService( + {} as any, // db + {} as any, // pageTransclusionsRepo + {} as any, // pageTransclusionReferencesRepo + {} as any, // pageTemplateReferencesRepo + pageRepo as any, + {} as any, // pagePermissionRepo + {} as any, // spaceMemberRepo + {} as any, // attachmentRepo + {} as any, // storageService + {} as any, // pageAccessService + ); + + jest + .spyOn(service, 'filterViewerAccessiblePageIds') + .mockResolvedValue(opts.accessibleIds); + + return { service, pageRepo }; + } + + const now = new Date('2026-06-20T00:00:00.000Z'); + + it('returns no_access for ids the viewer cannot see', async () => { + const { service } = makeService({ accessibleIds: [], pages: [] }); + const { items } = await service.lookupTemplate(['p1'], 'u1', 'w1'); + expect(items).toEqual([{ sourcePageId: 'p1', status: 'no_access' }]); + }); + + it('returns not_found for accessible-but-missing pages', async () => { + const { service } = makeService({ accessibleIds: ['p1'], pages: [] }); + const { items } = await service.lookupTemplate(['p1'], 'u1', 'w1'); + expect(items).toEqual([{ sourcePageId: 'p1', status: 'not_found' }]); + }); + + it('returns content + meta for accessible pages and strips comment marks', async () => { + const content = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'hello', + marks: [{ type: 'comment', attrs: { commentId: 'c1' } }], + }, + ], + }, + ], + }; + const { service } = makeService({ + accessibleIds: ['p1'], + pages: [ + { id: 'p1', title: 'Tmpl', icon: '📄', content, updatedAt: now }, + ], + }); + + const { items } = await service.lookupTemplate(['p1'], 'u1', 'w1'); + expect(items).toHaveLength(1); + const item = items[0] as any; + expect(item.status).toBeUndefined(); + expect(item.title).toBe('Tmpl'); + expect(item.icon).toBe('📄'); + expect(item.sourceUpdatedAt).toBe(now); + + // comment mark must be gone from the returned content + const json = JSON.stringify(item.content); + expect(json).not.toContain('comment'); + expect(json).toContain('hello'); + }); + + it('maps a mixed batch positionally', async () => { + const { service } = makeService({ + accessibleIds: ['ok'], + pages: [ + { id: 'ok', title: 'A', icon: null, content: { type: 'doc', content: [] }, updatedAt: now }, + ], + }); + const { items } = await service.lookupTemplate( + ['no', 'ok', 'gone'], + 'u1', + 'w1', + ); + expect((items[0] as any).status).toBe('no_access'); + expect((items[1] as any).status).toBeUndefined(); + expect((items[2] as any).status).toBe('no_access'); + }); +}); diff --git a/apps/server/src/core/page/transclusion/spec/page-template.controller.spec.ts b/apps/server/src/core/page/transclusion/spec/page-template.controller.spec.ts new file mode 100644 index 00000000..2de644e0 --- /dev/null +++ b/apps/server/src/core/page/transclusion/spec/page-template.controller.spec.ts @@ -0,0 +1,93 @@ +import { Test } from '@nestjs/testing'; +import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { PageTemplateController } from '../page-template.controller'; +import { TransclusionService } from '../transclusion.service'; +import { PageRepo } from '@docmost/db/repos/page/page.repo'; +import { PageAccessService } from '../../page-access/page-access.service'; +import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard'; +import { UserThrottlerGuard } from '../../../../integrations/throttle/user-throttler.guard'; + +describe('PageTemplateController.toggleTemplate', () => { + let controller: PageTemplateController; + let pageRepo: { findById: jest.Mock; updatePage: jest.Mock }; + let pageAccessService: { validateCanEdit: jest.Mock }; + let transclusionService: Partial>; + + const user = { id: 'u1', workspaceId: 'w1' } as any; + const page = { + id: 'p1', + workspaceId: 'w1', + deletedAt: null, + isTemplate: false, + } as any; + + beforeEach(async () => { + pageRepo = { + findById: jest.fn().mockResolvedValue(page), + updatePage: jest.fn().mockResolvedValue(undefined), + }; + pageAccessService = { + validateCanEdit: jest.fn().mockResolvedValue(undefined), + }; + transclusionService = { lookupTemplate: jest.fn() }; + + const module = await Test.createTestingModule({ + controllers: [PageTemplateController], + providers: [ + { provide: TransclusionService, useValue: transclusionService }, + { provide: PageRepo, useValue: pageRepo }, + { provide: PageAccessService, useValue: pageAccessService }, + ], + }) + .overrideGuard(JwtAuthGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(UserThrottlerGuard) + .useValue({ canActivate: () => true }) + .compile(); + + controller = module.get(PageTemplateController); + }); + + it('throws NotFound and does not touch the page when the page is missing', async () => { + pageRepo.findById.mockResolvedValue(null); + await expect( + controller.toggleTemplate({ pageId: 'p1' } as any, user), + ).rejects.toBeInstanceOf(NotFoundException); + expect(pageAccessService.validateCanEdit).not.toHaveBeenCalled(); + expect(pageRepo.updatePage).not.toHaveBeenCalled(); + }); + + it('enforces CASL edit: when validateCanEdit throws, the flag is NOT flipped', async () => { + pageAccessService.validateCanEdit.mockRejectedValue( + new ForbiddenException(), + ); + await expect( + controller.toggleTemplate({ pageId: 'p1' } as any, user), + ).rejects.toBeInstanceOf(ForbiddenException); + expect(pageAccessService.validateCanEdit).toHaveBeenCalledWith(page, user); + expect(pageRepo.updatePage).not.toHaveBeenCalled(); + }); + + it('flips is_template (toggle) when the user can edit', async () => { + const out = await controller.toggleTemplate( + { pageId: 'p1' } as any, + user, + ); + expect(pageAccessService.validateCanEdit).toHaveBeenCalledWith(page, user); + // page.isTemplate was false → toggled to true + expect(pageRepo.updatePage).toHaveBeenCalledWith({ isTemplate: true }, 'p1'); + expect(out).toEqual({ pageId: 'p1', isTemplate: true }); + }); + + it('respects an explicit isTemplate flag instead of toggling', async () => { + const out = await controller.toggleTemplate( + { pageId: 'p1', isTemplate: false } as any, + user, + ); + expect(pageRepo.updatePage).toHaveBeenCalledWith( + { isTemplate: false }, + 'p1', + ); + expect(out).toEqual({ pageId: 'p1', isTemplate: false }); + }); +}); diff --git a/apps/server/src/core/page/transclusion/transclusion.module.ts b/apps/server/src/core/page/transclusion/transclusion.module.ts index e01e386d..ab8fada5 100644 --- a/apps/server/src/core/page/transclusion/transclusion.module.ts +++ b/apps/server/src/core/page/transclusion/transclusion.module.ts @@ -1,9 +1,10 @@ import { Module } from '@nestjs/common'; import { TransclusionController } from './transclusion.controller'; +import { PageTemplateController } from './page-template.controller'; import { TransclusionService } from './transclusion.service'; @Module({ - controllers: [TransclusionController], + controllers: [TransclusionController, PageTemplateController], providers: [TransclusionService], exports: [TransclusionService], }) diff --git a/apps/server/src/core/page/transclusion/transclusion.service.ts b/apps/server/src/core/page/transclusion/transclusion.service.ts index e208707c..afe08e44 100644 --- a/apps/server/src/core/page/transclusion/transclusion.service.ts +++ b/apps/server/src/core/page/transclusion/transclusion.service.ts @@ -10,17 +10,27 @@ import { InjectKysely } from 'nestjs-kysely'; import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; import { PageTransclusionsRepo } from '@docmost/db/repos/page-transclusions/page-transclusions.repo'; import { PageTransclusionReferencesRepo } from '@docmost/db/repos/page-transclusions/page-transclusion-references.repo'; +import { PageTemplateReferencesRepo } from '@docmost/db/repos/page-template-references/page-template-references.repo'; import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo'; import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo'; import { StorageService } from '../../../integrations/storage/storage.service'; import { + collectPageEmbedsFromPmJson, collectReferencesFromPmJson, collectTransclusionsFromPmJson, } from './utils/transclusion-prosemirror.util'; import { rewriteAttachmentsForUnsync } from './utils/transclusion-unsync.util'; -import { TransclusionLookup } from './transclusion.types'; +import { + PageTemplateLookup, + TransclusionLookup, +} from './transclusion.types'; +import { + getProsemirrorContent, + removeMarkTypeFromDoc, +} from '../../../common/helpers/prosemirror/utils'; +import { jsonToNode } from '../../../collaboration/collaboration.util'; import { Page, User } from '@docmost/db/types/entity.types'; import { PageAccessService } from '../page-access/page-access.service'; @@ -41,6 +51,7 @@ export class TransclusionService { @InjectKysely() private readonly db: KyselyDB, private readonly pageTransclusionsRepo: PageTransclusionsRepo, private readonly pageTransclusionReferencesRepo: PageTransclusionReferencesRepo, + private readonly pageTemplateReferencesRepo: PageTemplateReferencesRepo, private readonly pageRepo: PageRepo, private readonly pagePermissionRepo: PagePermissionRepo, private readonly spaceMemberRepo: SpaceMemberRepo, @@ -217,6 +228,225 @@ export class TransclusionService { return { inserted: rows.length }; } + // --------------------------------------------------------------------------- + // Whole-page live embeds (pageEmbed node) + // --------------------------------------------------------------------------- + + /** + * Restrict a set of candidate `pageEmbed` source ids to the pages that + * actually live in `workspaceId` (and are not soft-deleted). Defense in depth: + * `page_template_references` is NOT access-filtered, so we must never persist a + * reference to a cross-workspace source page. This is a single workspace-scoped + * existence query; it does NOT do per-viewer permission filtering (that stays + * the job of `lookupTemplate` at read time — see the warning below). + */ + private async filterInWorkspaceSourceIds( + sourceIds: string[], + workspaceId: string, + trx?: KyselyTransaction, + ): Promise> { + if (sourceIds.length === 0) return new Set(); + const db = trx ?? this.db; + const rows = await db + .selectFrom('pages') + .select('id') + .where('id', 'in', sourceIds) + .where('workspaceId', '=', workspaceId) + .where('deletedAt', 'is', null) + .execute(); + return new Set(rows.map((r) => r.id)); + } + + /** + * Diff `page_template_references` for a host page against the `pageEmbed` + * nodes currently in its content. Mirror of `syncPageReferences` but keyed by + * `sourcePageId` only (whole-page, no transclusionId). Idempotent. + * + * SECURITY: `page_template_references` rows are NOT access-filtered. Inserts + * are restricted here to in-workspace source pages so the graph can never + * accumulate cross-workspace edges, but rows are still NOT per-viewer + * permission-filtered. EVERY consumer of these rows MUST permission-filter at + * read time (as `lookupTemplate` does via `filterViewerAccessiblePageIds`). + */ + async syncPageTemplateReferences( + referencePageId: string, + workspaceId: string, + pmJson: unknown, + trx?: KyselyTransaction, + ): Promise<{ inserted: number; deleted: number }> { + const desired = collectPageEmbedsFromPmJson(pmJson); + const inWorkspace = await this.filterInWorkspaceSourceIds( + desired.map((d) => d.sourcePageId), + workspaceId, + trx, + ); + const desiredIds = new Set( + desired.map((d) => d.sourcePageId).filter((id) => inWorkspace.has(id)), + ); + + const existing = + await this.pageTemplateReferencesRepo.findByReferencePageId( + referencePageId, + trx, + ); + const existingIds = new Set(existing.map((e) => e.sourcePageId)); + + const toInsert = Array.from(desiredIds) + .filter((id) => !existingIds.has(id)) + .map((sourcePageId) => ({ + workspaceId, + referencePageId, + sourcePageId, + })); + + const toDelete = existing + .filter((e) => !desiredIds.has(e.sourcePageId)) + .map((e) => e.sourcePageId); + + if (toInsert.length > 0) { + await this.pageTemplateReferencesRepo.insertMany(toInsert, trx); + } + if (toDelete.length > 0) { + await this.pageTemplateReferencesRepo.deleteByReferenceAndSources( + referencePageId, + toDelete, + trx, + ); + } + + return { inserted: toInsert.length, deleted: toDelete.length }; + } + + /** + * Bulk-insert `page_template_references` for brand-new pages (duplication, + * import) where there is nothing to diff against. + * + * SECURITY: like `syncPageTemplateReferences`, inserts are restricted to + * in-workspace source pages so the (non-access-filtered) reference graph never + * gains a cross-workspace edge. Read-time per-viewer permission filtering is + * still required by every consumer. + */ + async insertTemplateReferencesForPages( + pages: Array<{ id: string; workspaceId: string; content: unknown }>, + trx?: KyselyTransaction, + ): Promise<{ inserted: number }> { + // Collect candidate source ids per workspace, then validate each workspace's + // set in a single existence query before building insert rows. + const candidatesByWorkspace = new Map>(); + const pageEmbeds = pages.map((page) => { + const sourceIds = collectPageEmbedsFromPmJson(page.content).map( + (e) => e.sourcePageId, + ); + let set = candidatesByWorkspace.get(page.workspaceId); + if (!set) { + set = new Set(); + candidatesByWorkspace.set(page.workspaceId, set); + } + for (const id of sourceIds) set.add(id); + return { page, sourceIds }; + }); + + const inWorkspaceByWorkspace = new Map>(); + for (const [workspaceId, candidates] of candidatesByWorkspace) { + inWorkspaceByWorkspace.set( + workspaceId, + await this.filterInWorkspaceSourceIds( + Array.from(candidates), + workspaceId, + trx, + ), + ); + } + + const rows: Array<{ + workspaceId: string; + referencePageId: string; + sourcePageId: string; + }> = []; + for (const { page, sourceIds } of pageEmbeds) { + const inWorkspace = inWorkspaceByWorkspace.get(page.workspaceId); + for (const sourcePageId of sourceIds) { + if (!inWorkspace?.has(sourcePageId)) continue; + rows.push({ + workspaceId: page.workspaceId, + referencePageId: page.id, + sourcePageId, + }); + } + } + if (rows.length === 0) return { inserted: 0 }; + await this.pageTemplateReferencesRepo.insertMany(rows, trx); + return { inserted: rows.length }; + } + + /** + * Resolve whole-page content for a set of source page ids on behalf of an + * authenticated viewer. For each accessible page returns its current content + * with `comment` marks stripped (comments belong to the source). Inaccessible + * pages return `no_access`, missing/deleted pages return `not_found`. Does NOT + * require `is_template` — any accessible page can be embedded (the template + * flag only affects picker discovery). + */ + async lookupTemplate( + sourcePageIds: string[], + viewerUserId: string, + workspaceId: string, + ): Promise<{ items: PageTemplateLookup[] }> { + if (sourcePageIds.length === 0) return { items: [] }; + + const uniqueIds = Array.from(new Set(sourcePageIds)); + const accessibleSet = new Set( + await this.filterViewerAccessiblePageIds( + uniqueIds, + viewerUserId, + workspaceId, + ), + ); + + const accessibleIds = uniqueIds.filter((id) => accessibleSet.has(id)); + const pages = await this.pageRepo.findManyByIds(accessibleIds, { + workspaceId, + includeContent: true, + }); + const pageById = new Map(pages.map((p) => [p.id, p])); + + const items: PageTemplateLookup[] = sourcePageIds.map((sourcePageId) => { + if (!accessibleSet.has(sourcePageId)) { + return { sourcePageId, status: 'no_access' as const }; + } + const page = pageById.get(sourcePageId); + if (!page) { + return { sourcePageId, status: 'not_found' as const }; + } + + let content: unknown = null; + try { + const pmJson = getProsemirrorContent(page.content); + const doc = jsonToNode(pmJson); + content = doc ? removeMarkTypeFromDoc(doc, 'comment').toJSON() : pmJson; + } catch (err) { + this.logger.error( + { err, sourcePageId }, + 'Failed to prepare template content for lookup', + ); + // Never return content carrying the source's comment marks. If the + // happy-path stripping failed, treat the page as not resolvable. + return { sourcePageId, status: 'not_found' as const }; + } + + return { + sourcePageId, + slugId: page.slugId, + title: page.title ?? null, + icon: page.icon ?? null, + content, + sourceUpdatedAt: page.updatedAt, + }; + }); + + return { items }; + } + /** * Resolve viewer access for source page IDs supplied by an authenticated * caller. Restricts candidates to pages the viewer can see at the space @@ -224,7 +454,7 @@ export class TransclusionService { * cannot read a sync block from a private space they don't belong to via * an unrestricted source page. */ - private async filterViewerAccessiblePageIds( + async filterViewerAccessiblePageIds( pageIds: string[], viewerUserId: string, workspaceId: string, diff --git a/apps/server/src/core/page/transclusion/transclusion.types.ts b/apps/server/src/core/page/transclusion/transclusion.types.ts index 240d121b..7501202b 100644 --- a/apps/server/src/core/page/transclusion/transclusion.types.ts +++ b/apps/server/src/core/page/transclusion/transclusion.types.ts @@ -12,3 +12,15 @@ export type TransclusionNodeSnapshot = { transclusionId: string; content: unknown; }; + +export type PageTemplateLookup = + | { + sourcePageId: string; + slugId: string; + title: string | null; + icon: string | null; + content: unknown; + sourceUpdatedAt: Date; + } + | { sourcePageId: string; status: 'not_found' } + | { sourcePageId: string; status: 'no_access' }; diff --git a/apps/server/src/core/page/transclusion/utils/transclusion-prosemirror.util.ts b/apps/server/src/core/page/transclusion/utils/transclusion-prosemirror.util.ts index 307985f8..eeeea0e2 100644 --- a/apps/server/src/core/page/transclusion/utils/transclusion-prosemirror.util.ts +++ b/apps/server/src/core/page/transclusion/utils/transclusion-prosemirror.util.ts @@ -2,12 +2,17 @@ import { TransclusionNodeSnapshot } from '../transclusion.types'; const TRANSCLUSION_TYPE = 'transclusionSource'; const REFERENCE_TYPE = 'transclusionReference'; +const PAGE_EMBED_TYPE = 'pageEmbed'; export type TransclusionReferenceSnapshot = { sourcePageId: string; transclusionId: string; }; +export type PageEmbedSnapshot = { + sourcePageId: string; +}; + /** * Walks a ProseMirror JSON document and returns one snapshot per top-level * `transclusion` node. Does not recurse into transclusions (schema disallows @@ -93,3 +98,42 @@ export function collectReferencesFromPmJson( visit(doc); return out; } + +/** + * Walks a ProseMirror JSON document and returns one snapshot per unique + * `sourcePageId` found on `pageEmbed` nodes (whole-page live embeds). Order + * preserved by first-seen, duplicates deduped. `pageEmbed` is an atom so it + * has no relevant children; we don't descend into transclusion sources. + */ +export function collectPageEmbedsFromPmJson( + doc: unknown, +): PageEmbedSnapshot[] { + if (!doc || typeof doc !== 'object') return []; + + const seen = new Set(); + const out: PageEmbedSnapshot[] = []; + + const visit = (node: any): void => { + if (!node || typeof node !== 'object') return; + + if (node.type === PAGE_EMBED_TYPE) { + const sourcePageId = node.attrs?.sourcePageId; + if (typeof sourcePageId === 'string' && sourcePageId.length > 0) { + if (!seen.has(sourcePageId)) { + seen.add(sourcePageId); + out.push({ sourcePageId }); + } + } + return; // atom node - no children + } + + if (node.type === TRANSCLUSION_TYPE) return; + + if (Array.isArray(node.content)) { + for (const child of node.content) visit(child); + } + }; + + visit(doc); + return out; +} diff --git a/apps/server/src/core/search/dto/search.dto.ts b/apps/server/src/core/search/dto/search.dto.ts index 40486a52..f23dd4d5 100644 --- a/apps/server/src/core/search/dto/search.dto.ts +++ b/apps/server/src/core/search/dto/search.dto.ts @@ -58,6 +58,10 @@ export class SearchSuggestionDTO { @IsBoolean() includePages?: boolean; + @IsOptional() + @IsBoolean() + onlyTemplates?: boolean; + @IsOptional() @IsString() spaceId?: string; diff --git a/apps/server/src/core/search/search.service.ts b/apps/server/src/core/search/search.service.ts index 9883b265..c3807398 100644 --- a/apps/server/src/core/search/search.service.ts +++ b/apps/server/src/core/search/search.service.ts @@ -216,6 +216,11 @@ export class SearchService { .where('workspaceId', '=', workspaceId) .limit(limit); + // Template picker: restrict to pages flagged as templates. + if (suggestion.onlyTemplates) { + pageSearch = pageSearch.where('isTemplate', '=', true); + } + // search all spaces the user has access to, prioritizing the current space const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId); diff --git a/apps/server/src/database/database.module.ts b/apps/server/src/database/database.module.ts index ab6648c7..d2083566 100644 --- a/apps/server/src/database/database.module.ts +++ b/apps/server/src/database/database.module.ts @@ -13,6 +13,7 @@ import { PagePermissionRepo } from './repos/page/page-permission.repo'; import { CommentRepo } from './repos/comment/comment.repo'; import { PageTransclusionsRepo } from './repos/page-transclusions/page-transclusions.repo'; import { PageTransclusionReferencesRepo } from './repos/page-transclusions/page-transclusion-references.repo'; +import { PageTemplateReferencesRepo } from './repos/page-template-references/page-template-references.repo'; import { PageHistoryRepo } from './repos/page/page-history.repo'; import { AttachmentRepo } from './repos/attachment/attachment.repo'; import { KyselyDB } from '@docmost/db/types/kysely.types'; @@ -86,6 +87,7 @@ import { normalizePostgresUrl } from '../common/helpers'; PagePermissionRepo, PageTransclusionsRepo, PageTransclusionReferencesRepo, + PageTemplateReferencesRepo, PageHistoryRepo, CommentRepo, FavoriteRepo, @@ -117,6 +119,7 @@ import { normalizePostgresUrl } from '../common/helpers'; PagePermissionRepo, PageTransclusionsRepo, PageTransclusionReferencesRepo, + PageTemplateReferencesRepo, PageHistoryRepo, CommentRepo, FavoriteRepo, diff --git a/apps/server/src/database/migrations/20260620T130000-page-is-template.ts b/apps/server/src/database/migrations/20260620T130000-page-is-template.ts new file mode 100644 index 00000000..3b6c7359 --- /dev/null +++ b/apps/server/src/database/migrations/20260620T130000-page-is-template.ts @@ -0,0 +1,20 @@ +import { type Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .alterTable('pages') + .addColumn('is_template', 'boolean', (col) => + col.notNull().defaultTo(false), + ) + .execute(); + + // Partial index backing the template picker: only template rows are indexed. + await sql`CREATE INDEX pages_is_template_idx ON pages (workspace_id) WHERE is_template`.execute( + db, + ); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropIndex('pages_is_template_idx').execute(); + await db.schema.alterTable('pages').dropColumn('is_template').execute(); +} diff --git a/apps/server/src/database/migrations/20260620T131000-page-template-references.ts b/apps/server/src/database/migrations/20260620T131000-page-template-references.ts new file mode 100644 index 00000000..0d201062 --- /dev/null +++ b/apps/server/src/database/migrations/20260620T131000-page-template-references.ts @@ -0,0 +1,42 @@ +import { type Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('page_template_references') + .addColumn('id', 'uuid', (col) => + col.primaryKey().defaultTo(sql`gen_uuid_v7()`), + ) + .addColumn('workspace_id', 'uuid', (col) => + col.notNull().references('workspaces.id').onDelete('cascade'), + ) + .addColumn('reference_page_id', 'uuid', (col) => + col.notNull().references('pages.id').onDelete('cascade'), + ) + .addColumn('source_page_id', 'uuid', (col) => + col.notNull().references('pages.id').onDelete('cascade'), + ) + .addColumn('created_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addUniqueConstraint('page_template_references_unique', [ + 'reference_page_id', + 'source_page_id', + ]) + .execute(); + + await db.schema + .createIndex('page_template_references_source_idx') + .on('page_template_references') + .column('source_page_id') + .execute(); + + await db.schema + .createIndex('page_template_references_ws_idx') + .on('page_template_references') + .column('workspace_id') + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('page_template_references').execute(); +} diff --git a/apps/server/src/database/repos/page-template-references/page-template-references.repo.ts b/apps/server/src/database/repos/page-template-references/page-template-references.repo.ts new file mode 100644 index 00000000..a678422f --- /dev/null +++ b/apps/server/src/database/repos/page-template-references/page-template-references.repo.ts @@ -0,0 +1,66 @@ +import { Injectable } from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; +import { dbOrTx } from '@docmost/db/utils'; +import { + InsertablePageTemplateReference, + PageTemplateReference, +} from '@docmost/db/types/entity.types'; + +@Injectable() +export class PageTemplateReferencesRepo { + constructor(@InjectKysely() private readonly db: KyselyDB) {} + + async findByReferencePageId( + referencePageId: string, + trx?: KyselyTransaction, + ): Promise { + return dbOrTx(this.db, trx) + .selectFrom('pageTemplateReferences') + .selectAll() + .where('referencePageId', '=', referencePageId) + .execute(); + } + + async findReferencePageIdsBySource( + sourcePageId: string, + workspaceId: string, + trx?: KyselyTransaction, + ): Promise { + const rows = await dbOrTx(this.db, trx) + .selectFrom('pageTemplateReferences') + .select('referencePageId') + .distinct() + .where('workspaceId', '=', workspaceId) + .where('sourcePageId', '=', sourcePageId) + .execute(); + return rows.map((r) => r.referencePageId); + } + + async insertMany( + rows: InsertablePageTemplateReference[], + trx?: KyselyTransaction, + ): Promise { + if (rows.length === 0) return; + await dbOrTx(this.db, trx) + .insertInto('pageTemplateReferences') + .values(rows) + .onConflict((oc) => + oc.columns(['referencePageId', 'sourcePageId']).doNothing(), + ) + .execute(); + } + + async deleteByReferenceAndSources( + referencePageId: string, + sourcePageIds: string[], + trx?: KyselyTransaction, + ): Promise { + if (sourcePageIds.length === 0) return; + await dbOrTx(this.db, trx) + .deleteFrom('pageTemplateReferences') + .where('referencePageId', '=', referencePageId) + .where('sourcePageId', 'in', sourcePageIds) + .execute(); + } +} diff --git a/apps/server/src/database/repos/page/page.repo.ts b/apps/server/src/database/repos/page/page.repo.ts index 7f3ea79c..504d01b3 100644 --- a/apps/server/src/database/repos/page/page.repo.ts +++ b/apps/server/src/database/repos/page/page.repo.ts @@ -40,6 +40,7 @@ export class PageRepo { 'spaceId', 'workspaceId', 'isLocked', + 'isTemplate', 'createdAt', 'updatedAt', 'deletedAt', @@ -112,6 +113,7 @@ export class PageRepo { opts?: { trx?: KyselyTransaction; workspaceId?: string; + includeContent?: boolean; }, ): Promise { if (pageIds.length === 0) return []; @@ -120,6 +122,7 @@ export class PageRepo { let query = db .selectFrom('pages') .select(this.baseFields) + .$if(opts?.includeContent, (qb) => qb.select('content')) .where('id', 'in', pageIds); if (opts?.workspaceId) { diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index 64c68469..cafcee0c 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -240,6 +240,14 @@ export interface PageTransclusionReferences { workspaceId: string; } +export interface PageTemplateReferences { + createdAt: Generated; + id: Generated; + referencePageId: string; + sourcePageId: string; + workspaceId: string; +} + export interface PageTransclusions { content: Json; createdAt: Generated; @@ -281,6 +289,7 @@ export interface Pages { icon: string | null; id: Generated; isLocked: Generated; + isTemplate: Generated; lastUpdatedAiChatId: string | null; lastUpdatedById: string | null; lastUpdatedSource: Generated; @@ -643,6 +652,7 @@ export interface DB { notifications: Notifications; pageAccess: PageAccess; pageTransclusionReferences: PageTransclusionReferences; + pageTemplateReferences: PageTemplateReferences; pageTransclusions: PageTransclusions; pagePermissions: PagePermissions; pageHistory: PageHistory; diff --git a/apps/server/src/database/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts index 819268d5..65a6c4da 100644 --- a/apps/server/src/database/types/entity.types.ts +++ b/apps/server/src/database/types/entity.types.ts @@ -12,6 +12,7 @@ import { PageAccess as _PageAccess, PageTransclusions, PageTransclusionReferences, + PageTemplateReferences, PagePermissions as _PagePermissions, PageVerifications as _PageVerifications, PageVerifiers as _PageVerifiers, @@ -188,6 +189,14 @@ export type UpdatablePageTransclusionReference = Updateable< Omit >; +// Page Template Reference (whole-page live embed back-references) +export type PageTemplateReference = Selectable; +export type InsertablePageTemplateReference = + Insertable; +export type UpdatablePageTemplateReference = Updateable< + Omit +>; + // File Task export type FileTask = Selectable; export type InsertableFileTask = Insertable; diff --git a/apps/server/src/integrations/throttle/throttle.module.ts b/apps/server/src/integrations/throttle/throttle.module.ts index bea5bcfb..1cb0c41a 100644 --- a/apps/server/src/integrations/throttle/throttle.module.ts +++ b/apps/server/src/integrations/throttle/throttle.module.ts @@ -7,6 +7,7 @@ import { parseRedisUrl } from '../../common/helpers'; import { AUTH_THROTTLER, AI_CHAT_THROTTLER, + PAGE_TEMPLATE_THROTTLER, PUBLIC_SHARE_AI_THROTTLER, } from './throttler-names'; import Redis from 'ioredis'; @@ -22,6 +23,11 @@ import Redis from 'ioredis'; throttlers: [ { name: AUTH_THROTTLER, ttl: 60_000, limit: 10 }, { name: AI_CHAT_THROTTLER, ttl: 60_000, limit: 25 }, + // Whole-page template lookup returns full ProseMirror docs for up + // to 50 ids per call and the embed depth cap is client-side only, so + // a scripted client could drive heavy content fan-out. 30 req/min + // per user is plenty for legitimate render-time batched lookups. + { name: PAGE_TEMPLATE_THROTTLER, ttl: 60_000, limit: 30 }, // Anonymous public-share assistant: ~5 req/min per IP. { name: PUBLIC_SHARE_AI_THROTTLER, ttl: 60_000, limit: 5 }, ], diff --git a/apps/server/src/integrations/throttle/throttler-names.ts b/apps/server/src/integrations/throttle/throttler-names.ts index 6533997f..f1ab971e 100644 --- a/apps/server/src/integrations/throttle/throttler-names.ts +++ b/apps/server/src/integrations/throttle/throttler-names.ts @@ -1,5 +1,6 @@ export const AUTH_THROTTLER = 'auth'; export const AI_CHAT_THROTTLER = 'ai-chat'; +export const PAGE_TEMPLATE_THROTTLER = 'page-template'; // IP-keyed throttler for the anonymous public-share AI assistant. There is no // authenticated user on that route, so it is keyed by client IP (the default // ThrottlerGuard tracker) to bound anonymous abuse — the workspace owner pays diff --git a/docs/page-templates-plan.md b/docs/page-templates-plan.md deleted file mode 100644 index 71d4a932..00000000 --- a/docs/page-templates-plan.md +++ /dev/null @@ -1,184 +0,0 @@ -# Шаблоны страниц — живая вставка целой страницы в другие — дизайн - -> Статус: **черновик / дизайн**. Реализация ещё не начата. -> Исходный кейс: одну страницу-«шаблон» нужно вставлять в несколько других так, -> чтобы при правке источника вставки обновлялись автоматически. -> -> Принятые на старте решения (выбор пользователя): -> - **Семантика** — живая синхронная вставка (контент источника обновляется в местах вставки), НЕ статическая копия. -> - **Сценарий** — вставка ноды в тело существующей страницы через slash-команду + пикер. -> - **Источник** — обычная страница со спец-флагом `is_template`. - -## 1. Что уже есть в кодовой базе (и почему мы это расширяем) - -В Gitmost уже реализована **блочная транслюзия** (synced blocks) — она покрывает «вставить ОДИН блок живой ссылкой в другие страницы»: - -- Ноды `transclusionSource` / `transclusionReference` — [packages/editor-ext/src/lib/transclusion/](../packages/editor-ext/src/lib/transclusion/). -- Таблицы `page_transclusions` (снапшот каждого source-блока на странице) и `page_transclusion_references` (кто кого ссылается) — [миграция](../apps/server/src/database/migrations/20260501T202258-page-transclusions.ts). -- Сервис [transclusion.service.ts](../apps/server/src/core/page/transclusion/transclusion.service.ts): `lookup`, `lookupWithAccessSet`, `syncPageTransclusions`, `syncPageReferences`, `unsyncReference`, `listReferences`, `insert*ForPages`. -- Контроль доступа: `filterViewerAccessiblePageIds` (членство в space + page-permissions) и публичный share-путь `ShareService.lookupTransclusionForShare` (граф доступа share, токенизация вложений, срезание комментариев). -- Клиент: read-only рендерер [transclusion-content.tsx](../apps/client/src/features/editor/components/transclusion/transclusion-content.tsx), батчинг-контекст [transclusion-lookup-context.tsx](../apps/client/src/features/editor/components/transclusion/transclusion-lookup-context.tsx), вьюха ссылки [transclusion-reference-view.tsx](../apps/client/src/features/editor/components/transclusion/transclusion-reference-view.tsx). -- Синхронизация ссылок происходит в [persistence.extension.ts](../apps/server/src/collaboration/extensions/persistence.extension.ts) (`syncTransclusion` после сохранения документа), **только для Yjs-путей** (живой коллаб). REST-обновления контента сейчас транслюзию не пересинхронизируют. - -**Вывод:** нужная фича — это та же транслюзия, но на уровне **целой страницы**, а не блока, плюс пометка источника флагом. ~70 % инфраструктуры переиспользуется; писать с нуля нужно только нодy `pageEmbed`, whole-page lookup, флаг `is_template` и UI-вставку. - -### Что НЕ переиспользуем - -В БД есть upstream-таблица `Templates` (Docmost), настройка `allowMemberTemplates`, тип избранного `template` и урезанный `TemplateSlashCommand`/`templateExtensions`. **Это другая, статическая механика** («создать страницу из шаблона-копии») и она не подходит под выбранный сценарий (живой синхрон + источник-страница). Не конфликтуем с ней, но и не строим на ней — ведём отдельный флаг `is_template` на странице. Урезанный `TemplateSlashCommand` к нашей фиче отношения не имеет. - -## 2. Модель - -- **Шаблон** = обычная, живая, редактируемая страница с `pages.is_template = true`. Флаг меняет только то, *как* страница всплывает (пикер шаблонов, опционально — группировка/скрытие в дереве), но не запрещает её редактировать или открывать как обычную. -- **Вставка** = новая Tiptap-нода `pageEmbed` (блочная, `atom`, `isolating`) с атрибутом `sourcePageId`. Рендерится read-only: вьюха тянет **весь** текущий контент страницы-источника и показывает его. Снапшот контента в документе хоста НЕ хранится — только ссылка `sourcePageId`. За счёт этого вставка «живая». -- **Обратные ссылки** = таблица `page_template_references` (`reference_page_id`, `source_page_id`) — чтобы знать «где используется этот шаблон» (для предупреждения при удалении и инвалидации кэша). Аналог `page_transclusion_references`, но whole-page. - -## 3. Развилка: отдельная нода `pageEmbed` vs расширение `transclusionReference` - -### Вариант A (рекомендуется) — отдельная нода `pageEmbed` -`transclusionReference` адресует конкретный блок по `transclusionId` внутри `sourcePageId`. У whole-page нет `transclusionId`. Можно было бы подставлять sentinel (`transclusionId = '__page__'`), но это засоряет инварианты уже работающей блочной транслюзии и её UNIQUE-констрейнт. - -- **Плюсы:** проверенный блочный путь не трогаем (нулевой риск регрессии); чистое разделение; при этом переиспользуем хелперы (рендерер, батчинг, контроль доступа). -- **Минусы:** чуть больше нового кода (новая нода, вьюха, эндпоинт, таблица). - -### Вариант B — расширить `transclusionReference` на whole-page (`transclusionId = null`) -- **Плюсы:** максимум переиспользования (та же нода, lookup, unsync, ремап при duplicate). -- **Минусы:** NULL в UNIQUE-констрейнте Postgres ведёт себя нетривиально (NULL-ы различны); ломаются инварианты рабочей фичи; риск регрессии блочной транслюзии. - -**Решение:** Вариант A. Дальше дизайн исходит из `pageEmbed`. - -## 4. Модель данных (миграции) - -Соглашение по именованию: `apps/server/src/database/migrations/YYYYMMDDThhmmss-description.ts`. Только ДОБАВЛЯЕМ столбцы/таблицы. После — `pnpm --filter server migration:codegen` для регенерации `src/database/types/db.d.ts`. - -**Миграция 1 — флаг шаблона:** -```sql -ALTER TABLE pages ADD COLUMN is_template boolean NOT NULL DEFAULT false; --- частичный индекс под пикер шаблонов -CREATE INDEX pages_is_template_idx ON pages (workspace_id) WHERE is_template; -``` - -**Миграция 2 — обратные ссылки whole-page (можно отложить до фазы 2, см. §9):** -```sql -CREATE TABLE page_template_references ( - id uuid PRIMARY KEY DEFAULT gen_uuid_v7(), - workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, - reference_page_id uuid NOT NULL REFERENCES pages(id) ON DELETE CASCADE, -- где встроено - source_page_id uuid NOT NULL REFERENCES pages(id) ON DELETE CASCADE, -- какой шаблон - created_at timestamptz NOT NULL DEFAULT now(), - UNIQUE (reference_page_id, source_page_id) -); -CREATE INDEX page_template_references_source_idx ON page_template_references (source_page_id); -CREATE INDEX page_template_references_ws_idx ON page_template_references (workspace_id); -``` - -## 5. Бэкенд - -### 5.1. Флаг `is_template` -- Тоггл: новый `POST /pages/toggle-template` (или поле в существующем `POST /pages/update`) → `pages.is_template`. Авторизация — стандартная CASL (право `Edit` на page/space, как у прочих мутаций страницы). -- `is_template` добавить в выдачу `pageRepo.findById` (колонка уже попадёт в `pages` select; убедиться, что отдаётся клиенту в `IPage`). -- Поиск: расширить search-suggestions фильтром `onlyTemplates` (для пикера показывать только `is_template = true`). - -### 5.2. Whole-page lookup (для авторизованных) -Новый эндпоинт `POST /pages/template/lookup`: -``` -Body: { sourcePageIds: string[] } // ≤ 50, как у block-lookup -Resp: { items: Array< - | { sourcePageId, title, icon, content, sourceUpdatedAt } - | { sourcePageId, status: 'no_access' | 'not_found' } - > } -``` -- Доступ: переиспользовать `filterViewerAccessiblePageIds` (членство в space + `pagePermissionRepo.filterAccessiblePageIds`). Если страница недоступна → `no_access`; удалена/нет → `not_found`. -- Контент: брать `pages.content`; **срезать `comment`-марки** (комментарии принадлежат источнику) через `removeMarkTypeFromDoc(doc, 'comment')` — как делает share-путь. -- `not_template`: можно НЕ запрещать встраивать не-шаблон (флаг — это про обнаружение в пикере, а не жёсткий констрейнт). Решение: lookup отдаёт контент любой доступной страницы; пикер же показывает только шаблоны. Это упрощает и не создаёт «битых» вставок, если со страницы потом сняли флаг. - -### 5.3. Синхронизация обратных ссылок -- Добавить `collectPageEmbedsFromPmJson(doc)` рядом с [transclusion-prosemirror.util.ts](../apps/server/src/core/page/transclusion/utils/transclusion-prosemirror.util.ts) — обход PM JSON, сбор `pageEmbed` нод → `{ sourcePageId }[]` (дедуп). -- Добавить `syncPageTemplateReferences(referencePageId, workspaceId, pmJson)` (diff с `page_template_references`) и дёрнуть его в `persistence.extension.syncTransclusion`. -- **Известный пробел:** REST-обновления контента (агент/AI через `updatePageContent`) не вызывают `syncTransclusion`. Для нашей фичи это терпимо: lookup работает по `sourcePageId` из самой ноды, а рассинхрон затронет только обратную таблицу (UI «где используется»). Отметить как follow-up. - -### 5.4. Публичный share-путь (фаза 2) -Зеркалить `ShareService.lookupTransclusionForShare` → `POST /shares/template/lookup`: -- источник-шаблон резолвится, только если он сам попадает в граф доступа share (его шарили / есть расшаренный предок с `includeSubPages`); -- токенизация вложений источника, срезание комментариев, схлопывание `not_found → no_access` (анти-утечка). -- **UX-нюанс:** шаблоны обычно лежат вне расшаренного поддерева → по умолчанию в публичном share они дадут `no_access` (вьюха покажет плейсхолдер). Это безопасный дефолт (без случайной утечки). Альтернатива «запекать контент шаблона в хост для share-зрителя» — отдельное решение, фаза 3. - -### 5.5. Ремап при дублировании страниц -В `duplicatePage` ([page.service.ts](../apps/server/src/core/page/services/page.service.ts)) уже ремапятся `mention` и `transclusionReference.sourcePageId`. Добавить ремап `pageEmbed.sourcePageId` (если источник тоже в копируемом наборе → указать на новую копию; иначе оставить как есть). Плюс `insertTemplateReferencesForPages` по аналогии с `insertReferencesForPages`. - -### 5.6. Регистрация ноды в серверной схеме (критично!) -Нода `pageEmbed` должна быть зарегистрирована в **серверном** `tiptapExtensions` ([collaboration.util.ts](../apps/server/src/collaboration/collaboration.util.ts)), иначе сервер вырежет её при сохранении/коллаборации (та же ловушка, что описана в [arbitrary-html-embed-plan.md](./arbitrary-html-embed-plan.md) §2). MCP-зеркало схемы (`packages/mcp/src/lib/`) — обновлять не обязательно для MVP (MCP может трактовать ноду как opaque), отметить как follow-up. - -## 6. Клиент - -### 6.1. Нода `pageEmbed` -- Новый модуль `packages/editor-ext/src/lib/page-embed/page-embed.ts`: `Node.create({ name:'pageEmbed', group:'block', atom:true, isolating:true })`, атрибут `sourcePageId` с `parseHTML`/`renderHTML` через `data-source-page-id` (для round-trip HTML↔JSON и paste). Экспорт в `packages/editor-ext/src/index.ts`. -- Регистрация в клиентских `mainExtensions` ([extensions.ts](../apps/client/src/features/editor/extensions/extensions.ts)) и серверной схеме (§5.6). - -### 6.2. NodeView `page-embed-view.tsx` -- Тянет whole-page контент через `useTemplateLookup` (расширить/обобщить батчинг-паттерн `transclusion-lookup-context.tsx`, или TanStack Query с ключом `sourcePageId`). -- Тело рендерит read-only вложенным редактором по образцу [transclusion-content.tsx](../apps/client/src/features/editor/components/transclusion/transclusion-content.tsx) (изоляция событий, `editable=false`, `UniqueID` с `updateDocument:false`). -- Шапка: иконка+заголовок шаблона со ссылкой на источник, кнопка «обновить», меню «отвязать → превратить в статическую копию» (новый `unsyncPageEmbed`, запекает текущий контент в документ хоста — по образцу `unsyncReference`). -- **Защита от циклов** (см. §7.1). - -### 6.3. Slash-команда + пикер -- Slash-пункт `/template` (или `/embed page`) открывает пикер страниц — переиспользовать [mention-list.tsx](../apps/client/src/features/editor/components/mention/mention-list.tsx) + search-query с фильтром `onlyTemplates` → вставляет `pageEmbed` с выбранным `sourcePageId`. - -### 6.4. Пометить страницу как шаблон -- Тоггл «Сделать шаблоном / Снять» в меню узла дерева ([space-tree-node-menu.tsx](../apps/client/src/features/page/tree/components/space-tree-node-menu.tsx)) и/или в «...» меню заголовка страницы → мутация на `POST /pages/toggle-template`. -- (Опционально, фаза 2) Галерея/раздел «Шаблоны». - -## 7. Краевые случаи (главное) - -### 7.1. Циклы / бесконечная рекурсия (самое важное) -A встраивает B, B встраивает A → бесконечная вложенность на клиенте. Сервер из lookup отдаёт «сырой» контент одного уровня и зациклиться не может — **гард обязателен на клиенте**: -- React-контекст с цепочкой `sourcePageId` предков; если текущий `sourcePageId` уже в цепочке → рендерить плейсхолдер «циклическая вставка», не рекурсировать. -- Жёсткий лимит глубины вложенности (например, 5). -- При выборе в пикере запрещать вставку самой текущей страницы (self-embed). Полное обнаружение циклов на вставке (обход графа) — избыточно, опираемся на рендер-гард. - -### 7.2. Удаление шаблона -Удаление страницы-шаблона — soft-delete (корзина) → вставки дают `not_found`/`no_access`, вьюха показывает «шаблон в корзине/не найден». Таблица `page_template_references` позволяет предупредить «используется в N страницах» перед удалением. При восстановлении вставки снова резолвятся. - -### 7.3. Доступ -Зритель хоста может не иметь доступа к странице-источнику (другой space/ограничение) → lookup вернёт `no_access`, вьюха — плейсхолдер. Это корректно (без утечки). - -### 7.4. Комментарии -Срезать `comment`-марки из встроенного контента (`removeMarkTypeFromDoc`) — комментарии относятся к источнику. - -### 7.5. Вложения -Встроенный контент ссылается на вложения источника. Для авторизованных доступ обычный (lookup уже проверил доступ к источнику). Для публичных share — токенизация по образцу share-пути (фаза 2). - -### 7.6. Вложенные транслюзии внутри шаблона -Шаблон может содержать `transclusionSource`/`transclusionReference`/`pageEmbed`. При whole-page рендере они отрисуются своими вьюхами (доп. вложенные lookup-и) — работает, но учитывать в гарде глубины (§7.1). - -### 7.7. История версий хоста -В истории хоста хранится только нода-ссылка (мелкая), не снапшот. Значит старые версии хоста покажут *текущий* контент шаблона (живой), без point-in-time точности. Снапшот-режим — вне scope, отметить. - -### 7.8. Экспорт (Markdown/HTML) и RAG/поиск -`jsonToHtml`/`jsonToMarkdown`/`jsonToText` на сервере не развернут `pageEmbed` (в документе только ссылка) → экспорт и `textContent` хоста не содержат текста шаблона; полнотекстовый/RAG-поиск не найдёт хост по тексту шаблона. Для MVP — плейсхолдер/ссылка; серверное разворачивание вставок при экспорте/индексации — фаза 3. - -## 8. Реестр переиспользования - -| Что | Файл | Как используем | -| --- | --- | --- | -| Read-only рендерер | `transclusion-content.tsx` | тело `pageEmbed` | -| Батчинг lookup | `transclusion-lookup-context.tsx` | `useTemplateLookup` | -| Контроль доступа | `transclusion.service.ts::filterViewerAccessiblePageIds` / `lookupWithAccessSet` | whole-page lookup | -| Share-путь | `share.service.ts::lookupTransclusionForShare` | `lookupTemplateForShare` (фаза 2) | -| Sync ссылок | `persistence.extension.ts::syncTransclusion` + `collectReferencesFromPmJson` | `+ collectPageEmbedsFromPmJson` / `syncPageTemplateReferences` | -| Unsync→копия | `transclusion.service.ts::unsyncReference` | `unsyncPageEmbed` | -| Пикер страниц | `mention-list.tsx` + search-query | пикер шаблонов (`onlyTemplates`) | -| Ремап при копировании | `page.service.ts::duplicatePage` | `+ ремап pageEmbed.sourcePageId` | -| Меню страницы | `space-tree-node-menu.tsx` | тоггл «Сделать шаблоном» | -| Серверная схема | `collaboration.util.ts::tiptapExtensions` | регистрация `pageEmbed` (критично) | - -## 9. Этапность - -- **MVP:** флаг `is_template` + тоггл-UI; нода `pageEmbed` + вьюха (живой read-only fetch с гардом циклов); `/template` slash + пикер; auth-эндпоинт lookup; синхронизация ссылок; ремап при duplicate. Без share (на публичных страницах — плейсхолдер), без разворачивания при экспорте. Таблица `page_template_references` — желательна, но можно начать с резолва по in-doc нодам. -- **Фаза 2:** публичный share-lookup; «отвязать → статическая копия»; «используется в N страницах» + предупреждение при удалении; галерея шаблонов. -- **Фаза 3:** разворачивание вставок на сервере для экспорта/RAG/textContent; режим point-in-time снапшота; обновление MCP-зеркала схемы; sync ссылок на REST-пути. - -## 10. Открытые вопросы - -1. Прятать ли страницы-шаблоны из обычного дерева space или показывать с бейджем? (предлагаю: показывать с бейджем, отдельную «галерею» — фаза 2). -2. Ограничивать ли источник только `is_template`-страницами на бэке, или разрешать встраивать любую доступную (флаг — только для пикера)? (предлагаю второе — меньше «битых» вставок). -3. Нужен ли whole-page embed на публичных share сразу в MVP или плейсхолдер достаточен на старте? (предлагаю плейсхолдер → фаза 2). diff --git a/packages/editor-ext/src/index.ts b/packages/editor-ext/src/index.ts index 003d2288..77ce9809 100644 --- a/packages/editor-ext/src/index.ts +++ b/packages/editor-ext/src/index.ts @@ -22,6 +22,7 @@ export * from "./lib/search-and-replace"; export * from "./lib/embed-provider"; export * from "./lib/subpages"; export * from "./lib/transclusion"; +export * from "./lib/page-embed"; export * from "./lib/highlight"; export * from "./lib/indent"; export * from "./lib/heading/heading"; diff --git a/packages/editor-ext/src/lib/page-embed/index.ts b/packages/editor-ext/src/lib/page-embed/index.ts new file mode 100644 index 00000000..43cb3a9c --- /dev/null +++ b/packages/editor-ext/src/lib/page-embed/index.ts @@ -0,0 +1 @@ +export * from "./page-embed"; diff --git a/packages/editor-ext/src/lib/page-embed/page-embed.ts b/packages/editor-ext/src/lib/page-embed/page-embed.ts new file mode 100644 index 00000000..119acfd4 --- /dev/null +++ b/packages/editor-ext/src/lib/page-embed/page-embed.ts @@ -0,0 +1,88 @@ +import { mergeAttributes, Node } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; + +export interface PageEmbedOptions { + HTMLAttributes: Record; + view: any; +} + +export interface PageEmbedAttributes { + sourcePageId?: string | null; +} + +declare module "@tiptap/core" { + interface Commands { + pageEmbed: { + insertPageEmbed: (attributes: PageEmbedAttributes) => ReturnType; + }; + } +} + +/** + * Whole-page live embed. Holds only a `sourcePageId` reference; the node view + * fetches the source page's current content at render time, so the embed stays + * live (no snapshot is stored in the host document). Separate from + * `transclusionReference` (which addresses a single block by `transclusionId`). + */ +export const PageEmbed = Node.create({ + name: "pageEmbed", + + addOptions() { + return { + HTMLAttributes: {}, + view: null, + }; + }, + + group: "block", + atom: true, + isolating: true, + selectable: true, + draggable: true, + + addAttributes() { + return { + sourcePageId: { + default: null, + parseHTML: (el) => el.getAttribute("data-source-page-id"), + renderHTML: (attrs) => + attrs.sourcePageId + ? { "data-source-page-id": attrs.sourcePageId } + : {}, + }, + }; + }, + + parseHTML() { + return [{ tag: `div[data-type="${this.name}"]` }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "div", + mergeAttributes( + { "data-type": this.name }, + this.options.HTMLAttributes, + HTMLAttributes, + ), + ]; + }, + + addCommands() { + return { + insertPageEmbed: + (attributes) => + ({ commands }) => + commands.insertContent({ + type: this.name, + attrs: attributes, + }), + }; + }, + + addNodeView() { + if (!this.options.view) return null; + this.editor.isInitialized = true; + return ReactNodeViewRenderer(this.options.view); + }, +});