From 6c422011ac26c49978b5526cf8d3d644db9ca510 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Tue, 22 Apr 2025 20:37:32 +0100 Subject: [PATCH] feat: public page sharing (#1012) * Share - WIP * - public attachment links - WIP * WIP * WIP * Share - WIP * WIP * WIP * include userRole in space object * WIP * Server render shared page meta tags * disable user select * Close Navbar on outside click on mobile * update shared page spaceId * WIP * fix * close sidebar on click * close sidebar * defaults * update copy * Store share key in lowercase * refactor page breadcrumbs * Change copy * add link ref * open link button * add meta og:title * add twitter tags * WIP * make shares/info endpoint public * fix * * add /p/ segment to share urls * minore fixes * change mobile breadcrumb icon --- apps/client/index.html | 1 + .../public/locales/en-US/translation.json | 23 +- apps/client/src/App.tsx | 11 + .../layouts/global/global-app-shell.tsx | 14 +- .../components/settings/settings-queries.tsx | 12 +- .../components/settings/settings-sidebar.tsx | 15 + .../src/components/theme-toggle.module.css | 19 + apps/client/src/components/theme-toggle.tsx | 33 +- .../src/features/editor/atoms/editor-atoms.ts | 2 + .../editor/components/drawio/drawio-view.tsx | 2 +- .../components/excalidraw/excalidraw-view.tsx | 2 +- .../components/mention/mention-view.tsx | 21 +- .../table-of-contents.module.css | 5 + .../table-of-contents/table-of-contents.tsx | 26 +- .../features/editor/readonly-page-editor.tsx | 67 ++ .../breadcrumbs/breadcrumb.module.css | 1 + .../components/breadcrumbs/breadcrumb.tsx | 88 ++- .../components/header/page-header-menu.tsx | 3 + apps/client/src/features/page/page.utils.ts | 19 +- .../page/tree/components/space-tree.tsx | 31 +- .../features/page/tree/styles/tree.module.css | 6 +- .../src/features/page/tree/utils/utils.ts | 2 +- .../src/features/share/atoms/sidebar-atom.ts | 9 + .../share/components/share-action-menu.tsx | 106 +++ .../share/components/share-layout.tsx | 10 + .../features/share/components/share-list.tsx | 97 +++ .../features/share/components/share-modal.tsx | 227 +++++++ .../features/share/components/share-shell.tsx | 196 ++++++ .../share/components/share.module.css | 20 + .../features/share/components/shared-tree.tsx | 179 +++++ .../features/share/hooks/use-toggle-toc.ts | 8 + .../src/features/share/queries/share-query.ts | 179 +++++ .../features/share/services/share-service.ts | 59 ++ .../src/features/share/types/share.types.ts | 73 +++ apps/client/src/features/share/utils.ts | 60 ++ .../components/sidebar/space-sidebar.tsx | 12 +- apps/client/src/lib/api-client.ts | 1 + .../src/pages/settings/shares/shares.tsx | 31 + .../client/src/pages/share/share-redirect.tsx | 35 + apps/client/src/pages/share/shared-page.tsx | 58 ++ apps/server/package.json | 12 +- .../src/common/helpers/prosemirror/utils.ts | 39 ++ .../core/attachment/attachment.controller.ts | 611 ++++++++++-------- .../src/core/attachment/attachment.module.ts | 3 +- apps/server/src/core/auth/dto/jwt-payload.ts | 9 + .../src/core/auth/services/token.service.ts | 16 + .../casl/abilities/space-ability.factory.ts | 3 + .../casl/interfaces/space-ability.type.ts | 4 +- apps/server/src/core/core.module.ts | 2 + .../src/core/page/services/page.service.ts | 12 +- apps/server/src/core/share/dto/share.dto.ts | 58 ++ .../src/core/share/share-seo.controller.ts | 109 ++++ .../server/src/core/share/share.controller.ts | 171 +++++ apps/server/src/core/share/share.module.ts | 13 + apps/server/src/core/share/share.service.ts | 297 +++++++++ apps/server/src/core/share/share.util.ts | 22 + apps/server/src/database/database.module.ts | 3 + .../migrations/20250408T191830-shares.ts | 38 ++ .../src/database/repos/page/page.repo.ts | 11 +- .../src/database/repos/share/share.repo.ts | 242 +++++++ apps/server/src/database/types/db.d.ts | 15 + .../server/src/database/types/entity.types.ts | 6 + .../src/integrations/export/export.service.ts | 26 +- apps/server/src/integrations/export/utils.ts | 38 +- apps/server/src/main.ts | 11 +- pnpm-lock.yaml | 309 +++++---- 66 files changed, 3331 insertions(+), 512 deletions(-) create mode 100644 apps/client/src/components/theme-toggle.module.css create mode 100644 apps/client/src/features/editor/readonly-page-editor.tsx create mode 100644 apps/client/src/features/share/atoms/sidebar-atom.ts create mode 100644 apps/client/src/features/share/components/share-action-menu.tsx create mode 100644 apps/client/src/features/share/components/share-layout.tsx create mode 100644 apps/client/src/features/share/components/share-list.tsx create mode 100644 apps/client/src/features/share/components/share-modal.tsx create mode 100644 apps/client/src/features/share/components/share-shell.tsx create mode 100644 apps/client/src/features/share/components/share.module.css create mode 100644 apps/client/src/features/share/components/shared-tree.tsx create mode 100644 apps/client/src/features/share/hooks/use-toggle-toc.ts create mode 100644 apps/client/src/features/share/queries/share-query.ts create mode 100644 apps/client/src/features/share/services/share-service.ts create mode 100644 apps/client/src/features/share/types/share.types.ts create mode 100644 apps/client/src/features/share/utils.ts create mode 100644 apps/client/src/pages/settings/shares/shares.tsx create mode 100644 apps/client/src/pages/share/share-redirect.tsx create mode 100644 apps/client/src/pages/share/shared-page.tsx create mode 100644 apps/server/src/core/share/dto/share.dto.ts create mode 100644 apps/server/src/core/share/share-seo.controller.ts create mode 100644 apps/server/src/core/share/share.controller.ts create mode 100644 apps/server/src/core/share/share.module.ts create mode 100644 apps/server/src/core/share/share.service.ts create mode 100644 apps/server/src/core/share/share.util.ts create mode 100644 apps/server/src/database/migrations/20250408T191830-shares.ts create mode 100644 apps/server/src/database/repos/share/share.repo.ts diff --git a/apps/client/index.html b/apps/client/index.html index 98e02c8b..c96058cb 100644 --- a/apps/client/index.html +++ b/apps/client/index.html @@ -6,6 +6,7 @@ Docmost +
diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index bbc6a702..0746ed15 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -362,5 +362,26 @@ "Move page to a different space.": "Move page to a different space.", "Real-time editor connection lost. Retrying...": "Real-time editor connection lost. Retrying...", "Table of contents": "Table of contents", - "Add headings (H1, H2, H3) to generate a table of contents.": "Add headings (H1, H2, H3) to generate a table of contents." + "Add headings (H1, H2, H3) to generate a table of contents.": "Add headings (H1, H2, H3) to generate a table of contents.", + "Share": "Share", + "Public sharing": "Public sharing", + "Shared by": "Shared by", + "Shared at": "Shared at", + "Inherits public sharing from": "Inherits public sharing from", + "Share to web": "Share to web", + "Shared to web": "Shared to web", + "Anyone with the link can view this page": "Anyone with the link can view this page", + "Make this page publicly accessible": "Make this page publicly accessible", + "Include sub-pages": "Include sub-pages", + "Make sub-pages public too": "Make sub-pages public too", + "Allow search engines to index page": "Allow search engines to index page", + "Open page": "Open page", + "Page": "Page", + "Delete public share link": "Delete public share link", + "Delete share": "Delete share", + "Are you sure you want to delete this shared link?": "Are you sure you want to delete this shared link?", + "Publicly shared pages from spaces you are a member of will appear here": "Publicly shared pages from spaces you are a member of will appear here", + "Share deleted successfully": "Share deleted successfully", + "Share not found": "Share not found", + "Failed to share page": "Failed to share page" } diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index c806f852..4399062f 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -26,6 +26,10 @@ import { useTranslation } from "react-i18next"; import Security from "@/ee/security/pages/security.tsx"; import License from "@/ee/licence/pages/license.tsx"; import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx"; +import SharedPage from "@/pages/share/shared-page.tsx"; +import Shares from "@/pages/settings/shares/shares.tsx"; +import ShareLayout from "@/features/share/components/share-layout.tsx"; +import ShareRedirect from '@/pages/share/share-redirect.tsx'; export default function App() { const { t } = useTranslation(); @@ -51,6 +55,12 @@ export default function App() { )} + }> + } /> + } /> + + + } /> } /> }> @@ -78,6 +88,7 @@ export default function App() { } /> } /> } /> + } /> } /> {!isCloud() && } />} {isCloud() && } />} diff --git a/apps/client/src/components/layouts/global/global-app-shell.tsx b/apps/client/src/components/layouts/global/global-app-shell.tsx index 4b5c0269..c8aff4cc 100644 --- a/apps/client/src/components/layouts/global/global-app-shell.tsx +++ b/apps/client/src/components/layouts/global/global-app-shell.tsx @@ -14,6 +14,8 @@ import { AppHeader } from "@/components/layouts/global/app-header.tsx"; import Aside from "@/components/layouts/global/aside.tsx"; import classes from "./app-shell.module.css"; import { useTrialEndAction } from "@/ee/hooks/use-trial-end-action.tsx"; +import { useClickOutside, useMergedRef } from "@mantine/hooks"; +import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts"; export default function GlobalAppShell({ children, @@ -22,11 +24,19 @@ export default function GlobalAppShell({ }) { useTrialEndAction(); const [mobileOpened] = useAtom(mobileSidebarAtom); + const toggleMobile = useToggleSidebar(mobileSidebarAtom); const [desktopOpened] = useAtom(desktopSidebarAtom); const [{ isAsideOpen }] = useAtom(asideStateAtom); const [sidebarWidth, setSidebarWidth] = useAtom(sidebarWidthAtom); const [isResizing, setIsResizing] = useState(false); const sidebarRef = useRef(null); + const navbarOutsideRef = useClickOutside(() => { + if (mobileOpened) { + toggleMobile(); + } + }); + + const mergedRef = useMergedRef(sidebarRef, navbarOutsideRef); const startResizing = React.useCallback((mouseDownEvent) => { mouseDownEvent.preventDefault(); @@ -102,7 +112,7 @@ export default function GlobalAppShell({
{isSpaceRoute && } @@ -111,7 +121,7 @@ export default function GlobalAppShell({ )} {isSettingsRoute ? ( - {children} + {children} ) : ( children )} diff --git a/apps/client/src/components/settings/settings-queries.tsx b/apps/client/src/components/settings/settings-queries.tsx index f4cddfaa..2f3b46bd 100644 --- a/apps/client/src/components/settings/settings-queries.tsx +++ b/apps/client/src/components/settings/settings-queries.tsx @@ -8,7 +8,8 @@ import { getGroups } from "@/features/group/services/group-service.ts"; import { QueryParams } from "@/lib/types.ts"; import { getWorkspaceMembers } from "@/features/workspace/services/workspace-service.ts"; import { getLicenseInfo } from "@/ee/licence/services/license-service.ts"; -import { getSsoProviders } from '@/ee/security/services/security-service.ts'; +import { getSsoProviders } from "@/ee/security/services/security-service.ts"; +import { getShares } from "@/features/share/services/share-service.ts"; export const prefetchWorkspaceMembers = () => { const params = { limit: 100, page: 1, query: "" } as QueryParams; @@ -56,4 +57,11 @@ export const prefetchSsoProviders = () => { queryKey: ["sso-providers"], queryFn: () => getSsoProviders(), }); -}; \ No newline at end of file +}; + +export const prefetchShares = () => { + queryClient.prefetchQuery({ + queryKey: ["share-list", { page: 1 }], + queryFn: () => getShares({ page: 1, limit: 100 }), + }); +}; diff --git a/apps/client/src/components/settings/settings-sidebar.tsx b/apps/client/src/components/settings/settings-sidebar.tsx index 16d94c64..483c0026 100644 --- a/apps/client/src/components/settings/settings-sidebar.tsx +++ b/apps/client/src/components/settings/settings-sidebar.tsx @@ -11,6 +11,7 @@ import { IconCoin, IconLock, IconKey, + IconWorld, } from "@tabler/icons-react"; import { Link, useLocation, useNavigate } from "react-router-dom"; import classes from "./settings.module.css"; @@ -23,11 +24,14 @@ import { prefetchBilling, prefetchGroups, prefetchLicense, + prefetchShares, prefetchSpaces, prefetchSsoProviders, prefetchWorkspaceMembers, } from "@/components/settings/settings-queries.tsx"; import AppVersion from "@/components/settings/app-version.tsx"; +import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; +import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts"; interface DataItem { label: string; @@ -82,6 +86,7 @@ const groupedData: DataGroup[] = [ }, { label: "Groups", icon: IconUsersGroup, path: "/settings/groups" }, { label: "Spaces", icon: IconSpaces, path: "/settings/spaces" }, + { label: "Public sharing", icon: IconWorld, path: "/settings/sharing" }, ], }, { @@ -103,6 +108,8 @@ export default function SettingsSidebar() { const navigate = useNavigate(); const { isAdmin } = useUserRole(); const [workspace] = useAtom(workspaceAtom); + const [mobileSidebarOpened] = useAtom(mobileSidebarAtom); + const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom); useEffect(() => { setActive(location.pathname); @@ -170,6 +177,9 @@ export default function SettingsSidebar() { case "Security & SSO": prefetchHandler = prefetchSsoProviders; break; + case "Public sharing": + prefetchHandler = prefetchShares; + break; default: break; } @@ -181,6 +191,11 @@ export default function SettingsSidebar() { data-active={active.startsWith(item.path) || undefined} key={item.label} to={item.path} + onClick={() => { + if (mobileSidebarOpened) { + toggleMobileSidebar(); + } + }} > {t(item.label)} diff --git a/apps/client/src/components/theme-toggle.module.css b/apps/client/src/components/theme-toggle.module.css new file mode 100644 index 00000000..936c5983 --- /dev/null +++ b/apps/client/src/components/theme-toggle.module.css @@ -0,0 +1,19 @@ +.dark { + @mixin dark { + display: none; + } + + @mixin light { + display: block; + } +} + +.light { + @mixin light { + display: none; + } + + @mixin dark { + display: block; + } +} diff --git a/apps/client/src/components/theme-toggle.tsx b/apps/client/src/components/theme-toggle.tsx index a988db6f..bf9245a2 100644 --- a/apps/client/src/components/theme-toggle.tsx +++ b/apps/client/src/components/theme-toggle.tsx @@ -1,13 +1,28 @@ -import { Button, Group, useMantineColorScheme } from '@mantine/core'; +import { + ActionIcon, + Tooltip, + useComputedColorScheme, + useMantineColorScheme, +} from "@mantine/core"; +import { IconMoon, IconSun } from "@tabler/icons-react"; +import classes from "./theme-toggle.module.css"; export function ThemeToggle() { - const { setColorScheme } = useMantineColorScheme(); + const { setColorScheme } = useMantineColorScheme(); + const computedColorScheme = useComputedColorScheme(); - return ( - - - - - - ); + return ( + + { + setColorScheme(computedColorScheme === "light" ? "dark" : "light"); + }} + aria-label="Toggle color scheme" + > + + + + + ); } diff --git a/apps/client/src/features/editor/atoms/editor-atoms.ts b/apps/client/src/features/editor/atoms/editor-atoms.ts index 6f54c057..d4f133f7 100644 --- a/apps/client/src/features/editor/atoms/editor-atoms.ts +++ b/apps/client/src/features/editor/atoms/editor-atoms.ts @@ -5,4 +5,6 @@ export const pageEditorAtom = atom(null); export const titleEditorAtom = atom(null); +export const readOnlyEditorAtom = atom(null); + export const yjsConnectionStatusAtom = atom(""); diff --git a/apps/client/src/features/editor/components/drawio/drawio-view.tsx b/apps/client/src/features/editor/components/drawio/drawio-view.tsx index 16e6dc97..468e26a0 100644 --- a/apps/client/src/features/editor/components/drawio/drawio-view.tsx +++ b/apps/client/src/features/editor/components/drawio/drawio-view.tsx @@ -139,7 +139,7 @@ export default function DrawioView(props: NodeViewProps) { )} /> - {selected && ( + {selected && editor.isEditable && ( - {selected && ( + {selected && editor.isEditable && ( {entityType === "user" && ( @@ -28,7 +41,9 @@ export default function MentionView(props: NodeViewProps) { diff --git a/apps/client/src/features/editor/components/table-of-contents/table-of-contents.module.css b/apps/client/src/features/editor/components/table-of-contents/table-of-contents.module.css index 9554a84d..739cc0d1 100644 --- a/apps/client/src/features/editor/components/table-of-contents/table-of-contents.module.css +++ b/apps/client/src/features/editor/components/table-of-contents/table-of-contents.module.css @@ -52,3 +52,8 @@ ) !important; } } + + +.leftBorder { + border-left: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); +} diff --git a/apps/client/src/features/editor/components/table-of-contents/table-of-contents.tsx b/apps/client/src/features/editor/components/table-of-contents/table-of-contents.tsx index 6945a29b..b309d67d 100644 --- a/apps/client/src/features/editor/components/table-of-contents/table-of-contents.tsx +++ b/apps/client/src/features/editor/components/table-of-contents/table-of-contents.tsx @@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next"; type TableOfContentsProps = { editor: ReturnType; + isShare?: boolean; }; export type HeadingLink = { @@ -73,6 +74,7 @@ export const TableOfContents: FC = (props) => { const handleUpdate = () => { const result = recalculateLinks(props.editor?.$nodes("heading")); + setLinks(result.links); setHeadingDOMNodes(result.nodes); }; @@ -85,9 +87,12 @@ export const TableOfContents: FC = (props) => { }; }, [props.editor]); - useEffect(() => { - handleUpdate(); - }, []); + useEffect( + () => { + handleUpdate(); + }, + props.isShare ? [props.editor] : [], + ); useEffect(() => { try { @@ -133,16 +138,23 @@ export const TableOfContents: FC = (props) => { if (!links.length) { return ( <> - - {t("Add headings (H1, H2, H3) to generate a table of contents.")} - + {!props.isShare && ( + + {t("Add headings (H1, H2, H3) to generate a table of contents.")} + + )} ); } return ( <> -
+ {props.isShare && ( + + {t("Table of contents")} + + )} +
{links.map((item, idx) => ( component="button" diff --git a/apps/client/src/features/editor/readonly-page-editor.tsx b/apps/client/src/features/editor/readonly-page-editor.tsx new file mode 100644 index 00000000..bd6b9f6b --- /dev/null +++ b/apps/client/src/features/editor/readonly-page-editor.tsx @@ -0,0 +1,67 @@ +import "@/features/editor/styles/index.css"; +import React, { useMemo } from "react"; +import { EditorProvider } from "@tiptap/react"; +import { mainExtensions } from "@/features/editor/extensions/extensions"; +import { Document } from "@tiptap/extension-document"; +import { Heading } from "@tiptap/extension-heading"; +import { Text } from "@tiptap/extension-text"; +import { Placeholder } from "@tiptap/extension-placeholder"; +import { useAtom } from "jotai/index"; +import { + pageEditorAtom, + readOnlyEditorAtom, +} from "@/features/editor/atoms/editor-atoms.ts"; +import { Editor } from "@tiptap/core"; + +interface PageEditorProps { + title: string; + content: any; +} + +export default function ReadonlyPageEditor({ + title, + content, +}: PageEditorProps) { + const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom); + + const extensions = useMemo(() => { + return [...mainExtensions]; + }, []); + + const titleExtensions = [ + Document.extend({ + content: "heading", + }), + Heading, + Text, + Placeholder.configure({ + placeholder: "Untitled", + showOnlyWhenEditable: false, + }), + ]; + + return ( + <> + + + { + if (editor) { + // @ts-ignore + setReadOnlyEditor(editor); + } + }} + > +
+ + ); +} diff --git a/apps/client/src/features/page/components/breadcrumbs/breadcrumb.module.css b/apps/client/src/features/page/components/breadcrumbs/breadcrumb.module.css index cf3637b2..cebee031 100644 --- a/apps/client/src/features/page/components/breadcrumbs/breadcrumb.module.css +++ b/apps/client/src/features/page/components/breadcrumbs/breadcrumb.module.css @@ -2,6 +2,7 @@ display: flex; align-items: center; overflow: hidden; + flex-wrap: nowrap; a { color: var(--mantine-color-default-color); diff --git a/apps/client/src/features/page/components/breadcrumbs/breadcrumb.tsx b/apps/client/src/features/page/components/breadcrumbs/breadcrumb.tsx index 367b2682..9d78f38c 100644 --- a/apps/client/src/features/page/components/breadcrumbs/breadcrumb.tsx +++ b/apps/client/src/features/page/components/breadcrumbs/breadcrumb.tsx @@ -1,6 +1,6 @@ import { useAtomValue } from "jotai"; import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts"; -import React, { useEffect, useState } from "react"; +import React, { useCallback, useEffect, useState } from "react"; import { findBreadcrumbPath } from "@/features/page/tree/utils"; import { Button, @@ -9,14 +9,16 @@ import { Breadcrumbs, ActionIcon, Text, + Tooltip, } from "@mantine/core"; -import { IconDots } from "@tabler/icons-react"; +import { IconCornerDownRightDouble, IconDots } from "@tabler/icons-react"; import { Link, useParams } from "react-router-dom"; import classes from "./breadcrumb.module.css"; import { SpaceTreeNode } from "@/features/page/tree/types.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts"; import { usePageQuery } from "@/features/page/queries/page-query.ts"; import { extractPageSlugId } from "@/lib"; +import { useMediaQuery } from "@mantine/hooks"; function getTitle(name: string, icon: string) { if (icon) { @@ -34,6 +36,7 @@ export default function Breadcrumb() { const { data: currentPage } = usePageQuery({ pageId: extractPageSlugId(pageSlug), }); + const isMobile = useMediaQuery("(max-width: 48em)"); useEffect(() => { if (treeData?.length > 0 && currentPage) { @@ -43,7 +46,7 @@ export default function Breadcrumb() { }, [currentPage?.id, treeData]); const HiddenNodesTooltipContent = () => - breadcrumbNodes?.slice(1, -2).map((node) => ( + breadcrumbNodes?.slice(1, -1).map((node) => ( + + )); + + const renderAnchor = useCallback( + (node: SpaceTreeNode) => ( + + + {getTitle(node.name, node.icon)} + + + ), + [spaceSlug], ); const getBreadcrumbItems = () => { @@ -77,7 +102,7 @@ export default function Breadcrumb() { if (breadcrumbNodes.length > 3) { const firstNode = breadcrumbNodes[0]; - const secondLastNode = breadcrumbNodes[breadcrumbNodes.length - 2]; + //const secondLastNode = breadcrumbNodes[breadcrumbNodes.length - 2]; const lastNode = breadcrumbNodes[breadcrumbNodes.length - 1]; return [ @@ -98,7 +123,7 @@ export default function Breadcrumb() { , - renderAnchor(secondLastNode), + //renderAnchor(secondLastNode), renderAnchor(lastNode), ]; } @@ -106,11 +131,40 @@ export default function Breadcrumb() { return breadcrumbNodes.map(renderAnchor); }; + const getMobileBreadcrumbItems = () => { + if (!breadcrumbNodes) return []; + + if (breadcrumbNodes.length > 0) { + return [ + + + + + + + + + + + + , + ]; + } + + return breadcrumbNodes.map(renderAnchor); + }; + return (
{breadcrumbNodes && ( - {getBreadcrumbItems()} + {isMobile ? getMobileBreadcrumbItems() : getBreadcrumbItems()} )}
diff --git a/apps/client/src/features/page/components/header/page-header-menu.tsx b/apps/client/src/features/page/components/header/page-header-menu.tsx index 93d10520..9267ad9e 100644 --- a/apps/client/src/features/page/components/header/page-header-menu.tsx +++ b/apps/client/src/features/page/components/header/page-header-menu.tsx @@ -35,6 +35,7 @@ import { import { formattedDate, timeAgo } from "@/lib/time.ts"; import MovePageModal from "@/features/page/components/move-page-modal.tsx"; import { useTimeAgo } from "@/hooks/use-time-ago.tsx"; +import ShareModal from '@/features/share/components/share-modal.tsx'; interface PageHeaderMenuProps { readOnly?: boolean; @@ -58,6 +59,8 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) { )} + + { ], }); - return `p/${titleSlug}-${pageSlugId}`; + return `${titleSlug}-${pageSlugId}`; }; export const buildPageUrl = ( @@ -17,7 +17,20 @@ export const buildPageUrl = ( pageTitle?: string, ): string => { if (spaceName === undefined) { - return `/${buildPageSlug(pageSlugId, pageTitle)}`; + return `/p/${buildPageSlug(pageSlugId, pageTitle)}`; } - return `/s/${spaceName}/${buildPageSlug(pageSlugId, pageTitle)}`; + return `/s/${spaceName}/p/${buildPageSlug(pageSlugId, pageTitle)}`; +}; + +export const buildSharedPageUrl = (opts: { + shareId: string; + pageSlugId: string; + pageTitle?: string; +}): string => { + const { shareId, pageSlugId, pageTitle } = opts; + if (!shareId) { + return `/share/p/${buildPageSlug(pageSlugId, pageTitle)}`; + } + + return `/share/${shareId}/p/${buildPageSlug(pageSlugId, pageTitle)}`; }; diff --git a/apps/client/src/features/page/tree/components/space-tree.tsx b/apps/client/src/features/page/tree/components/space-tree.tsx index 7b2f2f7d..5a00f258 100644 --- a/apps/client/src/features/page/tree/components/space-tree.tsx +++ b/apps/client/src/features/page/tree/components/space-tree.tsx @@ -8,9 +8,9 @@ import { useUpdatePageMutation, } from "@/features/page/queries/page-query.ts"; import { useEffect, useRef, useState } from "react"; -import { useNavigate, useParams } from "react-router-dom"; +import { Link, useParams } from "react-router-dom"; import classes from "@/features/page/tree/styles/tree.module.css"; -import { ActionIcon, Menu, rem } from "@mantine/core"; +import { ActionIcon, Box, Menu, rem } from "@mantine/core"; import { IconArrowRight, IconChevronDown, @@ -58,6 +58,8 @@ import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal. import { useTranslation } from "react-i18next"; import ExportModal from "@/components/common/export-modal"; import MovePageModal from "../../components/move-page-modal.tsx"; +import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; +import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts"; interface SpaceTreeProps { spaceId: string; @@ -230,13 +232,14 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) { } function Node({ node, style, dragHandle, tree }: NodeRendererProps) { - const navigate = useNavigate(); + const { t } = useTranslation(); const updatePageMutation = useUpdatePageMutation(); const [treeData, setTreeData] = useAtom(treeDataAtom); const emit = useQueryEmit(); const { spaceSlug } = useParams(); const timerRef = useRef(null); - const { t } = useTranslation(); + const [mobileSidebarOpened] = useAtom(mobileSidebarAtom); + const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom); const prefetchPage = () => { timerRef.current = setTimeout(() => { @@ -287,11 +290,6 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps) { } } - const handleClick = () => { - const pageUrl = buildPageUrl(spaceSlug, node.data.slugId, node.data.name); - navigate(pageUrl); - }; - const handleUpdateNodeIcon = (nodeId: string, newIcon: string) => { const updatedTree = updateTreeNodeIcon(treeData, nodeId, newIcon); setTreeData(updatedTree); @@ -345,13 +343,22 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps) { }, 650); } + const pageUrl = buildPageUrl(spaceSlug, node.data.slugId, node.data.name); + return ( <> -
{ + if (mobileSidebarOpened) { + toggleMobileSidebar(); + } + }} onMouseEnter={prefetchPage} onMouseLeave={cancelPagePrefetch} > @@ -385,7 +392,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps) { /> )}
-
+ ); } diff --git a/apps/client/src/features/page/tree/styles/tree.module.css b/apps/client/src/features/page/tree/styles/tree.module.css index 0a258fb5..716101e0 100644 --- a/apps/client/src/features/page/tree/styles/tree.module.css +++ b/apps/client/src/features/page/tree/styles/tree.module.css @@ -18,7 +18,7 @@ align-items: center; height: 100%; width: 93%; /* not to overlap with scroll bar */ - + text-decoration: none; color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0)); &:hover { @@ -70,6 +70,10 @@ background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5)); } +.row:focus .node:global(.isFocused) { + background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5)); +} + .row { white-space: nowrap; cursor: pointer; diff --git a/apps/client/src/features/page/tree/utils/utils.ts b/apps/client/src/features/page/tree/utils/utils.ts index 0dfe8ed4..7ae84e38 100644 --- a/apps/client/src/features/page/tree/utils/utils.ts +++ b/apps/client/src/features/page/tree/utils/utils.ts @@ -1,7 +1,7 @@ import { IPage } from "@/features/page/types/page.types.ts"; import { SpaceTreeNode } from "@/features/page/tree/types.ts"; -function sortPositionKeys(keys: any[]) { +export function sortPositionKeys(keys: any[]) { return keys.sort((a, b) => { if (a.position < b.position) return -1; if (a.position > b.position) return 1; diff --git a/apps/client/src/features/share/atoms/sidebar-atom.ts b/apps/client/src/features/share/atoms/sidebar-atom.ts new file mode 100644 index 00000000..0bc9d681 --- /dev/null +++ b/apps/client/src/features/share/atoms/sidebar-atom.ts @@ -0,0 +1,9 @@ +import { atomWithWebStorage } from "@/lib/jotai-helper.ts"; +import { atom } from 'jotai'; + +export const tableOfContentAsideAtom = atomWithWebStorage( + "showTOC", + true, +); + +export const mobileTableOfContentAsideAtom = atom(false); \ No newline at end of file diff --git a/apps/client/src/features/share/components/share-action-menu.tsx b/apps/client/src/features/share/components/share-action-menu.tsx new file mode 100644 index 00000000..398e25e9 --- /dev/null +++ b/apps/client/src/features/share/components/share-action-menu.tsx @@ -0,0 +1,106 @@ +import { Menu, ActionIcon, Text } from "@mantine/core"; +import React from "react"; +import { + IconCopy, + IconDots, + IconFileDescription, + IconTrash, +} from "@tabler/icons-react"; +import { modals } from "@mantine/modals"; +import { useTranslation } from "react-i18next"; +import { ISharedItem } from "@/features/share/types/share.types.ts"; +import { + buildPageUrl, + buildSharedPageUrl, +} from "@/features/page/page.utils.ts"; +import { useClipboard } from "@mantine/hooks"; +import { notifications } from "@mantine/notifications"; +import { useNavigate } from "react-router-dom"; +import { useDeleteShareMutation } from "@/features/share/queries/share-query.ts"; + +interface Props { + share: ISharedItem; +} +export default function ShareActionMenu({ share }: Props) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const clipboard = useClipboard(); + const deleteShareMutation = useDeleteShareMutation(); + + const openPage = () => { + const pageLink = buildPageUrl( + share.space.slug, + share.page.slugId, + share.page.title, + ); + navigate(pageLink); + }; + + const copyLink = () => { + const shareLink = buildSharedPageUrl({ + shareId: share.key, + pageTitle: share.page.title, + pageSlugId: share.page.slugId, + }); + + clipboard.copy(shareLink); + notifications.show({ message: t("Link copied") }); + }; + const onDelete = async () => { + deleteShareMutation.mutateAsync(share.key); + }; + + const openDeleteModal = () => + modals.openConfirmModal({ + title: t("Delete public share link"), + children: ( + + {t("Are you sure you want to delete this shared link?")} + + ), + centered: true, + labels: { confirm: t("Delete"), cancel: t("Don't") }, + confirmProps: { color: "red" }, + onConfirm: onDelete, + }); + + return ( + <> + + + + + + + + + }> + {t("Copy link")} + + + } + > + {t("Open page")} + + } + disabled={share.space?.userRole === "reader"} + > + {t("Delete share")} + + + + + ); +} diff --git a/apps/client/src/features/share/components/share-layout.tsx b/apps/client/src/features/share/components/share-layout.tsx new file mode 100644 index 00000000..e3b2eb17 --- /dev/null +++ b/apps/client/src/features/share/components/share-layout.tsx @@ -0,0 +1,10 @@ +import { Outlet } from "react-router-dom"; +import ShareShell from "@/features/share/components/share-shell.tsx"; + +export default function ShareLayout() { + return ( + + + + ); +} diff --git a/apps/client/src/features/share/components/share-list.tsx b/apps/client/src/features/share/components/share-list.tsx new file mode 100644 index 00000000..d5acbbd6 --- /dev/null +++ b/apps/client/src/features/share/components/share-list.tsx @@ -0,0 +1,97 @@ +import { Table, Group, Text, Anchor } from "@mantine/core"; +import React, { useState } from "react"; +import { Link } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import Paginate from "@/components/common/paginate.tsx"; +import { useGetSharesQuery } from "@/features/share/queries/share-query.ts"; +import { ISharedItem } from "@/features/share/types/share.types.ts"; +import { format } from "date-fns"; +import ShareActionMenu from "@/features/share/components/share-action-menu.tsx"; +import { buildSharedPageUrl } from "@/features/page/page.utils.ts"; +import { getPageIcon } from "@/lib"; +import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; +import classes from "./share.module.css"; + +export default function ShareList() { + const { t } = useTranslation(); + const [page, setPage] = useState(1); + const { data, isLoading } = useGetSharesQuery({ page }); + + return ( + <> + + + + + {t("Page")} + {t("Shared by")} + {t("Shared at")} + + + + + {data?.items.map((share: ISharedItem, index: number) => ( + + + + + {getPageIcon(share.page.icon)} +
+ + {share.page.title || t("untitled")} + +
+
+
+
+ + + + + {share.creator.name} + + + + + + {format(new Date(share.createdAt), "MMM dd, yyyy")} + + + + + +
+ ))} +
+
+
+ + {data?.items.length > 0 && ( + + )} + + ); +} diff --git a/apps/client/src/features/share/components/share-modal.tsx b/apps/client/src/features/share/components/share-modal.tsx new file mode 100644 index 00000000..20dcc518 --- /dev/null +++ b/apps/client/src/features/share/components/share-modal.tsx @@ -0,0 +1,227 @@ +import { + ActionIcon, + Anchor, + Button, + Group, + Indicator, + Popover, + Switch, + Text, + TextInput, + Tooltip, +} from "@mantine/core"; +import { IconExternalLink, IconWorld } from "@tabler/icons-react"; +import React, { useEffect, useMemo, useState } from "react"; +import { + useCreateShareMutation, + useDeleteShareMutation, + useShareForPageQuery, + useUpdateShareMutation, +} from "@/features/share/queries/share-query.ts"; +import { Link, useParams } from "react-router-dom"; +import { extractPageSlugId, getPageIcon } from "@/lib"; +import { useTranslation } from "react-i18next"; +import CopyTextButton from "@/components/common/copy.tsx"; +import { getAppUrl } from "@/lib/config.ts"; +import { buildPageUrl } from "@/features/page/page.utils.ts"; +import classes from "@/features/share/components/share.module.css"; + +interface ShareModalProps { + readOnly: boolean; +} +export default function ShareModal({ readOnly }: ShareModalProps) { + const { t } = useTranslation(); + const { pageSlug } = useParams(); + const pageId = extractPageSlugId(pageSlug); + const { data: share } = useShareForPageQuery(pageId); + const { spaceSlug } = useParams(); + const createShareMutation = useCreateShareMutation(); + const updateShareMutation = useUpdateShareMutation(); + const deleteShareMutation = useDeleteShareMutation(); + // pageIsShared means that the share exists and its level equals zero. + const pageIsShared = share && share.level === 0; + // if level is greater than zero, then it is a descendant page from a shared page + const isDescendantShared = share && share.level > 0; + + const publicLink = `${getAppUrl()}/share/${share?.key}/p/${pageSlug}`; + + const [isPagePublic, setIsPagePublic] = useState(false); + useEffect(() => { + if (share) { + setIsPagePublic(true); + } else { + setIsPagePublic(false); + } + }, [share, pageId]); + + const handleChange = async (event: React.ChangeEvent) => { + const value = event.currentTarget.checked; + + if (value) { + createShareMutation.mutateAsync({ + pageId: pageId, + includeSubPages: true, + searchIndexing: true, + }); + setIsPagePublic(value); + } else { + if (share && share.id) { + deleteShareMutation.mutateAsync(share.id); + setIsPagePublic(value); + } + } + }; + + const handleSubPagesChange = async ( + event: React.ChangeEvent, + ) => { + const value = event.currentTarget.checked; + updateShareMutation.mutateAsync({ + shareId: share.id, + includeSubPages: value, + }); + }; + + const handleIndexSearchChange = async ( + event: React.ChangeEvent, + ) => { + const value = event.currentTarget.checked; + updateShareMutation.mutateAsync({ + shareId: share.id, + searchIndexing: value, + }); + }; + + const shareLink = useMemo(() => ( + + } + style={{ width: "100%" }} + /> + + + + + ), [publicLink]); + + return ( + + + + + + {isDescendantShared ? ( + <> + {t("Inherits public sharing from")} + + + {getPageIcon(share.sharedPage.icon)} +
+ + {share.sharedPage.title || t("untitled")} + +
+
+
+ + {shareLink} + + ) : ( + <> + +
+ + {isPagePublic ? t("Shared to web") : t("Share to web")} + + + {isPagePublic + ? t("Anyone with the link can view this page") + : t("Make this page publicly accessible")} + +
+ +
+ + {pageIsShared && ( + <> + {shareLink} + +
+ {t("Include sub-pages")} + + {t("Make sub-pages public too")} + +
+ + +
+ +
+ {t("Search engine indexing")} + + {t("Allow search engines to index page")} + +
+ +
+ + )} + + )} +
+
+ ); +} diff --git a/apps/client/src/features/share/components/share-shell.tsx b/apps/client/src/features/share/components/share-shell.tsx new file mode 100644 index 00000000..82863b34 --- /dev/null +++ b/apps/client/src/features/share/components/share-shell.tsx @@ -0,0 +1,196 @@ +import React, { useState } from "react"; +import { + ActionIcon, + Affix, + AppShell, + Button, + Group, + ScrollArea, + Tooltip, +} from "@mantine/core"; +import { useGetSharedPageTreeQuery } from "@/features/share/queries/share-query.ts"; +import { useParams } from "react-router-dom"; +import SharedTree from "@/features/share/components/shared-tree.tsx"; +import { TableOfContents } from "@/features/editor/components/table-of-contents/table-of-contents.tsx"; +import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts"; +import { ThemeToggle } from "@/components/theme-toggle.tsx"; +import { useAtomValue } from "jotai"; +import { useAtom } from "jotai"; +import { + desktopSidebarAtom, + mobileSidebarAtom, +} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; +import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx"; +import { useTranslation } from "react-i18next"; +import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts"; +import { + mobileTableOfContentAsideAtom, + tableOfContentAsideAtom, +} from "@/features/share/atoms/sidebar-atom.ts"; +import { IconList } from "@tabler/icons-react"; +import { useToggleToc } from "@/features/share/hooks/use-toggle-toc.ts"; +import classes from "./share.module.css"; +import { useClickOutside } from "@mantine/hooks"; + +const MemoizedSharedTree = React.memo(SharedTree); + +export default function ShareShell({ + children, +}: { + children: React.ReactNode; +}) { + const { t } = useTranslation(); + const [mobileOpened] = useAtom(mobileSidebarAtom); + const [desktopOpened] = useAtom(desktopSidebarAtom); + const toggleMobile = useToggleSidebar(mobileSidebarAtom); + const toggleDesktop = useToggleSidebar(desktopSidebarAtom); + + const [tocOpened] = useAtom(tableOfContentAsideAtom); + const [mobileTocOpened] = useAtom(mobileTableOfContentAsideAtom); + const toggleTocMobile = useToggleToc(mobileTableOfContentAsideAtom); + const toggleToc = useToggleToc(tableOfContentAsideAtom); + + const { shareId } = useParams(); + const { data } = useGetSharedPageTreeQuery(shareId); + const readOnlyEditor = useAtomValue(readOnlyEditorAtom); + + const [navbarOutside, setNavbarOutside] = useState(null); + const [asideOutside, setAsideOutside] = useState(null); + + useClickOutside( + () => { + if (mobileOpened) { + toggleMobile(); + } + if (mobileTocOpened) { + toggleTocMobile(); + } + }, + null, + [navbarOutside, asideOutside], + ); + + return ( + 1 && { + navbar: { + width: 300, + breakpoint: "sm", + collapsed: { + mobile: !mobileOpened, + desktop: !desktopOpened, + }, + }, + })} + aside={{ + width: 300, + breakpoint: "sm", + collapsed: { + mobile: !mobileTocOpened, + desktop: !tocOpened, + }, + }} + padding="md" + > + + + + {data?.pageTree?.length > 1 && ( + <> + + + + + + + + + )} + + + <> + + + + + + + + + + + + + + + + + + + {data?.pageTree?.length > 1 && ( + + + + )} + + + {children} + + + + + + + + +
+ {readOnlyEditor && ( + + )} +
+
+
+
+ ); +} diff --git a/apps/client/src/features/share/components/share.module.css b/apps/client/src/features/share/components/share.module.css new file mode 100644 index 00000000..617768ff --- /dev/null +++ b/apps/client/src/features/share/components/share.module.css @@ -0,0 +1,20 @@ +.shareLinkText { + @mixin light { + border-bottom: 0.05em solid var(--mantine-color-dark-0); + } + @mixin dark { + border-bottom: 0.05em solid var(--mantine-color-dark-2); + } +} + +.treeNode { + text-decoration: none; + user-select: none; +} + +.navbar, +.aside { + @media (max-width: $mantine-breakpoint-sm) { + width: 350px; + } +} diff --git a/apps/client/src/features/share/components/shared-tree.tsx b/apps/client/src/features/share/components/shared-tree.tsx new file mode 100644 index 00000000..5e85ab57 --- /dev/null +++ b/apps/client/src/features/share/components/shared-tree.tsx @@ -0,0 +1,179 @@ +import { ISharedPageTree } from "@/features/share/types/share.types.ts"; +import { NodeApi, NodeRendererProps, Tree, TreeApi } from "react-arborist"; +import { + buildSharedPageTree, + SharedPageTreeNode, +} from "@/features/share/utils.ts"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useElementSize, useMergedRef } from "@mantine/hooks"; +import { SpaceTreeNode } from "@/features/page/tree/types.ts"; +import { Link, useParams } from "react-router-dom"; +import { atom, useAtom } from "jotai/index"; +import { useTranslation } from "react-i18next"; +import { buildSharedPageUrl } from "@/features/page/page.utils.ts"; +import clsx from "clsx"; +import { + IconChevronDown, + IconChevronRight, + IconPointFilled, +} from "@tabler/icons-react"; +import { ActionIcon, Box } from "@mantine/core"; +import { extractPageSlugId } from "@/lib"; +import { OpenMap } from "react-arborist/dist/main/state/open-slice"; +import classes from "@/features/page/tree/styles/tree.module.css"; +import styles from "./share.module.css"; +import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; + +interface SharedTree { + sharedPageTree: ISharedPageTree; +} + +const openSharedTreeNodesAtom = atom({}); + +export default function SharedTree({ sharedPageTree }: SharedTree) { + const [tree, setTree] = useState< + TreeApi | null | undefined + >(null); + const rootElement = useRef(); + const { ref: sizeRef, width, height } = useElementSize(); + const mergedRef = useMergedRef(rootElement, sizeRef); + const { pageSlug } = useParams(); + const [openTreeNodes, setOpenTreeNodes] = useAtom( + openSharedTreeNodesAtom, + ); + + const currentNodeId = extractPageSlugId(pageSlug); + + const treeData: SharedPageTreeNode[] = useMemo(() => { + if (!sharedPageTree?.pageTree) return; + return buildSharedPageTree(sharedPageTree.pageTree); + }, [sharedPageTree?.pageTree]); + + useEffect(() => { + const parentNodeId = treeData?.[0]?.slugId; + + if (parentNodeId && tree) { + const parentNode = tree.get(parentNodeId); + + setTimeout(() => { + if (parentNode) { + tree.openSiblings(parentNode); + } + }); + + // open direct children of parent node + parentNode?.children.forEach((node) => { + tree.openSiblings(node); + }); + } + }, [treeData, tree]); + + useEffect(() => { + if (currentNodeId && tree) { + setTimeout(() => { + // focus on node and open all parents + tree?.select(currentNodeId, { align: "auto" }); + }, 200); + } else { + tree?.deselectAll(); + } + }, [currentNodeId, tree]); + + if (!sharedPageTree || !sharedPageTree?.pageTree) { + return null; + } + + return ( +
+ {rootElement.current && ( + setTree(t)} + openByDefault={false} + disableMultiSelection={true} + className={classes.tree} + rowClassName={classes.row} + rowHeight={30} + overscanCount={10} + dndRootElement={rootElement.current} + onToggle={() => { + setOpenTreeNodes(tree?.openState); + }} + initialOpenState={openTreeNodes} + onClick={(e) => { + if (tree && tree.focusedNode) { + tree.select(tree.focusedNode); + } + }} + > + {Node} + + )} +
+ ); +} + +function Node({ node, style, tree }: NodeRendererProps) { + const { shareId } = useParams(); + const { t } = useTranslation(); + const [, setMobileSidebarState] = useAtom(mobileSidebarAtom); + + const pageUrl = buildSharedPageUrl({ + shareId: shareId, + pageSlugId: node.data.slugId, + pageTitle: node.data.name, + }); + + return ( + <> + { + setMobileSidebarState(false); + }} + > + + {node.data.name || t("untitled")} + + + ); +} + +interface PageArrowProps { + node: NodeApi; +} + +function PageArrow({ node }: PageArrowProps) { + return ( + { + e.preventDefault(); + e.stopPropagation(); + node.toggle(); + }} + > + {node.isInternal ? ( + node.children && (node.children.length > 0 || node.data.hasChildren) ? ( + node.isOpen ? ( + + ) : ( + + ) + ) : ( + + ) + ) : null} + + ); +} diff --git a/apps/client/src/features/share/hooks/use-toggle-toc.ts b/apps/client/src/features/share/hooks/use-toggle-toc.ts new file mode 100644 index 00000000..ec43086a --- /dev/null +++ b/apps/client/src/features/share/hooks/use-toggle-toc.ts @@ -0,0 +1,8 @@ +import { useAtom } from "jotai"; + +export function useToggleToc(tocAtom: any) { + const [tocState, setTocState] = useAtom(tocAtom); + return () => { + setTocState(!tocState); + } +} diff --git a/apps/client/src/features/share/queries/share-query.ts b/apps/client/src/features/share/queries/share-query.ts new file mode 100644 index 00000000..dea047bf --- /dev/null +++ b/apps/client/src/features/share/queries/share-query.ts @@ -0,0 +1,179 @@ +import { + keepPreviousData, + useMutation, + useQuery, + useQueryClient, + UseQueryResult, +} from "@tanstack/react-query"; +import { notifications } from "@mantine/notifications"; +import { useTranslation } from "react-i18next"; +import { + ICreateShare, + IShare, + ISharedItem, + ISharedPage, + ISharedPageTree, + IShareForPage, + IShareInfoInput, + IUpdateShare, +} from "@/features/share/types/share.types.ts"; +import { + createShare, + deleteShare, + getSharedPageTree, + getShareForPage, + getShareInfo, + getSharePageInfo, + getShares, + updateShare, +} from "@/features/share/services/share-service.ts"; +import { IPage } from "@/features/page/types/page.types.ts"; +import { IPagination, QueryParams } from "@/lib/types.ts"; +import { useEffect } from "react"; + +export function useGetSharesQuery( + params?: QueryParams, +): UseQueryResult, Error> { + return useQuery({ + queryKey: ["share-list"], + queryFn: () => getShares(params), + placeholderData: keepPreviousData, + }); +} + +export function useGetShareByIdQuery( + shareId: string, +): UseQueryResult { + const query = useQuery({ + queryKey: ["share-by-id", shareId], + queryFn: () => getShareInfo(shareId), + enabled: !!shareId, + }); + + return query; +} + +export function useSharePageQuery( + shareInput: Partial, +): UseQueryResult { + const query = useQuery({ + queryKey: ["shares", shareInput], + queryFn: () => getSharePageInfo(shareInput), + enabled: !!shareInput.pageId, + }); + + return query; +} + +export function useShareForPageQuery( + pageId: string, +): UseQueryResult { + const query = useQuery({ + queryKey: ["share-for-page", pageId], + queryFn: () => getShareForPage(pageId), + enabled: !!pageId, + staleTime: 0, + retry: false, + }); + + return query; +} + +export function useCreateShareMutation() { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data) => createShare(data), + onSuccess: (data) => { + queryClient.invalidateQueries({ + predicate: (item) => + ["share-for-page", "share-list"].includes(item.queryKey[0] as string), + }); + }, + onError: (error) => { + notifications.show({ message: t("Failed to share page"), color: "red" }); + }, + }); +} + +export function useUpdateShareMutation() { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data) => updateShare(data), + onSuccess: (data) => { + queryClient.invalidateQueries({ + predicate: (item) => + ["share-for-page", "share-list"].includes(item.queryKey[0] as string), + }); + }, + onError: (error, params) => { + if (error?.["status"] === 404) { + queryClient.removeQueries({ + predicate: (item) => + ["share-for-page"].includes(item.queryKey[0] as string), + }); + + notifications.show({ + message: t("Share not found"), + color: "red", + }); + return; + } + + notifications.show({ + message: error?.["response"]?.data?.message || "Share not found", + color: "red", + }); + }, + }); +} + +export function useDeleteShareMutation() { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (shareId: string) => deleteShare(shareId), + onSuccess: (data) => { + queryClient.removeQueries({ + predicate: (item) => + ["share-for-page"].includes(item.queryKey[0] as string), + }); + + queryClient.invalidateQueries({ + predicate: (item) => + ["share-list"].includes(item.queryKey[0] as string), + }); + + notifications.show({ message: t("Share deleted successfully") }); + }, + onError: (error) => { + if (error?.["status"] === 404) { + queryClient.removeQueries({ + predicate: (item) => + ["share-for-page"].includes(item.queryKey[0] as string), + }); + } + + notifications.show({ + message: error?.["response"]?.data?.message || "Failed to delete share", + color: "red", + }); + }, + }); +} + +export function useGetSharedPageTreeQuery( + shareId: string, +): UseQueryResult { + return useQuery({ + queryKey: ["shared-page-tree", shareId], + queryFn: () => getSharedPageTree(shareId), + enabled: !!shareId, + placeholderData: keepPreviousData, + staleTime: 60 * 60 * 1000, + }); +} diff --git a/apps/client/src/features/share/services/share-service.ts b/apps/client/src/features/share/services/share-service.ts new file mode 100644 index 00000000..2f43ba20 --- /dev/null +++ b/apps/client/src/features/share/services/share-service.ts @@ -0,0 +1,59 @@ +import api from "@/lib/api-client"; +import { IPage } from "@/features/page/types/page.types"; + +import { + ICreateShare, + IShare, + ISharedItem, + ISharedPage, + ISharedPageTree, + IShareForPage, + IShareInfoInput, + IUpdateShare, +} from "@/features/share/types/share.types.ts"; +import { IPagination, QueryParams } from "@/lib/types.ts"; + +export async function getShares( + params?: QueryParams, +): Promise> { + const req = await api.post("/shares", params); + return req.data; +} + +export async function createShare(data: ICreateShare): Promise { + const req = await api.post("/shares/create", data); + return req.data; +} + +export async function getShareInfo(shareId: string): Promise { + const req = await api.post("/shares/info", { shareId }); + return req.data; +} + +export async function updateShare(data: IUpdateShare): Promise { + const req = await api.post("/shares/update", data); + return req.data; +} + +export async function getShareForPage(pageId: string): Promise { + const req = await api.post("/shares/for-page", { pageId }); + return req.data; +} + +export async function getSharePageInfo( + shareInput: Partial, +): Promise { + const req = await api.post("/shares/page-info", shareInput); + return req.data; +} + +export async function deleteShare(shareId: string): Promise { + await api.post("/shares/delete", { shareId }); +} + +export async function getSharedPageTree( + shareId: string, +): Promise { + const req = await api.post("/shares/tree", { shareId }); + return req.data; +} diff --git a/apps/client/src/features/share/types/share.types.ts b/apps/client/src/features/share/types/share.types.ts new file mode 100644 index 00000000..c40801e8 --- /dev/null +++ b/apps/client/src/features/share/types/share.types.ts @@ -0,0 +1,73 @@ +import { IPage } from "@/features/page/types/page.types.ts"; + +export interface IShare { + id: string; + key: string; + pageId: string; + includeSubPages: boolean; + searchIndexing: boolean; + creatorId: string; + spaceId: string; + workspaceId: string; + createdAt: string; + updatedAt: string; + deletedAt: string | null; + sharedPage?: ISharePage; +} + +export interface ISharedItem extends IShare { + page: { + id: string; + title: string; + slugId: string; + icon: string | null; + }; + space: { + id: string; + name: string; + slug: string; + userRole: string; + }; + creator: { + id: string; + name: string; + avatarUrl: string | null; + }; +} + +export interface ISharedPage extends IShare { + page: IPage; + share: IShare & { + level: number; + sharedPage: { id: string; slugId: string; title: string; icon: string }; + }; +} + +export interface IShareForPage extends IShare { + level: number; + sharedPage: ISharePage; +} + +interface ISharePage { + id: string; + slugId: string; + title: string; + icon: string; +} + +export interface ICreateShare { + pageId?: string; + includeSubPages?: boolean; + searchIndexing?: boolean; +} + +export type IUpdateShare = ICreateShare & { shareId: string; pageId?: string }; + +export interface IShareInfoInput { + pageId: string; +} + +export interface ISharedPageTree { + share: IShare; + pageTree: Partial; +} diff --git a/apps/client/src/features/share/utils.ts b/apps/client/src/features/share/utils.ts new file mode 100644 index 00000000..74ec349f --- /dev/null +++ b/apps/client/src/features/share/utils.ts @@ -0,0 +1,60 @@ +import { IPage } from "@/features/page/types/page.types.ts"; +import { sortPositionKeys } from "@/features/page/tree/utils"; + +export type SharedPageTreeNode = { + id: string; + slugId: string; + name: string; + icon?: string; + position: string; + spaceId: string; + parentPageId: string; + hasChildren: boolean; + children: SharedPageTreeNode[]; + label: string, + value: string, +}; + +export function buildSharedPageTree(pages: Partial): SharedPageTreeNode[] { + const pageMap: Record = {}; + + // Initialize each page as a tree node and store it in a map. + pages.forEach((page) => { + pageMap[page.id] = { + id: page.slugId, + slugId: page.slugId, + name: page.title, + icon: page.icon, + position: page.position, + // Initially assume a page has no children. + hasChildren: false, + spaceId: page.spaceId, + parentPageId: page.parentPageId, + label: page.title || 'untitled', + value: page.id, + children: [], + }; + }); + + // Build the tree structure. + const tree: SharedPageTreeNode[] = []; + pages.forEach((page) => { + if (page.parentPageId) { + // If the page has a parent, add it as a child of the parent node. + const parentNode = pageMap[page.parentPageId]; + if (parentNode) { + parentNode.children.push(pageMap[page.id]); + parentNode.hasChildren = true; + } else { + // Parent not found – treat this page as a top-level node. + tree.push(pageMap[page.id]); + } + } else { + // No parentPageId indicates a top-level page. + tree.push(pageMap[page.id]); + } + }); + + // Return the sorted tree. + return sortPositionKeys(tree); +} diff --git a/apps/client/src/features/space/components/sidebar/space-sidebar.tsx b/apps/client/src/features/space/components/sidebar/space-sidebar.tsx index 1df7a2c1..528e8051 100644 --- a/apps/client/src/features/space/components/sidebar/space-sidebar.tsx +++ b/apps/client/src/features/space/components/sidebar/space-sidebar.tsx @@ -38,6 +38,8 @@ import PageImportModal from "@/features/page/components/page-import-modal.tsx"; import { useTranslation } from "react-i18next"; import { SwitchSpace } from "./switch-space"; import ExportModal from "@/components/common/export-modal"; +import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; +import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts"; export function SpaceSidebar() { const { t } = useTranslation(); @@ -45,6 +47,9 @@ export function SpaceSidebar() { const location = useLocation(); const [opened, { open: openSettings, close: closeSettings }] = useDisclosure(false); + const [mobileSidebarOpened] = useAtom(mobileSidebarAtom); + const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom); + const { spaceSlug } = useParams(); const { data: space, isLoading, isError } = useGetSpaceBySlugQuery(spaceSlug); @@ -123,7 +128,12 @@ export function SpaceSidebar() { ) && ( { + handleCreatePage(); + if (mobileSidebarOpened) { + toggleMobileSidebar(); + } + }} >
+ + + {t("Public sharing")} - {getAppName()} + + + + + }> + {t( + "Publicly shared pages from spaces you are a member of will appear here", + )} + + + + + ); +} diff --git a/apps/client/src/pages/share/share-redirect.tsx b/apps/client/src/pages/share/share-redirect.tsx new file mode 100644 index 00000000..5653e83f --- /dev/null +++ b/apps/client/src/pages/share/share-redirect.tsx @@ -0,0 +1,35 @@ +import { useNavigate, useParams } from "react-router-dom"; +import { useEffect } from "react"; +import { buildSharedPageUrl } from "@/features/page/page.utils.ts"; +import { Error404 } from "@/components/ui/error-404.tsx"; +import { useGetShareByIdQuery } from "@/features/share/queries/share-query.ts"; + +export default function ShareRedirect() { + const { shareId } = useParams(); + const navigate = useNavigate(); + + const { data: share, isLoading, isError } = useGetShareByIdQuery(shareId); + + useEffect(() => { + if (share) { + navigate( + buildSharedPageUrl({ + shareId: share.key, + pageSlugId: share?.sharedPage.slugId, + pageTitle: share?.sharedPage.title, + }), + { replace: true }, + ); + } + }, [isLoading, share]); + + if (isError) { + return ; + } + + if (isLoading) { + return <>; + } + + return null; +} diff --git a/apps/client/src/pages/share/shared-page.tsx b/apps/client/src/pages/share/shared-page.tsx new file mode 100644 index 00000000..a574a614 --- /dev/null +++ b/apps/client/src/pages/share/shared-page.tsx @@ -0,0 +1,58 @@ +import { useNavigate, useParams } from "react-router-dom"; +import { Helmet } from "react-helmet-async"; +import { useTranslation } from "react-i18next"; +import { useSharePageQuery } from "@/features/share/queries/share-query.ts"; +import { Container } from "@mantine/core"; +import React, { useEffect } from "react"; +import ReadonlyPageEditor from "@/features/editor/readonly-page-editor.tsx"; +import { extractPageSlugId } from "@/lib"; +import { Error404 } from "@/components/ui/error-404.tsx"; + +export default function SingleSharedPage() { + const { t } = useTranslation(); + const { pageSlug } = useParams(); + const { shareId } = useParams(); + const navigate = useNavigate(); + + const { data, isLoading, isError, error } = useSharePageQuery({ + pageId: extractPageSlugId(pageSlug), + }); + + useEffect(() => { + if (shareId && data) { + if (data.share.key !== shareId) { + navigate(`/share/${data.share.key}/p/${pageSlug}`, { replace: true }); + } + } + }, [shareId, data]); + + if (isLoading) { + return <>; + } + + if (isError || !data) { + if ([401, 403, 404].includes(error?.["status"])) { + return ; + } + return
{t("Error fetching page data.")}
; + } + + return ( +
+ + {`${data?.page?.title || t("untitled")}`} + {!data?.share.searchIndexing && ( + + )} + + + + + +
+ ); +} diff --git a/apps/server/package.json b/apps/server/package.json index fced5e33..efd8d8ca 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -37,18 +37,18 @@ "@fastify/multipart": "^9.0.3", "@fastify/static": "^8.1.1", "@nestjs/bullmq": "^11.0.2", - "@nestjs/common": "^11.0.10", - "@nestjs/config": "^4.0.0", - "@nestjs/core": "^11.0.10", + "@nestjs/common": "^11.0.20", + "@nestjs/config": "^4.0.2", + "@nestjs/core": "^11.0.20", "@nestjs/event-emitter": "^3.0.0", "@nestjs/jwt": "^11.0.0", "@nestjs/mapped-types": "^2.1.0", "@nestjs/passport": "^11.0.5", - "@nestjs/platform-fastify": "^11.0.10", - "@nestjs/platform-socket.io": "^11.0.10", + "@nestjs/platform-fastify": "^11.0.20", + "@nestjs/platform-socket.io": "^11.0.20", "@nestjs/schedule": "^5.0.1", "@nestjs/terminus": "^11.0.0", - "@nestjs/websockets": "^11.0.10", + "@nestjs/websockets": "^11.0.20", "@node-saml/passport-saml": "^5.0.1", "@react-email/components": "0.0.28", "@react-email/render": "1.0.2", diff --git a/apps/server/src/common/helpers/prosemirror/utils.ts b/apps/server/src/common/helpers/prosemirror/utils.ts index 9d9b5ebe..aaadcd56 100644 --- a/apps/server/src/common/helpers/prosemirror/utils.ts +++ b/apps/server/src/common/helpers/prosemirror/utils.ts @@ -1,5 +1,6 @@ import { Node } from '@tiptap/pm/model'; import { jsonToNode } from '../../../collaboration/collaboration.util'; +import { validate as isValidUUID } from 'uuid'; export interface MentionNode { id: string; @@ -56,3 +57,41 @@ export function extractPageMentions(mentionList: MentionNode[]): MentionNode[] { } return pageMentionList as MentionNode[]; } + + +export function getProsemirrorContent(content: any) { + return ( + content ?? { + type: 'doc', + content: [{ type: 'paragraph', attrs: { textAlign: 'left' } }], + } + ); +} + +export function isAttachmentNode(nodeType: string) { + const attachmentNodeTypes = [ + 'attachment', + 'image', + 'video', + 'excalidraw', + 'drawio', + ]; + return attachmentNodeTypes.includes(nodeType); +} + +export function getAttachmentIds(prosemirrorJson: any) { + const doc = jsonToNode(prosemirrorJson); + const attachmentIds = []; + + doc?.descendants((node: Node) => { + if (isAttachmentNode(node.type.name)) { + if (node.attrs.attachmentId && isValidUUID(node.attrs.attachmentId)) { + if (!attachmentIds.includes(node.attrs.attachmentId)) { + attachmentIds.push(node.attrs.attachmentId); + } + } + } + }); + + return attachmentIds; +} \ No newline at end of file diff --git a/apps/server/src/core/attachment/attachment.controller.ts b/apps/server/src/core/attachment/attachment.controller.ts index 4804fce6..160d950b 100644 --- a/apps/server/src/core/attachment/attachment.controller.ts +++ b/apps/server/src/core/attachment/attachment.controller.ts @@ -1,310 +1,373 @@ import { - BadRequestException, - Controller, - ForbiddenException, - Get, - HttpCode, - HttpStatus, - Logger, - NotFoundException, - Param, - Post, - Req, - Res, - UseGuards, - UseInterceptors, + BadRequestException, + Controller, + ForbiddenException, + Get, + HttpCode, + HttpStatus, + Logger, + NotFoundException, + Param, + Post, + Query, + Req, + Res, + UseGuards, + UseInterceptors, } from '@nestjs/common'; -import {AttachmentService} from './services/attachment.service'; -import {FastifyReply} from 'fastify'; -import {FileInterceptor} from '../../common/interceptors/file.interceptor'; +import { AttachmentService } from './services/attachment.service'; +import { FastifyReply } from 'fastify'; +import { FileInterceptor } from '../../common/interceptors/file.interceptor'; import * as bytes from 'bytes'; -import {AuthUser} from '../../common/decorators/auth-user.decorator'; -import {AuthWorkspace} from '../../common/decorators/auth-workspace.decorator'; -import {JwtAuthGuard} from '../../common/guards/jwt-auth.guard'; -import {User, Workspace} from '@docmost/db/types/entity.types'; -import {StorageService} from '../../integrations/storage/storage.service'; +import { AuthUser } from '../../common/decorators/auth-user.decorator'; +import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { User, Workspace } from '@docmost/db/types/entity.types'; +import { StorageService } from '../../integrations/storage/storage.service'; import { - getAttachmentFolderPath, - validAttachmentTypes, + getAttachmentFolderPath, + validAttachmentTypes, } from './attachment.utils'; -import {getMimeType} from '../../common/helpers'; +import { getMimeType } from '../../common/helpers'; import { - AttachmentType, - inlineFileExtensions, - MAX_AVATAR_SIZE, + AttachmentType, + inlineFileExtensions, + MAX_AVATAR_SIZE, } from './attachment.constants'; import { - SpaceCaslAction, - SpaceCaslSubject, + SpaceCaslAction, + SpaceCaslSubject, } from '../casl/interfaces/space-ability.type'; import SpaceAbilityFactory from '../casl/abilities/space-ability.factory'; import { - WorkspaceCaslAction, - WorkspaceCaslSubject, + WorkspaceCaslAction, + WorkspaceCaslSubject, } from '../casl/interfaces/workspace-ability.type'; import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory'; -import {PageRepo} from '@docmost/db/repos/page/page.repo'; -import {AttachmentRepo} from '@docmost/db/repos/attachment/attachment.repo'; -import {validate as isValidUUID} from 'uuid'; -import {EnvironmentService} from "../../integrations/environment/environment.service"; +import { PageRepo } from '@docmost/db/repos/page/page.repo'; +import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo'; +import { validate as isValidUUID } from 'uuid'; +import { EnvironmentService } from '../../integrations/environment/environment.service'; +import { TokenService } from '../auth/services/token.service'; +import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload'; @Controller() export class AttachmentController { - private readonly logger = new Logger(AttachmentController.name); + private readonly logger = new Logger(AttachmentController.name); - constructor( - private readonly attachmentService: AttachmentService, - private readonly storageService: StorageService, - private readonly workspaceAbility: WorkspaceAbilityFactory, - private readonly spaceAbility: SpaceAbilityFactory, - private readonly pageRepo: PageRepo, - private readonly attachmentRepo: AttachmentRepo, - private readonly environmentService: EnvironmentService, - ) { - } + constructor( + private readonly attachmentService: AttachmentService, + private readonly storageService: StorageService, + private readonly workspaceAbility: WorkspaceAbilityFactory, + private readonly spaceAbility: SpaceAbilityFactory, + private readonly pageRepo: PageRepo, + private readonly attachmentRepo: AttachmentRepo, + private readonly environmentService: EnvironmentService, + private readonly tokenService: TokenService, + ) {} - @UseGuards(JwtAuthGuard) - @HttpCode(HttpStatus.OK) - @Post('files/upload') - @UseInterceptors(FileInterceptor) - async uploadFile( - @Req() req: any, - @Res() res: FastifyReply, - @AuthUser() user: User, - @AuthWorkspace() workspace: Workspace, - ) { - const maxFileSize = bytes(this.environmentService.getFileUploadSizeLimit()); + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @Post('files/upload') + @UseInterceptors(FileInterceptor) + async uploadFile( + @Req() req: any, + @Res() res: FastifyReply, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + const maxFileSize = bytes(this.environmentService.getFileUploadSizeLimit()); - let file = null; - try { - file = await req.file({ - limits: {fileSize: maxFileSize, fields: 3, files: 1}, - }); - } catch (err: any) { - this.logger.error(err.message); - if (err?.statusCode === 413) { - throw new BadRequestException( - `File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`, - ); - } - } - - if (!file) { - throw new BadRequestException('Failed to upload file'); - } - - const pageId = file.fields?.pageId?.value; - - if (!pageId) { - throw new BadRequestException('PageId is required'); - } - - const page = await this.pageRepo.findById(pageId); - - if (!page) { - throw new NotFoundException('Page not found'); - } - - const spaceAbility = await this.spaceAbility.createForUser( - user, - page.spaceId, + let file = null; + try { + file = await req.file({ + limits: { fileSize: maxFileSize, fields: 3, files: 1 }, + }); + } catch (err: any) { + this.logger.error(err.message); + if (err?.statusCode === 413) { + throw new BadRequestException( + `File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`, ); - if (spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) { - throw new ForbiddenException(); - } - - const spaceId = page.spaceId; - - const attachmentId = file.fields?.attachmentId?.value; - if (attachmentId && !isValidUUID(attachmentId)) { - throw new BadRequestException('Invalid attachment id'); - } - - try { - const fileResponse = await this.attachmentService.uploadFile({ - filePromise: file, - pageId: pageId, - spaceId: spaceId, - userId: user.id, - workspaceId: workspace.id, - attachmentId: attachmentId, - }); - - return res.send(fileResponse); - } catch (err: any) { - if (err?.statusCode === 413) { - const errMessage = `File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`; - this.logger.error(errMessage); - throw new BadRequestException(errMessage); - } - this.logger.error(err); - throw new BadRequestException('Error processing file upload.'); - } + } } - @UseGuards(JwtAuthGuard) - @Get('/files/:fileId/:fileName') - async getFile( - @Res() res: FastifyReply, - @AuthUser() user: User, - @AuthWorkspace() workspace: Workspace, - @Param('fileId') fileId: string, - @Param('fileName') fileName?: string, + if (!file) { + throw new BadRequestException('Failed to upload file'); + } + + const pageId = file.fields?.pageId?.value; + + if (!pageId) { + throw new BadRequestException('PageId is required'); + } + + const page = await this.pageRepo.findById(pageId); + + if (!page) { + throw new NotFoundException('Page not found'); + } + + const spaceAbility = await this.spaceAbility.createForUser( + user, + page.spaceId, + ); + if (spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) { + throw new ForbiddenException(); + } + + const spaceId = page.spaceId; + + const attachmentId = file.fields?.attachmentId?.value; + if (attachmentId && !isValidUUID(attachmentId)) { + throw new BadRequestException('Invalid attachment id'); + } + + try { + const fileResponse = await this.attachmentService.uploadFile({ + filePromise: file, + pageId: pageId, + spaceId: spaceId, + userId: user.id, + workspaceId: workspace.id, + attachmentId: attachmentId, + }); + + return res.send(fileResponse); + } catch (err: any) { + if (err?.statusCode === 413) { + const errMessage = `File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`; + this.logger.error(errMessage); + throw new BadRequestException(errMessage); + } + this.logger.error(err); + throw new BadRequestException('Error processing file upload.'); + } + } + + @UseGuards(JwtAuthGuard) + @Get('/files/:fileId/:fileName') + async getFile( + @Res() res: FastifyReply, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + @Param('fileId') fileId: string, + @Param('fileName') fileName?: string, + ) { + if (!isValidUUID(fileId)) { + throw new NotFoundException('Invalid file id'); + } + + const attachment = await this.attachmentRepo.findById(fileId); + if ( + !attachment || + attachment.workspaceId !== workspace.id || + !attachment.pageId || + !attachment.spaceId ) { - if (!isValidUUID(fileId)) { - throw new NotFoundException('Invalid file id'); - } + throw new NotFoundException(); + } - const attachment = await this.attachmentRepo.findById(fileId); - if ( - !attachment || - attachment.workspaceId !== workspace.id || - !attachment.pageId || - !attachment.spaceId - ) { - throw new NotFoundException(); - } + const spaceAbility = await this.spaceAbility.createForUser( + user, + attachment.spaceId, + ); - const spaceAbility = await this.spaceAbility.createForUser( - user, - attachment.spaceId, + if (spaceAbility.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) { + throw new ForbiddenException(); + } + + try { + const fileStream = await this.storageService.read(attachment.filePath); + res.headers({ + 'Content-Type': attachment.mimeType, + 'Cache-Control': 'private, max-age=3600', + }); + + if (!inlineFileExtensions.includes(attachment.fileExt)) { + res.header( + 'Content-Disposition', + `attachment; filename="${encodeURIComponent(attachment.fileName)}"`, ); + } - if (spaceAbility.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) { - throw new ForbiddenException(); - } + return res.send(fileStream); + } catch (err) { + this.logger.error(err); + throw new NotFoundException('File not found'); + } + } - try { - const fileStream = await this.storageService.read(attachment.filePath); - res.headers({ - 'Content-Type': attachment.mimeType, - 'Cache-Control': 'private, max-age=3600', - }); - - if (!inlineFileExtensions.includes(attachment.fileExt)) { - res.header( - 'Content-Disposition', - `attachment; filename="${encodeURIComponent(attachment.fileName)}"`, - ); - } - - return res.send(fileStream); - } catch (err) { - this.logger.error(err); - throw new NotFoundException('File not found'); - } + @Get('/files/public/:fileId/:fileName') + async getPublicFile( + @Res() res: FastifyReply, + @AuthWorkspace() workspace: Workspace, + @Param('fileId') fileId: string, + @Param('fileName') fileName?: string, + @Query('jwt') jwtToken?: string, + ) { + let jwtPayload: JwtAttachmentPayload = null; + try { + jwtPayload = await this.tokenService.verifyJwt( + jwtToken, + JwtType.ATTACHMENT, + ); + } catch (err) { + throw new BadRequestException( + 'Expired or invalid attachment access token', + ); } - @UseGuards(JwtAuthGuard) - @HttpCode(HttpStatus.OK) - @Post('attachments/upload-image') - @UseInterceptors(FileInterceptor) - async uploadAvatarOrLogo( - @Req() req: any, - @Res() res: FastifyReply, - @AuthUser() user: User, - @AuthWorkspace() workspace: Workspace, + if ( + !isValidUUID(fileId) || + fileId !== jwtPayload.attachmentId || + jwtPayload.workspaceId !== workspace.id ) { - const maxFileSize = bytes(MAX_AVATAR_SIZE); - - let file = null; - try { - file = await req.file({ - limits: {fileSize: maxFileSize, fields: 3, files: 1}, - }); - } catch (err: any) { - if (err?.statusCode === 413) { - throw new BadRequestException( - `File too large. Exceeds the ${MAX_AVATAR_SIZE} limit`, - ); - } - } - - if (!file) { - throw new BadRequestException('Invalid file upload'); - } - - const attachmentType = file.fields?.type?.value; - const spaceId = file.fields?.spaceId?.value; - - if (!attachmentType) { - throw new BadRequestException('attachment type is required'); - } - - if ( - !validAttachmentTypes.includes(attachmentType) || - attachmentType === AttachmentType.File - ) { - throw new BadRequestException('Invalid image attachment type'); - } - - if (attachmentType === AttachmentType.WorkspaceLogo) { - const ability = this.workspaceAbility.createForUser(user, workspace); - if ( - ability.cannot( - WorkspaceCaslAction.Manage, - WorkspaceCaslSubject.Settings, - ) - ) { - throw new ForbiddenException(); - } - } - - if (attachmentType === AttachmentType.SpaceLogo) { - if (!spaceId) { - throw new BadRequestException('spaceId is required'); - } - - const spaceAbility = await this.spaceAbility.createForUser(user, spaceId); - if ( - spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings) - ) { - throw new ForbiddenException(); - } - } - - try { - const fileResponse = await this.attachmentService.uploadImage( - file, - attachmentType, - user.id, - workspace.id, - spaceId, - ); - - return res.send(fileResponse); - } catch (err: any) { - this.logger.error(err); - throw new BadRequestException('Error processing file upload.'); - } + throw new NotFoundException('File not found'); } - @Get('attachments/img/:attachmentType/:fileName') - async getLogoOrAvatar( - @Res() res: FastifyReply, - @AuthWorkspace() workspace: Workspace, - @Param('attachmentType') attachmentType: AttachmentType, - @Param('fileName') fileName?: string, + const attachment = await this.attachmentRepo.findById(fileId); + if ( + !attachment || + attachment.workspaceId !== workspace.id || + !attachment.pageId || + !attachment.spaceId || + jwtPayload.pageId !== attachment.pageId ) { - if ( - !validAttachmentTypes.includes(attachmentType) || - attachmentType === AttachmentType.File - ) { - throw new BadRequestException('Invalid image attachment type'); - } - - const filePath = `${getAttachmentFolderPath(attachmentType, workspace.id)}/${fileName}`; - - try { - const fileStream = await this.storageService.read(filePath); - res.headers({ - 'Content-Type': getMimeType(filePath), - 'Cache-Control': 'private, max-age=86400', - }); - return res.send(fileStream); - } catch (err) { - this.logger.error(err); - throw new NotFoundException('File not found'); - } + throw new NotFoundException('File not found'); } + + try { + const fileStream = await this.storageService.read(attachment.filePath); + res.headers({ + 'Content-Type': attachment.mimeType, + 'Cache-Control': 'public, max-age=3600', + }); + + if (!inlineFileExtensions.includes(attachment.fileExt)) { + res.header( + 'Content-Disposition', + `attachment; filename="${encodeURIComponent(attachment.fileName)}"`, + ); + } + + return res.send(fileStream); + } catch (err) { + this.logger.error(err); + throw new NotFoundException('File not found'); + } + } + + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @Post('attachments/upload-image') + @UseInterceptors(FileInterceptor) + async uploadAvatarOrLogo( + @Req() req: any, + @Res() res: FastifyReply, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + const maxFileSize = bytes(MAX_AVATAR_SIZE); + + let file = null; + try { + file = await req.file({ + limits: { fileSize: maxFileSize, fields: 3, files: 1 }, + }); + } catch (err: any) { + if (err?.statusCode === 413) { + throw new BadRequestException( + `File too large. Exceeds the ${MAX_AVATAR_SIZE} limit`, + ); + } + } + + if (!file) { + throw new BadRequestException('Invalid file upload'); + } + + const attachmentType = file.fields?.type?.value; + const spaceId = file.fields?.spaceId?.value; + + if (!attachmentType) { + throw new BadRequestException('attachment type is required'); + } + + if ( + !validAttachmentTypes.includes(attachmentType) || + attachmentType === AttachmentType.File + ) { + throw new BadRequestException('Invalid image attachment type'); + } + + if (attachmentType === AttachmentType.WorkspaceLogo) { + const ability = this.workspaceAbility.createForUser(user, workspace); + if ( + ability.cannot( + WorkspaceCaslAction.Manage, + WorkspaceCaslSubject.Settings, + ) + ) { + throw new ForbiddenException(); + } + } + + if (attachmentType === AttachmentType.SpaceLogo) { + if (!spaceId) { + throw new BadRequestException('spaceId is required'); + } + + const spaceAbility = await this.spaceAbility.createForUser(user, spaceId); + if ( + spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings) + ) { + throw new ForbiddenException(); + } + } + + try { + const fileResponse = await this.attachmentService.uploadImage( + file, + attachmentType, + user.id, + workspace.id, + spaceId, + ); + + return res.send(fileResponse); + } catch (err: any) { + this.logger.error(err); + throw new BadRequestException('Error processing file upload.'); + } + } + + @Get('attachments/img/:attachmentType/:fileName') + async getLogoOrAvatar( + @Res() res: FastifyReply, + @AuthWorkspace() workspace: Workspace, + @Param('attachmentType') attachmentType: AttachmentType, + @Param('fileName') fileName?: string, + ) { + if ( + !validAttachmentTypes.includes(attachmentType) || + attachmentType === AttachmentType.File + ) { + throw new BadRequestException('Invalid image attachment type'); + } + + const filePath = `${getAttachmentFolderPath(attachmentType, workspace.id)}/${fileName}`; + + try { + const fileStream = await this.storageService.read(filePath); + res.headers({ + 'Content-Type': getMimeType(filePath), + 'Cache-Control': 'private, max-age=86400', + }); + return res.send(fileStream); + } catch (err) { + this.logger.error(err); + throw new NotFoundException('File not found'); + } + } } diff --git a/apps/server/src/core/attachment/attachment.module.ts b/apps/server/src/core/attachment/attachment.module.ts index 7dc47ed8..f80a2eb7 100644 --- a/apps/server/src/core/attachment/attachment.module.ts +++ b/apps/server/src/core/attachment/attachment.module.ts @@ -5,9 +5,10 @@ import { StorageModule } from '../../integrations/storage/storage.module'; import { UserModule } from '../user/user.module'; import { WorkspaceModule } from '../workspace/workspace.module'; import { AttachmentProcessor } from './processors/attachment.processor'; +import { TokenModule } from '../auth/token.module'; @Module({ - imports: [StorageModule, UserModule, WorkspaceModule], + imports: [StorageModule, UserModule, WorkspaceModule, TokenModule], controllers: [AttachmentController], providers: [AttachmentService, AttachmentProcessor], }) diff --git a/apps/server/src/core/auth/dto/jwt-payload.ts b/apps/server/src/core/auth/dto/jwt-payload.ts index ad172b78..b9ce13c4 100644 --- a/apps/server/src/core/auth/dto/jwt-payload.ts +++ b/apps/server/src/core/auth/dto/jwt-payload.ts @@ -2,6 +2,7 @@ export enum JwtType { ACCESS = 'access', COLLAB = 'collab', EXCHANGE = 'exchange', + ATTACHMENT = 'attachment', } export type JwtPayload = { sub: string; @@ -21,3 +22,11 @@ export type JwtExchangePayload = { workspaceId: string; type: 'exchange'; }; + +export type JwtAttachmentPayload = { + attachmentId: string; + pageId: string; + workspaceId: string; + type: 'attachment'; +}; + diff --git a/apps/server/src/core/auth/services/token.service.ts b/apps/server/src/core/auth/services/token.service.ts index ad745290..963e8e65 100644 --- a/apps/server/src/core/auth/services/token.service.ts +++ b/apps/server/src/core/auth/services/token.service.ts @@ -6,6 +6,7 @@ import { import { JwtService } from '@nestjs/jwt'; import { EnvironmentService } from '../../../integrations/environment/environment.service'; import { + JwtAttachmentPayload, JwtCollabPayload, JwtExchangePayload, JwtPayload, @@ -59,6 +60,21 @@ export class TokenService { return this.jwtService.sign(payload, { expiresIn: '10s' }); } + async generateAttachmentToken(opts: { + attachmentId: string; + pageId: string; + workspaceId: string; + }): Promise { + const { attachmentId, pageId, workspaceId } = opts; + const payload: JwtAttachmentPayload = { + attachmentId: attachmentId, + pageId: pageId, + workspaceId: workspaceId, + type: JwtType.ATTACHMENT, + }; + return this.jwtService.sign(payload, { expiresIn: '1h' }); + } + async verifyJwt(token: string, tokenType: string) { const payload = await this.jwtService.verifyAsync(token, { secret: this.environmentService.getAppSecret(), diff --git a/apps/server/src/core/casl/abilities/space-ability.factory.ts b/apps/server/src/core/casl/abilities/space-ability.factory.ts index d2173383..53a57a0c 100644 --- a/apps/server/src/core/casl/abilities/space-ability.factory.ts +++ b/apps/server/src/core/casl/abilities/space-ability.factory.ts @@ -45,6 +45,7 @@ function buildSpaceAdminAbility() { can(SpaceCaslAction.Manage, SpaceCaslSubject.Settings); can(SpaceCaslAction.Manage, SpaceCaslSubject.Member); can(SpaceCaslAction.Manage, SpaceCaslSubject.Page); + can(SpaceCaslAction.Manage, SpaceCaslSubject.Share); return build(); } @@ -55,6 +56,7 @@ function buildSpaceWriterAbility() { can(SpaceCaslAction.Read, SpaceCaslSubject.Settings); can(SpaceCaslAction.Read, SpaceCaslSubject.Member); can(SpaceCaslAction.Manage, SpaceCaslSubject.Page); + can(SpaceCaslAction.Manage, SpaceCaslSubject.Share); return build(); } @@ -65,5 +67,6 @@ function buildSpaceReaderAbility() { can(SpaceCaslAction.Read, SpaceCaslSubject.Settings); can(SpaceCaslAction.Read, SpaceCaslSubject.Member); can(SpaceCaslAction.Read, SpaceCaslSubject.Page); + can(SpaceCaslAction.Read, SpaceCaslSubject.Share); return build(); } diff --git a/apps/server/src/core/casl/interfaces/space-ability.type.ts b/apps/server/src/core/casl/interfaces/space-ability.type.ts index c927229b..d7801cab 100644 --- a/apps/server/src/core/casl/interfaces/space-ability.type.ts +++ b/apps/server/src/core/casl/interfaces/space-ability.type.ts @@ -9,9 +9,11 @@ export enum SpaceCaslSubject { Settings = 'settings', Member = 'member', Page = 'page', + Share = 'share', } export type ISpaceAbility = | [SpaceCaslAction, SpaceCaslSubject.Settings] | [SpaceCaslAction, SpaceCaslSubject.Member] - | [SpaceCaslAction, SpaceCaslSubject.Page]; + | [SpaceCaslAction, SpaceCaslSubject.Page] + | [SpaceCaslAction, SpaceCaslSubject.Share]; diff --git a/apps/server/src/core/core.module.ts b/apps/server/src/core/core.module.ts index 182a1420..f7f4f785 100644 --- a/apps/server/src/core/core.module.ts +++ b/apps/server/src/core/core.module.ts @@ -15,6 +15,7 @@ import { SpaceModule } from './space/space.module'; import { GroupModule } from './group/group.module'; import { CaslModule } from './casl/casl.module'; import { DomainMiddleware } from '../common/middlewares/domain.middleware'; +import { ShareModule } from './share/share.module'; @Module({ imports: [ @@ -28,6 +29,7 @@ import { DomainMiddleware } from '../common/middlewares/domain.middleware'; SpaceModule, GroupModule, CaslModule, + ShareModule, ], }) export class CoreModule implements NestModule { diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts index 43d8f1d2..5e4553c6 100644 --- a/apps/server/src/core/page/services/page.service.ts +++ b/apps/server/src/core/page/services/page.service.ts @@ -212,7 +212,7 @@ export class PageService { trx, ); const pageIds = await this.pageRepo - .getPageAndDescendants(rootPage.id) + .getPageAndDescendants(rootPage.id, { includeContent: false }) .then((pages) => pages.map((page) => page.id)); // The first id is the root page id if (pageIds.length > 1) { @@ -223,6 +223,16 @@ export class PageService { trx, ); } + + // update spaceId in shares + if (pageIds.length > 0) { + await trx + .updateTable('shares') + .set({ spaceId: spaceId }) + .where('pageId', 'in', pageIds) + .execute(); + } + // Update attachments await this.attachmentRepo.updateAttachmentsByPageId( { spaceId }, diff --git a/apps/server/src/core/share/dto/share.dto.ts b/apps/server/src/core/share/dto/share.dto.ts new file mode 100644 index 00000000..b6e789ec --- /dev/null +++ b/apps/server/src/core/share/dto/share.dto.ts @@ -0,0 +1,58 @@ +import { + IsBoolean, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, +} from 'class-validator'; + +export class CreateShareDto { + @IsString() + @IsNotEmpty() + pageId: string; + + @IsBoolean() + @IsOptional() + includeSubPages: boolean; + + @IsOptional() + @IsBoolean() + searchIndexing: boolean; +} + +export class UpdateShareDto extends CreateShareDto { + @IsString() + @IsNotEmpty() + shareId: string; + + @IsString() + @IsOptional() + pageId: string; +} + +export class ShareIdDto { + @IsString() + @IsNotEmpty() + shareId: string; +} + +export class SpaceIdDto { + @IsUUID() + spaceId: string; +} + +export class ShareInfoDto { + @IsString() + @IsOptional() + shareId?: string; + + @IsString() + @IsOptional() + pageId: string; +} + +export class SharePageIdDto { + @IsString() + @IsNotEmpty() + pageId: string; +} diff --git a/apps/server/src/core/share/share-seo.controller.ts b/apps/server/src/core/share/share-seo.controller.ts new file mode 100644 index 00000000..ecacecf0 --- /dev/null +++ b/apps/server/src/core/share/share-seo.controller.ts @@ -0,0 +1,109 @@ +import { Controller, Get, Param, Req, Res } from '@nestjs/common'; +import { ShareService } from './share.service'; +import { FastifyReply, FastifyRequest } from 'fastify'; +import { join } from 'path'; +import * as fs from 'node:fs'; +import { validate as isValidUUID } from 'uuid'; +import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; +import { EnvironmentService } from '../../integrations/environment/environment.service'; +import { Workspace } from '@docmost/db/types/entity.types'; + +@Controller('share') +export class ShareSeoController { + constructor( + private readonly shareService: ShareService, + private workspaceRepo: WorkspaceRepo, + private environmentService: EnvironmentService, + ) {} + + /* + * add meta tags to publicly shared pages + */ + @Get([':shareId/p/:pageSlug', 'p/:pageSlug']) + async getShare( + @Res({ passthrough: false }) res: FastifyReply, + @Req() req: FastifyRequest, + @Param('shareId') shareId: string, + @Param('pageSlug') pageSlug: string, + ) { + // Nestjs does not to apply middlewares to paths excluded from the global /api prefix + // https://github.com/nestjs/nest/issues/9124 + // https://github.com/nestjs/nest/issues/11572 + // https://github.com/nestjs/nest/issues/13401 + // we have to duplicate the DomainMiddleware code here as a workaround + + let workspace: Workspace = null; + if (this.environmentService.isSelfHosted()) { + workspace = await this.workspaceRepo.findFirst(); + } else { + const header = req.raw.headers.host; + const subdomain = header.split('.')[0]; + workspace = await this.workspaceRepo.findByHostname(subdomain); + } + + const clientDistPath = join( + __dirname, + '..', + '..', + '..', + '..', + 'client/dist', + ); + + if (fs.existsSync(clientDistPath)) { + const indexFilePath = join(clientDistPath, 'index.html'); + + if (!workspace) { + return this.sendIndex(indexFilePath, res); + } + + const pageId = this.extractPageSlugId(pageSlug); + + const share = await this.shareService.getShareForPage( + pageId, + workspace.id, + ); + + if (!share) { + return this.sendIndex(indexFilePath, res); + } + + const rawTitle = share.sharedPage.title ?? 'untitled'; + const metaTitle = + rawTitle.length > 80 ? `${rawTitle.slice(0, 77)}…` : rawTitle; + + const metaTagVar = ''; + + const metaTags = [ + ``, + ``, + !share.searchIndexing ? `` : '', + ] + .filter(Boolean) + .join('\n '); + + const html = fs.readFileSync(indexFilePath, 'utf8'); + const transformedHtml = html + .replace(/[\s\S]*?<\/title>/i, `<title>${metaTitle}`) + .replace(metaTagVar, metaTags); + + res.type('text/html').send(transformedHtml); + } + } + + sendIndex(indexFilePath: string, res: FastifyReply) { + const stream = fs.createReadStream(indexFilePath); + res.type('text/html').send(stream); + } + + extractPageSlugId(slug: string): string { + if (!slug) { + return undefined; + } + if (isValidUUID(slug)) { + return slug; + } + const parts = slug.split('-'); + return parts.length > 1 ? parts[parts.length - 1] : slug; + } +} diff --git a/apps/server/src/core/share/share.controller.ts b/apps/server/src/core/share/share.controller.ts new file mode 100644 index 00000000..5e8debe0 --- /dev/null +++ b/apps/server/src/core/share/share.controller.ts @@ -0,0 +1,171 @@ +import { + BadRequestException, + Body, + Controller, + ForbiddenException, + HttpCode, + HttpStatus, + NotFoundException, + Post, + UseGuards, +} from '@nestjs/common'; +import { AuthUser } from '../../common/decorators/auth-user.decorator'; +import { User, Workspace } from '@docmost/db/types/entity.types'; +import { + SpaceCaslAction, + SpaceCaslSubject, +} from '../casl/interfaces/space-ability.type'; +import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; +import SpaceAbilityFactory from '../casl/abilities/space-ability.factory'; +import { ShareService } from './share.service'; +import { + CreateShareDto, + ShareIdDto, + ShareInfoDto, + SharePageIdDto, + UpdateShareDto, +} from './dto/share.dto'; +import { PageRepo } from '@docmost/db/repos/page/page.repo'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { Public } from '../../common/decorators/public.decorator'; +import { ShareRepo } from '@docmost/db/repos/share/share.repo'; +import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; + +@UseGuards(JwtAuthGuard) +@Controller('shares') +export class ShareController { + constructor( + private readonly shareService: ShareService, + private readonly spaceAbility: SpaceAbilityFactory, + private readonly shareRepo: ShareRepo, + private readonly pageRepo: PageRepo, + ) {} + + @HttpCode(HttpStatus.OK) + @Post('/') + async getShares( + @AuthUser() user: User, + @Body() pagination: PaginationOptions, + ) { + return this.shareRepo.getShares(user.id, pagination); + } + + @Public() + @HttpCode(HttpStatus.OK) + @Post('/page-info') + async getSharedPageInfo( + @Body() dto: ShareInfoDto, + @AuthWorkspace() workspace: Workspace, + ) { + if (!dto.pageId && !dto.shareId) { + throw new BadRequestException(); + } + + return this.shareService.getSharedPage(dto, workspace.id); + } + + @Public() + @HttpCode(HttpStatus.OK) + @Post('/info') + async getShare(@Body() dto: ShareIdDto) { + const share = await this.shareRepo.findById(dto.shareId, { + includeSharedPage: true, + }); + + if (!share) { + throw new NotFoundException('Share not found'); + } + + return share; + } + + @HttpCode(HttpStatus.OK) + @Post('/for-page') + async getShareForPage( + @Body() dto: SharePageIdDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + const page = await this.pageRepo.findById(dto.pageId); + if (!page) { + throw new NotFoundException('Shared page not found'); + } + + const ability = await this.spaceAbility.createForUser(user, page.spaceId); + if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Share)) { + throw new ForbiddenException(); + } + + return this.shareService.getShareForPage(page.id, workspace.id); + } + + @HttpCode(HttpStatus.OK) + @Post('create') + async create( + @Body() createShareDto: CreateShareDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + const page = await this.pageRepo.findById(createShareDto.pageId); + + if (!page || workspace.id !== page.workspaceId) { + throw new NotFoundException('Page not found'); + } + + const ability = await this.spaceAbility.createForUser(user, page.spaceId); + if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Share)) { + throw new ForbiddenException(); + } + + return this.shareService.createShare({ + page, + authUserId: user.id, + workspaceId: workspace.id, + createShareDto, + }); + } + + @HttpCode(HttpStatus.OK) + @Post('update') + async update(@Body() updateShareDto: UpdateShareDto, @AuthUser() user: User) { + const share = await this.shareRepo.findById(updateShareDto.shareId); + + if (!share) { + throw new NotFoundException('Share not found'); + } + + const ability = await this.spaceAbility.createForUser(user, share.spaceId); + if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Share)) { + throw new ForbiddenException(); + } + + return this.shareService.updateShare(share.id, updateShareDto); + } + + @HttpCode(HttpStatus.OK) + @Post('delete') + async delete(@Body() shareIdDto: ShareIdDto, @AuthUser() user: User) { + const share = await this.shareRepo.findById(shareIdDto.shareId); + + if (!share) { + throw new NotFoundException('Share not found'); + } + + const ability = await this.spaceAbility.createForUser(user, share.spaceId); + if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Share)) { + throw new ForbiddenException(); + } + + await this.shareRepo.deleteShare(share.id); + } + + @Public() + @HttpCode(HttpStatus.OK) + @Post('/tree') + async getSharePageTree( + @Body() dto: ShareIdDto, + @AuthWorkspace() workspace: Workspace, + ) { + return this.shareService.getShareTree(dto.shareId, workspace.id); + } +} diff --git a/apps/server/src/core/share/share.module.ts b/apps/server/src/core/share/share.module.ts new file mode 100644 index 00000000..2ba9764e --- /dev/null +++ b/apps/server/src/core/share/share.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { ShareController } from './share.controller'; +import { ShareService } from './share.service'; +import { TokenModule } from '../auth/token.module'; +import { ShareSeoController } from './share-seo.controller'; + +@Module({ + imports: [TokenModule], + controllers: [ShareController, ShareSeoController], + providers: [ShareService], + exports: [ShareService], +}) +export class ShareModule {} diff --git a/apps/server/src/core/share/share.service.ts b/apps/server/src/core/share/share.service.ts new file mode 100644 index 00000000..a9140c0b --- /dev/null +++ b/apps/server/src/core/share/share.service.ts @@ -0,0 +1,297 @@ +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { CreateShareDto, ShareInfoDto, UpdateShareDto } from './dto/share.dto'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB } from '@docmost/db/types/kysely.types'; +import { nanoIdGen } from '../../common/helpers'; +import { PageRepo } from '@docmost/db/repos/page/page.repo'; +import { TokenService } from '../auth/services/token.service'; +import { jsonToNode } from '../../collaboration/collaboration.util'; +import { + getAttachmentIds, + getProsemirrorContent, + isAttachmentNode, +} from '../../common/helpers/prosemirror/utils'; +import { Node } from '@tiptap/pm/model'; +import { ShareRepo } from '@docmost/db/repos/share/share.repo'; +import { updateAttachmentAttr } from './share.util'; +import { Page } from '@docmost/db/types/entity.types'; +import { validate as isValidUUID } from 'uuid'; +import { sql } from 'kysely'; + +@Injectable() +export class ShareService { + private readonly logger = new Logger(ShareService.name); + + constructor( + private readonly shareRepo: ShareRepo, + private readonly pageRepo: PageRepo, + @InjectKysely() private readonly db: KyselyDB, + private readonly tokenService: TokenService, + ) {} + + async getShareTree(shareId: string, workspaceId: string) { + const share = await this.shareRepo.findById(shareId); + if (!share || share.workspaceId !== workspaceId) { + throw new NotFoundException('Share not found'); + } + + if (share.includeSubPages) { + const pageList = await this.pageRepo.getPageAndDescendants(share.pageId, { + includeContent: false, + }); + + return { share, pageTree: pageList }; + } else { + return { share, pageTree: [] }; + } + } + + async createShare(opts: { + authUserId: string; + workspaceId: string; + page: Page; + createShareDto: CreateShareDto; + }) { + const { authUserId, workspaceId, page, createShareDto } = opts; + + try { + const shares = await this.shareRepo.findByPageId(page.id); + if (shares) { + return shares; + } + + return await this.shareRepo.insertShare({ + key: nanoIdGen().toLowerCase(), + pageId: page.id, + includeSubPages: createShareDto.includeSubPages || true, + searchIndexing: createShareDto.searchIndexing || true, + creatorId: authUserId, + spaceId: page.spaceId, + workspaceId, + }); + } catch (err) { + this.logger.error(err); + throw new BadRequestException('Failed to share page'); + } + } + + async updateShare(shareId: string, updateShareDto: UpdateShareDto) { + try { + return this.shareRepo.updateShare( + { + includeSubPages: updateShareDto.includeSubPages, + searchIndexing: updateShareDto.searchIndexing, + }, + shareId, + ); + } catch (err) { + this.logger.error(err); + throw new BadRequestException('Failed to update share'); + } + } + + async getSharedPage(dto: ShareInfoDto, workspaceId: string) { + const share = await this.getShareForPage(dto.pageId, workspaceId); + + if (!share) { + throw new NotFoundException('Shared page not found'); + } + + const page = await this.pageRepo.findById(dto.pageId, { + includeContent: true, + includeCreator: true, + }); + + page.content = await this.updatePublicAttachments(page); + + if (!page) { + throw new NotFoundException('Shared page not found'); + } + + return { page, share }; + } + + async getShareForPage(pageId: string, workspaceId: string) { + // here we try to check if a page was shared directly or if it inherits the share from its closest shared ancestor + const share = await this.db + .withRecursive('page_hierarchy', (cte) => + cte + .selectFrom('pages') + .select([ + 'id', + 'slugId', + 'pages.title', + 'pages.icon', + 'parentPageId', + sql`0`.as('level'), + ]) + .where(isValidUUID(pageId) ? 'id' : 'slugId', '=', pageId) + .unionAll((union) => + union + .selectFrom('pages as p') + .select([ + 'p.id', + 'p.slugId', + 'p.title', + 'p.icon', + 'p.parentPageId', + // Increase the level by 1 for each ancestor. + sql`ph.level + 1`.as('level'), + ]) + .innerJoin('page_hierarchy as ph', 'ph.parentPageId', 'p.id'), + ), + ) + .selectFrom('page_hierarchy') + .leftJoin('shares', 'shares.pageId', 'page_hierarchy.id') + .select([ + 'page_hierarchy.id as sharedPageId', + 'page_hierarchy.slugId as sharedPageSlugId', + 'page_hierarchy.title as sharedPageTitle', + 'page_hierarchy.icon as sharedPageIcon', + 'page_hierarchy.level as level', + 'shares.id', + 'shares.key', + 'shares.pageId', + 'shares.includeSubPages', + 'shares.searchIndexing', + 'shares.creatorId', + 'shares.spaceId', + 'shares.workspaceId', + 'shares.createdAt', + 'shares.updatedAt', + ]) + .where('shares.id', 'is not', null) + .orderBy('page_hierarchy.level', 'asc') + .executeTakeFirst(); + + if (!share || share.workspaceId != workspaceId) { + return undefined; + } + + if (share.level === 1 && !share.includeSubPages) { + // we can only show a page if its shared ancestor permits it + return undefined; + } + + return { + id: share.id, + key: share.key, + includeSubPages: share.includeSubPages, + searchIndexing: share.searchIndexing, + pageId: share.pageId, + creatorId: share.creatorId, + spaceId: share.spaceId, + workspaceId: share.workspaceId, + createdAt: share.createdAt, + level: share.level, + sharedPage: { + id: share.sharedPageId, + slugId: share.sharedPageSlugId, + title: share.sharedPageTitle, + icon: share.sharedPageIcon, + }, + }; + } + + async getShareAncestorPage( + ancestorPageId: string, + childPageId: string, + ): Promise { + let ancestor = null; + try { + ancestor = await this.db + .withRecursive('page_ancestors', (db) => + db + .selectFrom('pages') + .select([ + 'id', + 'slugId', + 'title', + 'parentPageId', + 'spaceId', + (eb) => + eb + .case() + .when(eb.ref('id'), '=', ancestorPageId) + .then(true) + .else(false) + .end() + .as('found'), + ]) + .where( + isValidUUID(childPageId) ? 'id' : 'slugId', + '=', + childPageId, + ) + .unionAll((exp) => + exp + .selectFrom('pages as p') + .select([ + 'p.id', + 'p.slugId', + 'p.title', + 'p.parentPageId', + 'p.spaceId', + (eb) => + eb + .case() + .when(eb.ref('p.id'), '=', ancestorPageId) + .then(true) + .else(false) + .end() + .as('found'), + ]) + .innerJoin('page_ancestors as pa', 'pa.parentPageId', 'p.id') + // Continue recursing only when the target ancestor hasn't been found on that branch. + .where('pa.found', '=', false), + ), + ) + .selectFrom('page_ancestors') + .selectAll() + .where('found', '=', true) + .limit(1) + .executeTakeFirst(); + } catch (err) { + // empty + } + + return ancestor; + } + + async updatePublicAttachments(page: Page): Promise { + const prosemirrorJson = getProsemirrorContent(page.content); + const attachmentIds = getAttachmentIds(prosemirrorJson); + const attachmentMap = new Map(); + + await Promise.all( + attachmentIds.map(async (attachmentId: string) => { + const token = await this.tokenService.generateAttachmentToken({ + attachmentId, + pageId: page.id, + workspaceId: page.workspaceId, + }); + attachmentMap.set(attachmentId, token); + }), + ); + + const doc = jsonToNode(prosemirrorJson); + + doc?.descendants((node: Node) => { + if (!isAttachmentNode(node.type.name)) return; + + const attachmentId = node.attrs.attachmentId; + const token = attachmentMap.get(attachmentId); + if (!token) return; + + updateAttachmentAttr(node, 'src', token); + updateAttachmentAttr(node, 'url', token); + }); + + return doc.toJSON(); + } +} diff --git a/apps/server/src/core/share/share.util.ts b/apps/server/src/core/share/share.util.ts new file mode 100644 index 00000000..e21f55aa --- /dev/null +++ b/apps/server/src/core/share/share.util.ts @@ -0,0 +1,22 @@ +import { Node } from '@tiptap/pm/model'; + +export function updateAttachmentAttr( + node: Node, + attr: 'src' | 'url', + token: string, +) { + const attrVal = node.attrs[attr]; + if ( + attrVal && + (attrVal.startsWith('/files') || attrVal.startsWith('/api/files')) + ) { + // @ts-ignore + node.attrs[attr] = updateAttachmentUrl(attrVal, token); + } +} + +function updateAttachmentUrl(src: string, jwtToken: string) { + const updatedSrc = src.replace('/files/', '/files/public/'); + const separator = updatedSrc.includes('?') ? '&' : '?'; + return `${updatedSrc}${separator}jwt=${jwtToken}`; +} diff --git a/apps/server/src/database/database.module.ts b/apps/server/src/database/database.module.ts index 930bb59b..68c35dd3 100644 --- a/apps/server/src/database/database.module.ts +++ b/apps/server/src/database/database.module.ts @@ -24,6 +24,7 @@ import * as process from 'node:process'; import { MigrationService } from '@docmost/db/services/migration.service'; import { UserTokenRepo } from './repos/user-token/user-token.repo'; import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo'; +import { ShareRepo } from '@docmost/db/repos/share/share.repo'; // https://github.com/brianc/node-postgres/issues/811 types.setTypeParser(types.builtins.INT8, (val) => Number(val)); @@ -74,6 +75,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val)); AttachmentRepo, UserTokenRepo, BacklinkRepo, + ShareRepo ], exports: [ WorkspaceRepo, @@ -88,6 +90,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val)); AttachmentRepo, UserTokenRepo, BacklinkRepo, + ShareRepo ], }) export class DatabaseModule diff --git a/apps/server/src/database/migrations/20250408T191830-shares.ts b/apps/server/src/database/migrations/20250408T191830-shares.ts new file mode 100644 index 00000000..39d91454 --- /dev/null +++ b/apps/server/src/database/migrations/20250408T191830-shares.ts @@ -0,0 +1,38 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('shares') + .addColumn('id', 'uuid', (col) => + col.primaryKey().defaultTo(sql`gen_uuid_v7()`), + ) + .addColumn('key', 'varchar', (col) => col.notNull()) + .addColumn('page_id', 'uuid', (col) => + col.references('pages.id').onDelete('cascade'), + ) + .addColumn('include_sub_pages', 'boolean', (col) => col.defaultTo(false)) + .addColumn('search_indexing', 'boolean', (col) => col.defaultTo(false)) + .addColumn('creator_id', 'uuid', (col) => col.references('users.id')) + .addColumn('space_id', 'uuid', (col) => + col.references('spaces.id').onDelete('cascade').notNull(), + ) + .addColumn('workspace_id', 'uuid', (col) => + col.references('workspaces.id').onDelete('cascade').notNull(), + ) + .addColumn('created_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addColumn('updated_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addColumn('deleted_at', 'timestamptz', (col) => col) + .addUniqueConstraint('shares_key_workspace_id_unique', [ + 'key', + 'workspace_id', + ]) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('shares').execute(); +} diff --git a/apps/server/src/database/repos/page/page.repo.ts b/apps/server/src/database/repos/page/page.repo.ts index 850fb2d1..8f06c4d4 100644 --- a/apps/server/src/database/repos/page/page.repo.ts +++ b/apps/server/src/database/repos/page/page.repo.ts @@ -211,7 +211,10 @@ export class PageRepo { ).as('contributors'); } - async getPageAndDescendants(parentPageId: string) { + async getPageAndDescendants( + parentPageId: string, + opts: { includeContent: boolean }, + ) { return this.db .withRecursive('page_hierarchy', (db) => db @@ -221,11 +224,12 @@ export class PageRepo { 'slugId', 'title', 'icon', - 'content', + 'position', 'parentPageId', 'spaceId', 'workspaceId', ]) + .$if(opts?.includeContent, (qb) => qb.select('content')) .where('id', '=', parentPageId) .unionAll((exp) => exp @@ -235,11 +239,12 @@ export class PageRepo { 'p.slugId', 'p.title', 'p.icon', - 'p.content', + 'p.position', 'p.parentPageId', 'p.spaceId', 'p.workspaceId', ]) + .$if(opts?.includeContent, (qb) => qb.select('content')) .innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id'), ), ) diff --git a/apps/server/src/database/repos/share/share.repo.ts b/apps/server/src/database/repos/share/share.repo.ts new file mode 100644 index 00000000..c2943c07 --- /dev/null +++ b/apps/server/src/database/repos/share/share.repo.ts @@ -0,0 +1,242 @@ +import { Injectable } from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB, KyselyTransaction } from '../../types/kysely.types'; +import { dbOrTx } from '../../utils'; +import { + InsertableShare, + Share, + UpdatableShare, +} from '@docmost/db/types/entity.types'; +import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; +import { executeWithPagination } from '@docmost/db/pagination/pagination'; +import { validate as isValidUUID } from 'uuid'; +import { ExpressionBuilder, sql } from 'kysely'; +import { DB } from '@docmost/db/types/db'; +import { jsonObjectFrom } from 'kysely/helpers/postgres'; +import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; + +@Injectable() +export class ShareRepo { + constructor( + @InjectKysely() private readonly db: KyselyDB, + private spaceMemberRepo: SpaceMemberRepo, + ) {} + + private baseFields: Array = [ + 'id', + 'key', + 'pageId', + 'includeSubPages', + 'searchIndexing', + 'creatorId', + 'spaceId', + 'workspaceId', + 'createdAt', + 'updatedAt', + 'deletedAt', + ]; + + async findById( + shareId: string, + opts?: { + includeSharedPage?: boolean; + includeCreator?: boolean; + withLock?: boolean; + trx?: KyselyTransaction; + }, + ): Promise { + const db = dbOrTx(this.db, opts?.trx); + + let query = db.selectFrom('shares').select(this.baseFields); + + if (opts?.includeSharedPage) { + query = query.select((eb) => this.withSharedPage(eb)); + } + + if (opts?.includeCreator) { + query = query.select((eb) => this.withCreator(eb)); + } + + if (opts?.withLock && opts?.trx) { + query = query.forUpdate(); + } + + if (isValidUUID(shareId)) { + query = query.where('id', '=', shareId); + } else { + query = query.where(sql`LOWER(key)`, '=', shareId.toLowerCase()); + } + + return query.executeTakeFirst(); + } + + async findByPageId( + pageId: string, + opts?: { + includeCreator?: boolean; + withLock?: boolean; + trx?: KyselyTransaction; + }, + ): Promise { + const db = dbOrTx(this.db, opts?.trx); + + let query = db + .selectFrom('shares') + .select(this.baseFields) + .where('pageId', '=', pageId); + + if (opts?.includeCreator) { + query = query.select((eb) => this.withCreator(eb)); + } + + if (opts?.withLock && opts?.trx) { + query = query.forUpdate(); + } + return query.executeTakeFirst(); + } + + async updateShare( + updatableShare: UpdatableShare, + shareId: string, + trx?: KyselyTransaction, + ) { + return dbOrTx(this.db, trx) + .updateTable('shares') + .set({ ...updatableShare, updatedAt: new Date() }) + .where( + isValidUUID(shareId) ? 'id' : sql`LOWER(key)`, + '=', + shareId.toLowerCase(), + ) + .returning(this.baseFields) + .executeTakeFirst(); + } + + async insertShare( + insertableShare: InsertableShare, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + return db + .insertInto('shares') + .values(insertableShare) + .returning(this.baseFields) + .executeTakeFirst(); + } + + async deleteShare(shareId: string): Promise { + let query = this.db.deleteFrom('shares'); + + if (isValidUUID(shareId)) { + query = query.where('id', '=', shareId); + } else { + query = query.where(sql`LOWER(key)`, '=', shareId.toLowerCase()); + } + + await query.execute(); + } + + async getShares(userId: string, pagination: PaginationOptions) { + const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId); + + const query = this.db + .selectFrom('shares') + .select(this.baseFields) + .select((eb) => this.withPage(eb)) + .select((eb) => this.withSpace(eb, userId)) + .select((eb) => this.withCreator(eb)) + .where('spaceId', 'in', userSpaceIds) + .orderBy('updatedAt', 'desc'); + + const hasEmptyIds = userSpaceIds.length === 0; + const result = executeWithPagination(query, { + page: pagination.page, + perPage: pagination.limit, + hasEmptyIds, + }); + + return result; + } + + withPage(eb: ExpressionBuilder) { + return jsonObjectFrom( + eb + .selectFrom('pages') + .select(['pages.id', 'pages.title', 'pages.slugId', 'pages.icon']) + .whereRef('pages.id', '=', 'shares.pageId'), + ).as('page'); + } + + withSpace(eb: ExpressionBuilder, userId?: string) { + return jsonObjectFrom( + eb + .selectFrom('spaces') + .select(['spaces.id', 'spaces.name', 'spaces.slug']) + .$if(Boolean(userId), (qb) => + qb.select((eb) => this.withUserSpaceRole(eb, userId)), + ) + .whereRef('spaces.id', '=', 'shares.spaceId'), + ).as('space'); + } + + withUserSpaceRole(eb: ExpressionBuilder, userId: string) { + return eb + .selectFrom( + eb + .selectFrom('spaceMembers') + .select(['spaceMembers.role']) + .whereRef('spaceMembers.spaceId', '=', 'spaces.id') + .where('spaceMembers.userId', '=', userId) + .unionAll( + eb + .selectFrom('spaceMembers') + .innerJoin( + 'groupUsers', + 'groupUsers.groupId', + 'spaceMembers.groupId', + ) + .select(['spaceMembers.role']) + .whereRef('spaceMembers.spaceId', '=', 'spaces.id') + .where('groupUsers.userId', '=', userId), + ) + .as('roles_union'), + ) + .select('roles_union.role') + .orderBy( + sql`CASE roles_union.role + WHEN 'admin' THEN 3 + WHEN 'writer' THEN 2 + WHEN 'reader' THEN 1 + ELSE 0 + END`, + + 'desc', + ) + .limit(1) + .as('userRole'); + } + + withCreator(eb: ExpressionBuilder) { + return jsonObjectFrom( + eb + .selectFrom('users') + .select(['users.id', 'users.name', 'users.avatarUrl']) + .whereRef('users.id', '=', 'shares.creatorId'), + ).as('creator'); + } + + withSharedPage(eb: ExpressionBuilder) { + return jsonObjectFrom( + eb + .selectFrom('pages') + .select([ + 'pages.id', + 'pages.slugId', + 'pages.title', + 'pages.icon', + 'pages.parentPageId', + ]) + .whereRef('pages.id', '=', 'shares.pageId'), + ).as('sharedPage'); + } +} diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index eae94943..8c4cbd57 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -183,6 +183,20 @@ export interface Pages { ydoc: Buffer | null; } +export interface Shares { + createdAt: Generated; + creatorId: string | null; + deletedAt: Timestamp | null; + id: Generated; + includeSubPages: Generated; + key: string; + pageId: string | null; + searchIndexing: Generated; + spaceId: string; + updatedAt: Generated; + workspaceId: string; +} + export interface SpaceMembers { addedById: string | null; createdAt: Generated; @@ -288,6 +302,7 @@ export interface DB { groupUsers: GroupUsers; pageHistory: PageHistory; pages: Pages; + shares: Shares; spaceMembers: SpaceMembers; spaces: Spaces; users: Users; diff --git a/apps/server/src/database/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts index 8abd9f98..6cb55a11 100644 --- a/apps/server/src/database/types/entity.types.ts +++ b/apps/server/src/database/types/entity.types.ts @@ -16,6 +16,7 @@ import { Billing as BillingSubscription, AuthProviders, AuthAccounts, + Shares, } from './db'; // Workspace @@ -101,3 +102,8 @@ export type UpdatableAuthProvider = Updateable>; export type AuthAccount = Selectable; export type InsertableAuthAccount = Insertable; export type UpdatableAuthAccount = Updateable>; + +// Share +export type Share = Selectable; +export type InsertableShare = Insertable; +export type UpdatableShare = Updateable>; diff --git a/apps/server/src/integrations/export/export.service.ts b/apps/server/src/integrations/export/export.service.ts index 09fdb5fd..4bb5146f 100644 --- a/apps/server/src/integrations/export/export.service.ts +++ b/apps/server/src/integrations/export/export.service.ts @@ -15,10 +15,8 @@ import { StorageService } from '../storage/storage.service'; import { buildTree, computeLocalPath, - getAttachmentIds, getExportExtension, getPageTitle, - getProsemirrorContent, PageExportTree, replaceInternalLinks, updateAttachmentUrlsToLocalPaths, @@ -29,6 +27,10 @@ import { EditorState } from '@tiptap/pm/state'; // eslint-disable-next-line @typescript-eslint/no-require-imports import slugify = require('@sindresorhus/slugify'); import { EnvironmentService } from '../environment/environment.service'; +import { + getAttachmentIds, + getProsemirrorContent, +} from '../../common/helpers/prosemirror/utils'; @Injectable() export class ExportService { @@ -76,8 +78,11 @@ export class ExportService { `; } - if (format === ExportFormat.Markdown) { - const newPageHtml = pageHtml.replace(/]*>[\s\S]*?<\/colgroup>/gmi, ''); + if (format === ExportFormat.Markdown) { + const newPageHtml = pageHtml.replace( + /]*>[\s\S]*?<\/colgroup>/gim, + '', + ); return turndown(newPageHtml); } @@ -85,7 +90,9 @@ export class ExportService { } async exportPageWithChildren(pageId: string, format: string) { - const pages = await this.pageRepo.getPageAndDescendants(pageId); + const pages = await this.pageRepo.getPageAndDescendants(pageId, { + includeContent: true, + }); if (!pages || pages.length === 0) { throw new BadRequestException('No pages to export'); @@ -260,14 +267,7 @@ export class ExportService { const pages = await this.db .selectFrom('pages') - .select([ - 'id', - 'slugId', - 'title', - 'creatorId', - 'spaceId', - 'workspaceId', - ]) + .select(['id', 'slugId', 'title', 'creatorId', 'spaceId', 'workspaceId']) .select((eb) => this.pageRepo.withSpace(eb)) .where('id', 'in', pageMentionIds) .where('workspaceId', '=', workspaceId) diff --git a/apps/server/src/integrations/export/utils.ts b/apps/server/src/integrations/export/utils.ts index f99f337a..fe1815b0 100644 --- a/apps/server/src/integrations/export/utils.ts +++ b/apps/server/src/integrations/export/utils.ts @@ -4,6 +4,7 @@ import { Node } from '@tiptap/pm/model'; import { validate as isValidUUID } from 'uuid'; import * as path from 'path'; import { Page } from '@docmost/db/types/entity.types'; +import { isAttachmentNode } from '../../common/helpers/prosemirror/utils'; export type PageExportTree = Record; @@ -25,43 +26,6 @@ export function getPageTitle(title: string) { return title ? title : 'untitled'; } -export function getProsemirrorContent(content: any) { - return ( - content ?? { - type: 'doc', - content: [{ type: 'paragraph', attrs: { textAlign: 'left' } }], - } - ); -} - -export function getAttachmentIds(prosemirrorJson: any) { - const doc = jsonToNode(prosemirrorJson); - const attachmentIds = []; - - doc?.descendants((node: Node) => { - if (isAttachmentNode(node.type.name)) { - if (node.attrs.attachmentId && isValidUUID(node.attrs.attachmentId)) { - if (!attachmentIds.includes(node.attrs.attachmentId)) { - attachmentIds.push(node.attrs.attachmentId); - } - } - } - }); - - return attachmentIds; -} - -export function isAttachmentNode(nodeType: string) { - const attachmentNodeTypes = [ - 'attachment', - 'image', - 'video', - 'excalidraw', - 'drawio', - ]; - return attachmentNodeTypes.includes(nodeType); -} - export function updateAttachmentUrlsToLocalPaths(prosemirrorJson: any) { const doc = jsonToNode(prosemirrorJson); if (!doc) return null; diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 3ceeb789..95df255d 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -4,7 +4,12 @@ import { FastifyAdapter, NestFastifyApplication, } from '@nestjs/platform-fastify'; -import { Logger, NotFoundException, ValidationPipe } from '@nestjs/common'; +import { + Logger, + NotFoundException, + RequestMethod, + ValidationPipe, +} from '@nestjs/common'; import { TransformHttpResponseInterceptor } from './common/interceptors/http-response.interceptor'; import { WsRedisIoAdapter } from './ws/adapter/ws-redis.adapter'; import { InternalLogFilter } from './common/logger/internal-log-filter'; @@ -26,7 +31,9 @@ async function bootstrap() { }, ); - app.setGlobalPrefix('api', { exclude: ['robots.txt'] }); + app.setGlobalPrefix('api', { + exclude: ['robots.txt', 'share/:shareId/p/:pageSlug'], + }); const reflector = app.get(Reflector); const redisIoAdapter = new WsRedisIoAdapter(app); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8772d574..2fd73d8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -419,43 +419,43 @@ importers: version: 8.1.1 '@nestjs/bullmq': specifier: ^11.0.2 - version: 11.0.2(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.10)(bullmq@5.41.3) + version: 11.0.2(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.20)(bullmq@5.41.3) '@nestjs/common': - specifier: ^11.0.10 - version: 11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + specifier: ^11.0.20 + version: 11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) '@nestjs/config': - specifier: ^4.0.0 - version: 4.0.0(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(rxjs@7.8.1) + specifier: ^4.0.2 + version: 4.0.2(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(rxjs@7.8.1) '@nestjs/core': - specifier: ^11.0.10 - version: 11.0.10(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@11.0.10)(reflect-metadata@0.2.2)(rxjs@7.8.1) + specifier: ^11.0.20 + version: 11.0.20(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@11.0.20)(reflect-metadata@0.2.2)(rxjs@7.8.1) '@nestjs/event-emitter': specifier: ^3.0.0 - version: 3.0.0(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.10) + version: 3.0.0(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.20) '@nestjs/jwt': specifier: ^11.0.0 - version: 11.0.0(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1)) + version: 11.0.0(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1)) '@nestjs/mapped-types': specifier: ^2.1.0 - version: 2.1.0(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2) + version: 2.1.0(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2) '@nestjs/passport': specifier: ^11.0.5 - version: 11.0.5(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(passport@0.7.0) + version: 11.0.5(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(passport@0.7.0) '@nestjs/platform-fastify': - specifier: ^11.0.10 - version: 11.0.10(@fastify/static@8.1.1)(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.10) + specifier: ^11.0.20 + version: 11.0.20(@fastify/static@8.1.1)(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.20) '@nestjs/platform-socket.io': - specifier: ^11.0.10 - version: 11.0.10(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@11.0.10)(rxjs@7.8.1) + specifier: ^11.0.20 + version: 11.0.20(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@11.0.20)(rxjs@7.8.1) '@nestjs/schedule': specifier: ^5.0.1 - version: 5.0.1(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.10) + version: 5.0.1(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.20) '@nestjs/terminus': specifier: ^11.0.0 - version: 11.0.0(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.10)(reflect-metadata@0.2.2)(rxjs@7.8.1) + version: 11.0.0(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.20)(reflect-metadata@0.2.2)(rxjs@7.8.1) '@nestjs/websockets': - specifier: ^11.0.10 - version: 11.0.10(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.10)(@nestjs/platform-socket.io@11.0.10)(reflect-metadata@0.2.2)(rxjs@7.8.1) + specifier: ^11.0.20 + version: 11.0.20(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.20)(@nestjs/platform-socket.io@11.0.20)(reflect-metadata@0.2.2)(rxjs@7.8.1) '@node-saml/passport-saml': specifier: ^5.0.1 version: 5.0.1 @@ -509,7 +509,7 @@ importers: version: 3.3.11 nestjs-kysely: specifier: ^1.1.0 - version: 1.1.0(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.10)(kysely@0.27.5)(reflect-metadata@0.2.2) + version: 1.1.0(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.20)(kysely@0.27.5)(reflect-metadata@0.2.2) nodemailer: specifier: ^6.10.0 version: 6.10.0 @@ -564,7 +564,7 @@ importers: version: 11.0.1(chokidar@4.0.3)(typescript@5.7.3) '@nestjs/testing': specifier: ^11.0.10 - version: 11.0.10(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.10) + version: 11.0.10(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.20) '@types/bcrypt': specifier: ^5.0.2 version: 5.0.2 @@ -2083,8 +2083,8 @@ packages: '@fastify/cookie@11.0.2': resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==} - '@fastify/cors@10.0.2': - resolution: {integrity: sha512-DGdxOG36sS/tZv1NFiCJGi7wGuXOSPL2CmNX5PbOVKx0C6LuIALRMrqLByHTCcX1Rbl8NJ9IWlJex32bzydvlw==} + '@fastify/cors@11.0.1': + resolution: {integrity: sha512-dmZaE7M1f4SM8ZZuk5RhSsDJ+ezTgI7v3HHRj8Ow9CneczsPLZV6+2j2uwdaSLn8zhTv6QV0F4ZRcqdalGx1pQ==} '@fastify/deepmerge@2.0.2': resolution: {integrity: sha512-3wuLdX5iiiYeZWP6bQrjqhrcvBIf0NHbQH1Ur1WbHvoiuTYUEItgygea3zs8aHpiitn0lOB8gX20u1qO+FDm7Q==} @@ -2566,8 +2566,8 @@ packages: '@swc/core': optional: true - '@nestjs/common@11.0.10': - resolution: {integrity: sha512-pzGXp14KF2Q4CDZGQgPK4l8zEg7i6cNkb+10yc8ZA5K41cLe3ZbWW1YxtY2e/glHauOJwTLSVjH4tiRVtOTizg==} + '@nestjs/common@11.0.20': + resolution: {integrity: sha512-/GH8NDCczjn6+6RNEtSNAts/nq/wQE8L1qZ9TRjqjNqEsZNE1vpFuRIhmcO2isQZ0xY5rySnpaRdrOAul3gQ3A==} peerDependencies: class-transformer: '*' class-validator: '*' @@ -2579,14 +2579,14 @@ packages: class-validator: optional: true - '@nestjs/config@4.0.0': - resolution: {integrity: sha512-hyhUMtVwlT+tavtPNyekl8iP0QTU1U6awKrgdOSxhMhp3TQMltx7hz2yqGTcARp+19zWPfgJudyxthuD3lPp/Q==} + '@nestjs/config@4.0.2': + resolution: {integrity: sha512-McMW6EXtpc8+CwTUwFdg6h7dYcBUpH5iUILCclAsa+MbCEvC9ZKu4dCHRlJqALuhjLw97pbQu62l4+wRwGeZqA==} peerDependencies: '@nestjs/common': ^10.0.0 || ^11.0.0 rxjs: ^7.1.0 - '@nestjs/core@11.0.10': - resolution: {integrity: sha512-f0qB8ztNWZeAD4E4fUdHConmNYCa/A78U7WJu5mX9OLYfOAs3ESYCDfsH9MRUvkA4Ft4Y1uMmyJo5L4fg4+beg==} + '@nestjs/core@11.0.20': + resolution: {integrity: sha512-yUkEzBGiRNSEThVl6vMCXgoA9sDGWoRbJsTLdYdCC7lg7PE1iXBnna1FiBfQjT995pm0fjyM1e3WsXmyWeJXbw==} engines: {node: '>= 20'} peerDependencies: '@nestjs/common': ^11.0.0 @@ -2633,11 +2633,11 @@ packages: '@nestjs/common': ^10.0.0 || ^11.0.0 passport: ^0.5.0 || ^0.6.0 || ^0.7.0 - '@nestjs/platform-fastify@11.0.10': - resolution: {integrity: sha512-aOvuFsSUsfGziy6OmJwVDNx6aXougCMeUpEAlphuCLehSwfZQxhpy4SpThxTAtHU7RdmgGO7VfUGH+uUY8vHdQ==} + '@nestjs/platform-fastify@11.0.20': + resolution: {integrity: sha512-MZnjO77N/XesVzXhn8qnSEcnjXVIHxkh5zTz8SEIr6K2yWgGJZbTlNm7ul6l7QBeaCeNZtZJlvY/F+4Dbx8yCQ==} peerDependencies: '@fastify/static': ^8.0.0 - '@fastify/view': ^10.0.0 + '@fastify/view': ^10.0.0 || ^11.0.0 '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 peerDependenciesMeta: @@ -2646,8 +2646,8 @@ packages: '@fastify/view': optional: true - '@nestjs/platform-socket.io@11.0.10': - resolution: {integrity: sha512-39lAjq0+kZRiMuscDcugoG+onPDciM4jhuf8ZDjVcuSwtib1OGwrFtErSzp/KJsmHPSStgapbNev7eFi32uWQA==} + '@nestjs/platform-socket.io@11.0.20': + resolution: {integrity: sha512-fUyDjLt0wJ4WK+rXrd5/oSWw5xWpfDOknpP7YNgaFfvYW726KuS5gWysV7JPD2mgH85S6i+qiO3qZvHIs5DvxQ==} peerDependencies: '@nestjs/common': ^11.0.0 '@nestjs/websockets': ^11.0.0 @@ -2725,8 +2725,8 @@ packages: '@nestjs/platform-express': optional: true - '@nestjs/websockets@11.0.10': - resolution: {integrity: sha512-GPIEfqJyAkTHrHGK9w2OU8LJaZAZKW8WpWcTplThLxMelRq7mBkYOaGvc6dpr7fE1wWzWkwY0ZjQEnwnVmmxSg==} + '@nestjs/websockets@11.0.20': + resolution: {integrity: sha512-qcybahXdrPJFMILhAwJML9D/bExBEBFsfwFiePCeI4f//tiP0rXiLspLVOHClSeUPBaCNrx+Ae/HVe9UP+wtOg==} peerDependencies: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 @@ -3747,6 +3747,13 @@ packages: '@tiptap/core': ^2.7.0 '@tiptap/pm': ^2.7.0 + '@tokenizer/inflate@0.2.7': + resolution: {integrity: sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==} + engines: {node: '>=18'} + + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@tsconfig/node10@1.0.9': resolution: {integrity: sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==} @@ -5134,6 +5141,15 @@ packages: supports-color: optional: true + debug@4.4.0: + resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + decimal.js@10.4.3: resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==} @@ -5564,8 +5580,8 @@ packages: fastify-plugin@5.0.1: resolution: {integrity: sha512-HCxs+YnRaWzCl+cWRYFnHmeRFyR5GVnJTAaCJQiYzQSDwK9MgJdyAsuL3nh0EWRCYMgQ5MeziymvmAhUHYHDUQ==} - fastify@5.2.1: - resolution: {integrity: sha512-rslrNBF67eg8/Gyn7P2URV8/6pz8kSAscFL4EThZJ8JBMaXacVdVE4hmUcnPNKERl5o/xTiBSLfdowBRhVF1WA==} + fastify@5.3.0: + resolution: {integrity: sha512-vDpCJa4KRkHrdDMpDNtyPaIDi/ptCwoJ0M8RiefuIMvyXTgG63xYGe9DYYiCpydjh0ETIaLoSyKBNKkh7ew1eA==} fastq@1.17.1: resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} @@ -5581,6 +5597,9 @@ packages: picomatch: optional: true + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + figures@3.2.0: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} @@ -5592,6 +5611,10 @@ packages: file-saver@2.0.5: resolution: {integrity: sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==} + file-type@20.4.1: + resolution: {integrity: sha512-hw9gNZXUfZ02Jo0uafWLaFVPter5/k2rfcrjFJJHX/77xtSDOfJuEFb6oKlFV86FLP1SuyHMW1PSk0U9M5tKkQ==} + engines: {node: '>=18'} + filelist@1.0.4: resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==} @@ -6560,6 +6583,10 @@ packages: linkifyjs@4.2.0: resolution: {integrity: sha512-pCj3PrQyATaoTYKHrgWRF3SJwsm61udVh+vuls/Rl6SptiDhgE7ziUIudAedRY9QEfynmM7/RmLEfPUyw1HPCw==} + load-esm@1.0.2: + resolution: {integrity: sha512-nVAvWk/jeyrWyXEAs84mpQCYccxRqgKY4OznLuJhJCa0XsPSfdOIr2zvBZEj3IHEHbX97jjscKRRV539bW0Gpw==} + engines: {node: '>=13.2.0'} + loader-runner@4.3.0: resolution: {integrity: sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==} engines: {node: '>=6.11.5'} @@ -6813,9 +6840,6 @@ packages: mlly@1.7.3: resolution: {integrity: sha512-xUsx5n/mN0uQf4V548PKQ+YShA4/IW0KI1dZhrNrPCLG+xizETbHTkOa1f8/xut9JRPp8kQuMnz0oqwkTiLo/A==} - mnemonist@0.39.8: - resolution: {integrity: sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==} - ms@2.1.2: resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} @@ -7002,9 +7026,6 @@ packages: resolution: {integrity: sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==} engines: {node: '>= 0.4'} - obliterator@2.0.4: - resolution: {integrity: sha512-lgHwxlxV1qIg1Eap7LgIeoBWIMFibOjbrYPIPJZcI1mmGAI2m3lNYpK12Y+GBdPQ0U1hRwSord7GIaawz962qQ==} - obuf@1.1.2: resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} @@ -7165,6 +7186,10 @@ packages: peberminta@0.9.0: resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} + peek-readable@7.0.0: + resolution: {integrity: sha512-nri2TO5JE3/mRryik9LlHFT53cgHfRK0Lt0BAZQXku/AW3E6XLt2GaY8siWi7dvW/m1z0ecn+J+bpDa9ZN3IsQ==} + engines: {node: '>=18'} + pg-cloudflare@1.1.1: resolution: {integrity: sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==} @@ -7388,6 +7413,9 @@ packages: process-warning@4.0.0: resolution: {integrity: sha512-/MyYDxttz7DfGMMHiysAsFE4qF+pQYAA8ziO/3NcRVrQ5fSk+Mns4QZA/oRPFzvcqNoVJXQNWNAsdwBXLUkQKw==} + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + process@0.11.10: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} @@ -7874,6 +7902,9 @@ packages: secure-json-parse@3.0.2: resolution: {integrity: sha512-H6nS2o8bWfpFEV6U38sOSjS7bTbdgbCGU9wEM6W14P5H0QOsz94KCusifV44GpHDTu2nqZbuDNhTzu+mjDSw1w==} + secure-json-parse@4.0.0: + resolution: {integrity: sha512-dxtLJO6sc35jWidmLxo7ij+Eg48PM/kleBsxpC8QJE0qJICe+KawkDQmvCMZUr9u7WKVHgMW6vy3fQ7zMiFZMA==} + selderee@0.11.0: resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==} @@ -8091,6 +8122,10 @@ packages: strnum@1.0.5: resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==} + strtok3@10.2.2: + resolution: {integrity: sha512-Xt18+h4s7Z8xyZ0tmBoRmzxcop97R4BAh+dXouUDCYn+Em+1P3qpkUfI5ueWLT8ynC5hZ+q4iPEmGG1urvQGBg==} + engines: {node: '>=18'} + styled-jsx@5.1.1: resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==} engines: {node: '>= 12.0.0'} @@ -8234,6 +8269,10 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + token-types@6.0.0: + resolution: {integrity: sha512-lbDrTLVsHhOMljPscd0yitpozq7Ga2M5Cvez5AjGg8GASBjtt6iERCAJ93yommPmz62fb45oFIXHEZ3u9bfJEA==} + engines: {node: '>=14.16'} + tough-cookie@5.1.0: resolution: {integrity: sha512-rvZUv+7MoBYTiDmFPBrhL7Ujx9Sk+q9wwm22x8c8T5IJaR+Wsyc7TNxbVxo84kZoRJZZMazowFLqpankBEQrGg==} engines: {node: '>=16'} @@ -8432,6 +8471,10 @@ packages: resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} engines: {node: '>=8'} + uint8array-extras@1.4.0: + resolution: {integrity: sha512-ZPtzy0hu4cZjv3z5NW9gfKnNLjoz4y6uv4HlelAjDK7sY/xOkKZv9xK/WQpcsBB3jEybChz9DPC2U/+cusjJVQ==} + engines: {node: '>=18'} + unbox-primitive@1.0.2: resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} @@ -10906,8 +10949,8 @@ snapshots: '@fastify/ajv-compiler@4.0.2': dependencies: - ajv: 8.12.0 - ajv-formats: 3.0.1(ajv@8.12.0) + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) fast-uri: 3.0.6 '@fastify/busboy@3.1.1': {} @@ -10917,10 +10960,10 @@ snapshots: cookie: 1.0.2 fastify-plugin: 5.0.1 - '@fastify/cors@10.0.2': + '@fastify/cors@11.0.1': dependencies: fastify-plugin: 5.0.1 - mnemonist: 0.39.8 + toad-cache: 3.7.0 '@fastify/deepmerge@2.0.2': {} @@ -11545,17 +11588,17 @@ snapshots: '@emnapi/runtime': 1.2.0 '@tybys/wasm-util': 0.9.0 - '@nestjs/bull-shared@11.0.2(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.10)': + '@nestjs/bull-shared@11.0.2(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.20)': dependencies: - '@nestjs/common': 11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@nestjs/core': 11.0.10(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@11.0.10)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/common': 11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/core': 11.0.20(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@11.0.20)(reflect-metadata@0.2.2)(rxjs@7.8.1) tslib: 2.8.1 - '@nestjs/bullmq@11.0.2(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.10)(bullmq@5.41.3)': + '@nestjs/bullmq@11.0.2(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.20)(bullmq@5.41.3)': dependencies: - '@nestjs/bull-shared': 11.0.2(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.10) - '@nestjs/common': 11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@nestjs/core': 11.0.10(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@11.0.10)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/bull-shared': 11.0.2(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.20) + '@nestjs/common': 11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/core': 11.0.20(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@11.0.20)(reflect-metadata@0.2.2)(rxjs@7.8.1) bullmq: 5.41.3 tslib: 2.8.1 @@ -11588,9 +11631,11 @@ snapshots: - uglify-js - webpack-cli - '@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1)': + '@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1)': dependencies: + file-type: 20.4.1 iterare: 1.2.1 + load-esm: 1.0.2 reflect-metadata: 0.2.2 rxjs: 7.8.1 tslib: 2.8.1 @@ -11598,18 +11643,20 @@ snapshots: optionalDependencies: class-transformer: 0.5.1 class-validator: 0.14.1 + transitivePeerDependencies: + - supports-color - '@nestjs/config@4.0.0(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(rxjs@7.8.1)': + '@nestjs/config@4.0.2(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(rxjs@7.8.1)': dependencies: - '@nestjs/common': 11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/common': 11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) dotenv: 16.4.7 dotenv-expand: 12.0.1 lodash: 4.17.21 rxjs: 7.8.1 - '@nestjs/core@11.0.10(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@11.0.10)(reflect-metadata@0.2.2)(rxjs@7.8.1)': + '@nestjs/core@11.0.20(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@11.0.20)(reflect-metadata@0.2.2)(rxjs@7.8.1)': dependencies: - '@nestjs/common': 11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/common': 11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) '@nuxt/opencollective': 0.4.1 fast-safe-stringify: 2.1.1 iterare: 1.2.1 @@ -11619,51 +11666,52 @@ snapshots: tslib: 2.8.1 uid: 2.0.2 optionalDependencies: - '@nestjs/websockets': 11.0.10(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.10)(@nestjs/platform-socket.io@11.0.10)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/websockets': 11.0.20(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.20)(@nestjs/platform-socket.io@11.0.20)(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@nestjs/event-emitter@3.0.0(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.10)': + '@nestjs/event-emitter@3.0.0(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.20)': dependencies: - '@nestjs/common': 11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@nestjs/core': 11.0.10(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@11.0.10)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/common': 11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/core': 11.0.20(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@11.0.20)(reflect-metadata@0.2.2)(rxjs@7.8.1) eventemitter2: 6.4.9 - '@nestjs/jwt@11.0.0(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))': + '@nestjs/jwt@11.0.0(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))': dependencies: - '@nestjs/common': 11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/common': 11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) '@types/jsonwebtoken': 9.0.7 jsonwebtoken: 9.0.2 - '@nestjs/mapped-types@2.1.0(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)': + '@nestjs/mapped-types@2.1.0(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)': dependencies: - '@nestjs/common': 11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/common': 11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) reflect-metadata: 0.2.2 optionalDependencies: class-transformer: 0.5.1 class-validator: 0.14.1 - '@nestjs/passport@11.0.5(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(passport@0.7.0)': + '@nestjs/passport@11.0.5(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(passport@0.7.0)': dependencies: - '@nestjs/common': 11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/common': 11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) passport: 0.7.0 - '@nestjs/platform-fastify@11.0.10(@fastify/static@8.1.1)(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.10)': + '@nestjs/platform-fastify@11.0.20(@fastify/static@8.1.1)(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.20)': dependencies: - '@fastify/cors': 10.0.2 + '@fastify/cors': 11.0.1 '@fastify/formbody': 8.0.2 '@fastify/middie': 9.0.3 - '@nestjs/common': 11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@nestjs/core': 11.0.10(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@11.0.10)(reflect-metadata@0.2.2)(rxjs@7.8.1) - fastify: 5.2.1 + '@nestjs/common': 11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/core': 11.0.20(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@11.0.20)(reflect-metadata@0.2.2)(rxjs@7.8.1) + fast-querystring: 1.1.2 + fastify: 5.3.0 light-my-request: 6.6.0 path-to-regexp: 8.2.0 tslib: 2.8.1 optionalDependencies: '@fastify/static': 8.1.1 - '@nestjs/platform-socket.io@11.0.10(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@11.0.10)(rxjs@7.8.1)': + '@nestjs/platform-socket.io@11.0.20(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@11.0.20)(rxjs@7.8.1)': dependencies: - '@nestjs/common': 11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@nestjs/websockets': 11.0.10(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.10)(@nestjs/platform-socket.io@11.0.10)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/common': 11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/websockets': 11.0.20(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.20)(@nestjs/platform-socket.io@11.0.20)(reflect-metadata@0.2.2)(rxjs@7.8.1) rxjs: 7.8.1 socket.io: 4.8.1 tslib: 2.8.1 @@ -11672,10 +11720,10 @@ snapshots: - supports-color - utf-8-validate - '@nestjs/schedule@5.0.1(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.10)': + '@nestjs/schedule@5.0.1(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.20)': dependencies: - '@nestjs/common': 11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@nestjs/core': 11.0.10(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@11.0.10)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/common': 11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/core': 11.0.20(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@11.0.20)(reflect-metadata@0.2.2)(rxjs@7.8.1) cron: 3.5.0 '@nestjs/schematics@11.0.1(chokidar@4.0.3)(typescript@5.7.3)': @@ -11689,32 +11737,32 @@ snapshots: transitivePeerDependencies: - chokidar - '@nestjs/terminus@11.0.0(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.10)(reflect-metadata@0.2.2)(rxjs@7.8.1)': + '@nestjs/terminus@11.0.0(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.20)(reflect-metadata@0.2.2)(rxjs@7.8.1)': dependencies: - '@nestjs/common': 11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@nestjs/core': 11.0.10(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@11.0.10)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/common': 11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/core': 11.0.20(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@11.0.20)(reflect-metadata@0.2.2)(rxjs@7.8.1) boxen: 5.1.2 check-disk-space: 3.4.0 reflect-metadata: 0.2.2 rxjs: 7.8.1 - '@nestjs/testing@11.0.10(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.10)': + '@nestjs/testing@11.0.10(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.20)': dependencies: - '@nestjs/common': 11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@nestjs/core': 11.0.10(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@11.0.10)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/common': 11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/core': 11.0.20(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@11.0.20)(reflect-metadata@0.2.2)(rxjs@7.8.1) tslib: 2.8.1 - '@nestjs/websockets@11.0.10(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.10)(@nestjs/platform-socket.io@11.0.10)(reflect-metadata@0.2.2)(rxjs@7.8.1)': + '@nestjs/websockets@11.0.20(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.20)(@nestjs/platform-socket.io@11.0.20)(reflect-metadata@0.2.2)(rxjs@7.8.1)': dependencies: - '@nestjs/common': 11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@nestjs/core': 11.0.10(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@11.0.10)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/common': 11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/core': 11.0.20(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@11.0.20)(reflect-metadata@0.2.2)(rxjs@7.8.1) iterare: 1.2.1 object-hash: 3.0.0 reflect-metadata: 0.2.2 rxjs: 7.8.1 tslib: 2.8.1 optionalDependencies: - '@nestjs/platform-socket.io': 11.0.10(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@11.0.10)(rxjs@7.8.1) + '@nestjs/platform-socket.io': 11.0.20(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@11.0.20)(rxjs@7.8.1) '@next/env@14.2.10': {} @@ -11798,7 +11846,7 @@ snapshots: nx: 20.4.5(@swc/core@1.5.25(@swc/helpers@0.5.5)) semver: 7.6.3 tmp: 0.2.1 - tslib: 2.8.0 + tslib: 2.8.1 yargs-parser: 21.1.1 '@nx/js@20.4.5(@babel/traverse@7.27.0)(@swc/core@1.5.25(@swc/helpers@0.5.5))(@types/node@22.13.4)(nx@20.4.5(@swc/core@1.5.25(@swc/helpers@0.5.5)))(typescript@5.7.3)': @@ -11880,7 +11928,7 @@ snapshots: chalk: 4.1.2 enquirer: 2.3.6 nx: 20.4.5(@swc/core@1.5.25(@swc/helpers@0.5.5)) - tslib: 2.8.0 + tslib: 2.8.1 yargs-parser: 21.1.1 transitivePeerDependencies: - '@swc-node/register' @@ -12797,6 +12845,16 @@ snapshots: '@tiptap/core': 2.10.3(@tiptap/pm@2.10.3) '@tiptap/pm': 2.10.3 + '@tokenizer/inflate@0.2.7': + dependencies: + debug: 4.4.0 + fflate: 0.8.2 + token-types: 6.0.0 + transitivePeerDependencies: + - supports-color + + '@tokenizer/token@0.3.0': {} + '@tsconfig/node10@1.0.9': {} '@tsconfig/node12@1.0.11': {} @@ -13521,10 +13579,6 @@ snapshots: optionalDependencies: ajv: 8.12.0 - ajv-formats@3.0.1(ajv@8.12.0): - optionalDependencies: - ajv: 8.12.0 - ajv-formats@3.0.1(ajv@8.17.1): optionalDependencies: ajv: 8.17.1 @@ -14459,6 +14513,10 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.4.0: + dependencies: + ms: 2.1.3 + decimal.js@10.4.3: {} dedent@1.5.1(babel-plugin-macros@3.1.0): @@ -14574,7 +14632,7 @@ snapshots: dotenv-expand@11.0.6: dependencies: - dotenv: 16.4.5 + dotenv: 16.4.7 dotenv-expand@12.0.1: dependencies: @@ -15034,8 +15092,8 @@ snapshots: fast-json-stringify@6.0.1: dependencies: '@fastify/merge-json-schemas': 0.2.1 - ajv: 8.12.0 - ajv-formats: 3.0.1(ajv@8.12.0) + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) fast-uri: 3.0.6 json-schema-ref-resolver: 2.0.1 rfdc: 1.3.1 @@ -15058,7 +15116,7 @@ snapshots: fastify-plugin@5.0.1: {} - fastify@5.2.1: + fastify@5.3.0: dependencies: '@fastify/ajv-compiler': 4.0.2 '@fastify/error': 4.0.0 @@ -15070,10 +15128,10 @@ snapshots: find-my-way: 9.2.0 light-my-request: 6.6.0 pino: 9.1.0 - process-warning: 4.0.0 + process-warning: 5.0.0 rfdc: 1.3.1 - secure-json-parse: 3.0.2 - semver: 7.6.3 + secure-json-parse: 4.0.0 + semver: 7.7.1 toad-cache: 3.7.0 fastq@1.17.1: @@ -15088,6 +15146,8 @@ snapshots: optionalDependencies: picomatch: 4.0.2 + fflate@0.8.2: {} + figures@3.2.0: dependencies: escape-string-regexp: 1.0.5 @@ -15098,6 +15158,15 @@ snapshots: file-saver@2.0.5: {} + file-type@20.4.1: + dependencies: + '@tokenizer/inflate': 0.2.7 + strtok3: 10.2.2 + token-types: 6.0.0 + uint8array-extras: 1.4.0 + transitivePeerDependencies: + - supports-color + filelist@1.0.4: dependencies: minimatch: 5.1.6 @@ -16293,6 +16362,8 @@ snapshots: linkifyjs@4.2.0: {} + load-esm@1.0.2: {} + loader-runner@4.3.0: {} local-pkg@0.5.1: @@ -16529,10 +16600,6 @@ snapshots: pkg-types: 1.2.1 ufo: 1.5.4 - mnemonist@0.39.8: - dependencies: - obliterator: 2.0.4 - ms@2.1.2: {} ms@2.1.3: {} @@ -16571,10 +16638,10 @@ snapshots: neo-async@2.6.2: {} - nestjs-kysely@1.1.0(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.10)(kysely@0.27.5)(reflect-metadata@0.2.2): + nestjs-kysely@1.1.0(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.20)(kysely@0.27.5)(reflect-metadata@0.2.2): dependencies: - '@nestjs/common': 11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@nestjs/core': 11.0.10(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@11.0.10)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/common': 11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/core': 11.0.20(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@11.0.20)(reflect-metadata@0.2.2)(rxjs@7.8.1) kysely: 0.27.5 reflect-metadata: 0.2.2 @@ -16748,8 +16815,6 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.0.0 - obliterator@2.0.4: {} - obuf@1.1.2: {} oidc-token-hash@5.0.3: {} @@ -16919,6 +16984,8 @@ snapshots: peberminta@0.9.0: {} + peek-readable@7.0.0: {} + pg-cloudflare@1.1.1: optional: true @@ -17128,6 +17195,8 @@ snapshots: process-warning@4.0.0: {} + process-warning@5.0.0: {} + process@0.11.10: {} prompts@2.4.2: @@ -17703,6 +17772,8 @@ snapshots: secure-json-parse@3.0.2: {} + secure-json-parse@4.0.0: {} + selderee@0.11.0: dependencies: parseley: 0.12.1 @@ -17958,6 +18029,11 @@ snapshots: strnum@1.0.5: {} + strtok3@10.2.2: + dependencies: + '@tokenizer/token': 0.3.0 + peek-readable: 7.0.0 + styled-jsx@5.1.1(@babel/core@7.24.5)(babel-plugin-macros@3.1.0)(react@18.3.1): dependencies: client-only: 0.0.1 @@ -18099,6 +18175,11 @@ snapshots: toidentifier@1.0.1: {} + token-types@6.0.0: + dependencies: + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + tough-cookie@5.1.0: dependencies: tldts: 6.1.72 @@ -18304,6 +18385,8 @@ snapshots: dependencies: '@lukeed/csprng': 1.1.0 + uint8array-extras@1.4.0: {} + unbox-primitive@1.0.2: dependencies: call-bind: 1.0.7