mirror of
https://github.com/docmost/docmost.git
synced 2025-11-10 07:12:04 +10:00
Compare commits
38 Commits
e2b8899569
...
feat/shari
| Author | SHA1 | Date | |
|---|---|---|---|
| 57a3d5697d | |||
| 660555068b | |||
| 6cfddead24 | |||
| 7cd2c18b12 | |||
| 33738fba03 | |||
| dfb5e0dd59 | |||
| 07ed1a6a78 | |||
| f74688663d | |||
| a66fb875ac | |||
| e8082a0680 | |||
| deb6c9c09b | |||
| cf7f5bbd19 | |||
| 3e195e7fa1 | |||
| 86e8795c1f | |||
| 644cbd62de | |||
| b5b48378ab | |||
| a207ca7855 | |||
| 9ce708bb7d | |||
| f029381952 | |||
| 3efb62dec3 | |||
| 6f80ea56df | |||
| 0ff41d06fa | |||
| 410dce9ea9 | |||
| 9e5339c86b | |||
| c93a96ef55 | |||
| 56b97f306b | |||
| fa6c10b07f | |||
| 5ccea524c7 | |||
| c20931bd95 | |||
| 6543ef16f8 | |||
| 99540141ea | |||
| 5bdefda9c7 | |||
| 418e61614c | |||
| 8dff3e2240 | |||
| 16a253ec40 | |||
| 4f52d32bef | |||
| b5c90ec775 | |||
| 18e8c4cbaf |
@ -6,6 +6,7 @@
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Docmost</title>
|
||||
<!--meta-tags-->
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<Route element={<ShareLayout />}>
|
||||
<Route path={"/share/:shareId/p/:pageSlug"} element={<SharedPage />} />
|
||||
<Route path={"/share/p/:pageSlug"} element={<SharedPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path={"/share/:shareId"} element={<ShareRedirect />} />
|
||||
<Route path={"/p/:pageSlug"} element={<PageRedirect />} />
|
||||
|
||||
<Route element={<Layout />}>
|
||||
@ -78,6 +88,7 @@ export default function App() {
|
||||
<Route path={"groups"} element={<Groups />} />
|
||||
<Route path={"groups/:groupId"} element={<GroupInfo />} />
|
||||
<Route path={"spaces"} element={<Spaces />} />
|
||||
<Route path={"sharing"} element={<Shares />} />
|
||||
<Route path={"security"} element={<Security />} />
|
||||
{!isCloud() && <Route path={"license"} element={<License />} />}
|
||||
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
||||
|
||||
@ -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({
|
||||
<AppShell.Navbar
|
||||
className={classes.navbar}
|
||||
withBorder={false}
|
||||
ref={sidebarRef}
|
||||
ref={mergedRef}
|
||||
>
|
||||
<div className={classes.resizeHandle} onMouseDown={startResizing} />
|
||||
{isSpaceRoute && <SpaceSidebar />}
|
||||
@ -111,7 +121,7 @@ export default function GlobalAppShell({
|
||||
)}
|
||||
<AppShell.Main>
|
||||
{isSettingsRoute ? (
|
||||
<Container size={800}>{children}</Container>
|
||||
<Container size={850}>{children}</Container>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
|
||||
@ -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(),
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export const prefetchShares = () => {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["share-list", { page: 1 }],
|
||||
queryFn: () => getShares({ page: 1, limit: 100 }),
|
||||
});
|
||||
};
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<item.icon className={classes.linkIcon} stroke={2} />
|
||||
<span>{t(item.label)}</span>
|
||||
|
||||
19
apps/client/src/components/theme-toggle.module.css
Normal file
19
apps/client/src/components/theme-toggle.module.css
Normal file
@ -0,0 +1,19 @@
|
||||
.dark {
|
||||
@mixin dark {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@mixin light {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.light {
|
||||
@mixin light {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
@ -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 (
|
||||
<Group justify="center" mt="xl">
|
||||
<Button onClick={() => setColorScheme('light')}>Light</Button>
|
||||
<Button onClick={() => setColorScheme('dark')}>Dark</Button>
|
||||
<Button onClick={() => setColorScheme('auto')}>Auto</Button>
|
||||
</Group>
|
||||
);
|
||||
return (
|
||||
<Tooltip label="Toggle Color Scheme">
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
setColorScheme(computedColorScheme === "light" ? "dark" : "light");
|
||||
}}
|
||||
aria-label="Toggle color scheme"
|
||||
>
|
||||
<IconSun className={classes.light} size={18} stroke={1.5} />
|
||||
<IconMoon className={classes.dark} size={18} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@ -5,4 +5,6 @@ export const pageEditorAtom = atom<Editor | null>(null);
|
||||
|
||||
export const titleEditorAtom = atom<Editor | null>(null);
|
||||
|
||||
export const readOnlyEditorAtom = atom<Editor | null>(null);
|
||||
|
||||
export const yjsConnectionStatusAtom = atom<string>("");
|
||||
|
||||
@ -139,7 +139,7 @@ export default function DrawioView(props: NodeViewProps) {
|
||||
)}
|
||||
/>
|
||||
|
||||
{selected && (
|
||||
{selected && editor.isEditable && (
|
||||
<ActionIcon
|
||||
onClick={handleOpen}
|
||||
variant="default"
|
||||
|
||||
@ -170,7 +170,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
||||
)}
|
||||
/>
|
||||
|
||||
{selected && (
|
||||
{selected && editor.isEditable && (
|
||||
<ActionIcon
|
||||
onClick={handleOpen}
|
||||
variant="default"
|
||||
|
||||
@ -1,21 +1,34 @@
|
||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { ActionIcon, Anchor, Text } from "@mantine/core";
|
||||
import { IconFileDescription } from "@tabler/icons-react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { Link, useLocation, useParams } from "react-router-dom";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import {
|
||||
buildPageUrl,
|
||||
buildSharedPageUrl,
|
||||
} from "@/features/page/page.utils.ts";
|
||||
import classes from "./mention.module.css";
|
||||
|
||||
export default function MentionView(props: NodeViewProps) {
|
||||
const { node } = props;
|
||||
const { label, entityType, entityId, slugId } = node.attrs;
|
||||
const { spaceSlug } = useParams();
|
||||
const { shareId } = useParams();
|
||||
const {
|
||||
data: page,
|
||||
isLoading,
|
||||
isError,
|
||||
} = usePageQuery({ pageId: entityType === "page" ? slugId : null });
|
||||
|
||||
const location = useLocation();
|
||||
const isShareRoute = location.pathname.startsWith("/share");
|
||||
|
||||
const shareSlugUrl = buildSharedPageUrl({
|
||||
shareId,
|
||||
pageSlugId: slugId,
|
||||
pageTitle: label,
|
||||
});
|
||||
|
||||
return (
|
||||
<NodeViewWrapper style={{ display: "inline" }}>
|
||||
{entityType === "user" && (
|
||||
@ -28,7 +41,9 @@ export default function MentionView(props: NodeViewProps) {
|
||||
<Anchor
|
||||
component={Link}
|
||||
fw={500}
|
||||
to={buildPageUrl(spaceSlug, slugId, label)}
|
||||
to={
|
||||
isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label)
|
||||
}
|
||||
underline="never"
|
||||
className={classes.pageMentionLink}
|
||||
>
|
||||
|
||||
@ -52,3 +52,8 @@
|
||||
) !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.leftBorder {
|
||||
border-left: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
type TableOfContentsProps = {
|
||||
editor: ReturnType<typeof useEditor>;
|
||||
isShare?: boolean;
|
||||
};
|
||||
|
||||
export type HeadingLink = {
|
||||
@ -73,6 +74,7 @@ export const TableOfContents: FC<TableOfContentsProps> = (props) => {
|
||||
|
||||
const handleUpdate = () => {
|
||||
const result = recalculateLinks(props.editor?.$nodes("heading"));
|
||||
|
||||
setLinks(result.links);
|
||||
setHeadingDOMNodes(result.nodes);
|
||||
};
|
||||
@ -85,9 +87,12 @@ export const TableOfContents: FC<TableOfContentsProps> = (props) => {
|
||||
};
|
||||
}, [props.editor]);
|
||||
|
||||
useEffect(() => {
|
||||
handleUpdate();
|
||||
}, []);
|
||||
useEffect(
|
||||
() => {
|
||||
handleUpdate();
|
||||
},
|
||||
props.isShare ? [props.editor] : [],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
@ -133,16 +138,23 @@ export const TableOfContents: FC<TableOfContentsProps> = (props) => {
|
||||
if (!links.length) {
|
||||
return (
|
||||
<>
|
||||
<Text size="sm">
|
||||
{t("Add headings (H1, H2, H3) to generate a table of contents.")}
|
||||
</Text>
|
||||
{!props.isShare && (
|
||||
<Text size="sm">
|
||||
{t("Add headings (H1, H2, H3) to generate a table of contents.")}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
{props.isShare && (
|
||||
<Text mb="md" fw={500}>
|
||||
{t("Table of contents")}
|
||||
</Text>
|
||||
)}
|
||||
<div className={props.isShare ? classes.leftBorder : ""}>
|
||||
{links.map((item, idx) => (
|
||||
<Box<"button">
|
||||
component="button"
|
||||
|
||||
67
apps/client/src/features/editor/readonly-page-editor.tsx
Normal file
67
apps/client/src/features/editor/readonly-page-editor.tsx
Normal file
@ -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 (
|
||||
<>
|
||||
<EditorProvider
|
||||
editable={false}
|
||||
immediatelyRender={true}
|
||||
extensions={titleExtensions}
|
||||
content={title}
|
||||
></EditorProvider>
|
||||
|
||||
<EditorProvider
|
||||
editable={false}
|
||||
immediatelyRender={true}
|
||||
extensions={extensions}
|
||||
content={content}
|
||||
onCreate={({ editor }) => {
|
||||
if (editor) {
|
||||
// @ts-ignore
|
||||
setReadOnlyEditor(editor);
|
||||
}
|
||||
}}
|
||||
></EditorProvider>
|
||||
<div style={{ paddingBottom: "20vh" }}></div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
flex-wrap: nowrap;
|
||||
|
||||
a {
|
||||
color: var(--mantine-color-default-color);
|
||||
|
||||
@ -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) => (
|
||||
<Button.Group orientation="vertical" key={node.id}>
|
||||
<Button
|
||||
justify="start"
|
||||
@ -59,17 +62,39 @@ export default function Breadcrumb() {
|
||||
</Button.Group>
|
||||
));
|
||||
|
||||
const renderAnchor = (node: SpaceTreeNode) => (
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={buildPageUrl(spaceSlug, node.slugId, node.name)}
|
||||
underline="never"
|
||||
fz={"sm"}
|
||||
key={node.id}
|
||||
className={classes.truncatedText}
|
||||
>
|
||||
{getTitle(node.name, node.icon)}
|
||||
</Anchor>
|
||||
const MobileHiddenNodesTooltipContent = () =>
|
||||
breadcrumbNodes?.map((node) => (
|
||||
<Button.Group orientation="vertical" key={node.id}>
|
||||
<Button
|
||||
justify="start"
|
||||
component={Link}
|
||||
to={buildPageUrl(spaceSlug, node.slugId, node.name)}
|
||||
variant="default"
|
||||
style={{ border: "none" }}
|
||||
>
|
||||
<Text fz={"sm"} className={classes.truncatedText}>
|
||||
{getTitle(node.name, node.icon)}
|
||||
</Text>
|
||||
</Button>
|
||||
</Button.Group>
|
||||
));
|
||||
|
||||
const renderAnchor = useCallback(
|
||||
(node: SpaceTreeNode) => (
|
||||
<Tooltip label={node.name} key={node.id}>
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={buildPageUrl(spaceSlug, node.slugId, node.name)}
|
||||
underline="never"
|
||||
fz="sm"
|
||||
key={node.id}
|
||||
className={classes.truncatedText}
|
||||
>
|
||||
{getTitle(node.name, node.icon)}
|
||||
</Anchor>
|
||||
</Tooltip>
|
||||
),
|
||||
[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() {
|
||||
<HiddenNodesTooltipContent />
|
||||
</Popover.Dropdown>
|
||||
</Popover>,
|
||||
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 [
|
||||
<Popover
|
||||
width={250}
|
||||
position="bottom"
|
||||
withArrow
|
||||
shadow="xl"
|
||||
key="mobile-hidden-nodes"
|
||||
>
|
||||
<Popover.Target>
|
||||
<Tooltip label="Breadcrumbs">
|
||||
<ActionIcon color="gray" variant="transparent">
|
||||
<IconCornerDownRightDouble size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<MobileHiddenNodesTooltipContent />
|
||||
</Popover.Dropdown>
|
||||
</Popover>,
|
||||
];
|
||||
}
|
||||
|
||||
return breadcrumbNodes.map(renderAnchor);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ overflow: "hidden" }}>
|
||||
{breadcrumbNodes && (
|
||||
<Breadcrumbs className={classes.breadcrumbs}>
|
||||
{getBreadcrumbItems()}
|
||||
{isMobile ? getMobileBreadcrumbItems() : getBreadcrumbItems()}
|
||||
</Breadcrumbs>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -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) {
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<ShareModal readOnly={readOnly}/>
|
||||
|
||||
<Tooltip label={t("Comments")} openDelay={250} withArrow>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
|
||||
@ -8,7 +8,7 @@ const buildPageSlug = (pageSlugId: string, pageTitle?: string): string => {
|
||||
],
|
||||
});
|
||||
|
||||
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)}`;
|
||||
};
|
||||
|
||||
@ -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<any>) {
|
||||
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<any>) {
|
||||
}
|
||||
}
|
||||
|
||||
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<any>) {
|
||||
}, 650);
|
||||
}
|
||||
|
||||
const pageUrl = buildPageUrl(spaceSlug, node.data.slugId, node.data.name);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
<Box
|
||||
style={style}
|
||||
className={clsx(classes.node, node.state)}
|
||||
component={Link}
|
||||
to={pageUrl}
|
||||
// @ts-ignore
|
||||
ref={dragHandle}
|
||||
onClick={handleClick}
|
||||
onClick={() => {
|
||||
if (mobileSidebarOpened) {
|
||||
toggleMobileSidebar();
|
||||
}
|
||||
}}
|
||||
onMouseEnter={prefetchPage}
|
||||
onMouseLeave={cancelPagePrefetch}
|
||||
>
|
||||
@ -385,7 +392,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
9
apps/client/src/features/share/atoms/sidebar-atom.ts
Normal file
9
apps/client/src/features/share/atoms/sidebar-atom.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { atomWithWebStorage } from "@/lib/jotai-helper.ts";
|
||||
import { atom } from 'jotai';
|
||||
|
||||
export const tableOfContentAsideAtom = atomWithWebStorage<boolean>(
|
||||
"showTOC",
|
||||
true,
|
||||
);
|
||||
|
||||
export const mobileTableOfContentAsideAtom = atom<boolean>(false);
|
||||
106
apps/client/src/features/share/components/share-action-menu.tsx
Normal file
106
apps/client/src/features/share/components/share-action-menu.tsx
Normal file
@ -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: (
|
||||
<Text size="sm">
|
||||
{t("Are you sure you want to delete this shared link?")}
|
||||
</Text>
|
||||
),
|
||||
centered: true,
|
||||
labels: { confirm: t("Delete"), cancel: t("Don't") },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: onDelete,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu
|
||||
shadow="xl"
|
||||
position="bottom-end"
|
||||
offset={20}
|
||||
width={200}
|
||||
withArrow
|
||||
arrowPosition="center"
|
||||
>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" c="gray">
|
||||
<IconDots size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={copyLink} leftSection={<IconCopy size={16} />}>
|
||||
{t("Copy link")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
onClick={openPage}
|
||||
leftSection={<IconFileDescription size={16} />}
|
||||
>
|
||||
{t("Open page")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
c="red"
|
||||
onClick={openDeleteModal}
|
||||
leftSection={<IconTrash size={16} />}
|
||||
disabled={share.space?.userRole === "reader"}
|
||||
>
|
||||
{t("Delete share")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
10
apps/client/src/features/share/components/share-layout.tsx
Normal file
10
apps/client/src/features/share/components/share-layout.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
import ShareShell from "@/features/share/components/share-shell.tsx";
|
||||
|
||||
export default function ShareLayout() {
|
||||
return (
|
||||
<ShareShell>
|
||||
<Outlet />
|
||||
</ShareShell>
|
||||
);
|
||||
}
|
||||
97
apps/client/src/features/share/components/share-list.tsx
Normal file
97
apps/client/src/features/share/components/share-list.tsx
Normal file
@ -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 (
|
||||
<>
|
||||
<Table.ScrollContainer minWidth={500}>
|
||||
<Table verticalSpacing="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("Page")}</Table.Th>
|
||||
<Table.Th>{t("Shared by")}</Table.Th>
|
||||
<Table.Th>{t("Shared at")}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
|
||||
<Table.Tbody>
|
||||
{data?.items.map((share: ISharedItem, index: number) => (
|
||||
<Table.Tr key={index}>
|
||||
<Table.Td>
|
||||
<Anchor
|
||||
size="sm"
|
||||
underline="never"
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
color: "var(--mantine-color-text)",
|
||||
}}
|
||||
component={Link}
|
||||
target="_blank"
|
||||
to={buildSharedPageUrl({
|
||||
shareId: share.key,
|
||||
pageTitle: share.page.title,
|
||||
pageSlugId: share.page.slugId,
|
||||
})}
|
||||
>
|
||||
<Group gap="4" wrap="nowrap">
|
||||
{getPageIcon(share.page.icon)}
|
||||
<div className={classes.shareLinkText}>
|
||||
<Text fz="sm" fw={500} lineClamp={1}>
|
||||
{share.page.title || t("untitled")}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Anchor>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="4" wrap="nowrap">
|
||||
<CustomAvatar
|
||||
avatarUrl={share.creator?.avatarUrl}
|
||||
name={share.creator.name}
|
||||
size="sm"
|
||||
/>
|
||||
<Text fz="sm" lineClamp={1}>
|
||||
{share.creator.name}
|
||||
</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
|
||||
{format(new Date(share.createdAt), "MMM dd, yyyy")}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<ShareActionMenu share={share} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
|
||||
{data?.items.length > 0 && (
|
||||
<Paginate
|
||||
currentPage={page}
|
||||
hasPrevPage={data?.meta.hasPrevPage}
|
||||
hasNextPage={data?.meta.hasNextPage}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
227
apps/client/src/features/share/components/share-modal.tsx
Normal file
227
apps/client/src/features/share/components/share-modal.tsx
Normal file
@ -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<boolean>(false);
|
||||
useEffect(() => {
|
||||
if (share) {
|
||||
setIsPagePublic(true);
|
||||
} else {
|
||||
setIsPagePublic(false);
|
||||
}
|
||||
}, [share, pageId]);
|
||||
|
||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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<HTMLInputElement>,
|
||||
) => {
|
||||
const value = event.currentTarget.checked;
|
||||
updateShareMutation.mutateAsync({
|
||||
shareId: share.id,
|
||||
includeSubPages: value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleIndexSearchChange = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const value = event.currentTarget.checked;
|
||||
updateShareMutation.mutateAsync({
|
||||
shareId: share.id,
|
||||
searchIndexing: value,
|
||||
});
|
||||
};
|
||||
|
||||
const shareLink = useMemo(() => (
|
||||
<Group my="sm" gap={4} wrap="nowrap">
|
||||
<TextInput
|
||||
variant="filled"
|
||||
value={publicLink}
|
||||
readOnly
|
||||
rightSection={<CopyTextButton text={publicLink} />}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
<ActionIcon
|
||||
component="a"
|
||||
variant="default"
|
||||
target="_blank"
|
||||
href={publicLink}
|
||||
size="sm"
|
||||
>
|
||||
<IconExternalLink size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
), [publicLink]);
|
||||
|
||||
return (
|
||||
<Popover width={350} position="bottom" withArrow shadow="md">
|
||||
<Popover.Target>
|
||||
<Button
|
||||
style={{ border: "none" }}
|
||||
size="compact-sm"
|
||||
leftSection={
|
||||
<Indicator
|
||||
color="green"
|
||||
offset={5}
|
||||
disabled={!isPagePublic}
|
||||
withBorder
|
||||
>
|
||||
<IconWorld size={20} stroke={1.5} />
|
||||
</Indicator>
|
||||
}
|
||||
variant="default"
|
||||
>
|
||||
{t("Share")}
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown style={{ userSelect: "none" }}>
|
||||
{isDescendantShared ? (
|
||||
<>
|
||||
<Text size="sm">{t("Inherits public sharing from")}</Text>
|
||||
<Anchor
|
||||
size="sm"
|
||||
underline="never"
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
color: "var(--mantine-color-text)",
|
||||
}}
|
||||
component={Link}
|
||||
to={buildPageUrl(
|
||||
spaceSlug,
|
||||
share.sharedPage.slugId,
|
||||
share.sharedPage.title,
|
||||
)}
|
||||
>
|
||||
<Group gap="4" wrap="nowrap" my="sm">
|
||||
{getPageIcon(share.sharedPage.icon)}
|
||||
<div className={classes.shareLinkText}>
|
||||
<Text fz="sm" fw={500} lineClamp={1}>
|
||||
{share.sharedPage.title || t("untitled")}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Anchor>
|
||||
|
||||
{shareLink}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||
<div>
|
||||
<Text size="sm">
|
||||
{isPagePublic ? t("Shared to web") : t("Share to web")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{isPagePublic
|
||||
? t("Anyone with the link can view this page")
|
||||
: t("Make this page publicly accessible")}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
onChange={handleChange}
|
||||
defaultChecked={isPagePublic}
|
||||
disabled={readOnly}
|
||||
size="xs"
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{pageIsShared && (
|
||||
<>
|
||||
{shareLink}
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||
<div>
|
||||
<Text size="sm">{t("Include sub-pages")}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("Make sub-pages public too")}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
onChange={handleSubPagesChange}
|
||||
checked={share.includeSubPages}
|
||||
size="xs"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</Group>
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl" mt="sm">
|
||||
<div>
|
||||
<Text size="sm">{t("Search engine indexing")}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("Allow search engines to index page")}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
onChange={handleIndexSearchChange}
|
||||
checked={share.searchIndexing}
|
||||
size="xs"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
196
apps/client/src/features/share/components/share-shell.tsx
Normal file
196
apps/client/src/features/share/components/share-shell.tsx
Normal file
@ -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<HTMLElement | null>(null);
|
||||
const [asideOutside, setAsideOutside] = useState<HTMLElement | null>(null);
|
||||
|
||||
useClickOutside(
|
||||
() => {
|
||||
if (mobileOpened) {
|
||||
toggleMobile();
|
||||
}
|
||||
if (mobileTocOpened) {
|
||||
toggleTocMobile();
|
||||
}
|
||||
},
|
||||
null,
|
||||
[navbarOutside, asideOutside],
|
||||
);
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
header={{ height: 48 }}
|
||||
{...(data?.pageTree?.length > 1 && {
|
||||
navbar: {
|
||||
width: 300,
|
||||
breakpoint: "sm",
|
||||
collapsed: {
|
||||
mobile: !mobileOpened,
|
||||
desktop: !desktopOpened,
|
||||
},
|
||||
},
|
||||
})}
|
||||
aside={{
|
||||
width: 300,
|
||||
breakpoint: "sm",
|
||||
collapsed: {
|
||||
mobile: !mobileTocOpened,
|
||||
desktop: !tocOpened,
|
||||
},
|
||||
}}
|
||||
padding="md"
|
||||
>
|
||||
<AppShell.Header>
|
||||
<Group wrap="nowrap" justify="space-between" py="sm" px="xl">
|
||||
<Group>
|
||||
{data?.pageTree?.length > 1 && (
|
||||
<>
|
||||
<Tooltip label={t("Sidebar toggle")}>
|
||||
<SidebarToggle
|
||||
aria-label={t("Sidebar toggle")}
|
||||
opened={mobileOpened}
|
||||
onClick={toggleMobile}
|
||||
hiddenFrom="sm"
|
||||
size="sm"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label={t("Sidebar toggle")}>
|
||||
<SidebarToggle
|
||||
aria-label={t("Sidebar toggle")}
|
||||
opened={desktopOpened}
|
||||
onClick={toggleDesktop}
|
||||
visibleFrom="sm"
|
||||
size="sm"
|
||||
/>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
<Group>
|
||||
<>
|
||||
<Tooltip label={t("Table of contents")} withArrow>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
style={{ border: "none" }}
|
||||
onClick={toggleTocMobile}
|
||||
hiddenFrom="sm"
|
||||
size="sm"
|
||||
>
|
||||
<IconList size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label={t("Table of contents")} withArrow>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
style={{ border: "none" }}
|
||||
onClick={toggleToc}
|
||||
visibleFrom="sm"
|
||||
size="sm"
|
||||
>
|
||||
<IconList size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</>
|
||||
|
||||
<ThemeToggle />
|
||||
</Group>
|
||||
</Group>
|
||||
</AppShell.Header>
|
||||
|
||||
{data?.pageTree?.length > 1 && (
|
||||
<AppShell.Navbar
|
||||
p="md"
|
||||
className={classes.navbar}
|
||||
ref={setNavbarOutside}
|
||||
>
|
||||
<MemoizedSharedTree sharedPageTree={data} />
|
||||
</AppShell.Navbar>
|
||||
)}
|
||||
|
||||
<AppShell.Main>
|
||||
{children}
|
||||
|
||||
<Affix position={{ bottom: 20, right: 20 }}>
|
||||
<Button
|
||||
variant="default"
|
||||
component="a"
|
||||
target="_blank"
|
||||
href="https://docmost.com?ref=public-share"
|
||||
>
|
||||
Powered by Docmost
|
||||
</Button>
|
||||
</Affix>
|
||||
</AppShell.Main>
|
||||
|
||||
<AppShell.Aside
|
||||
p="md"
|
||||
withBorder={mobileTocOpened}
|
||||
className={classes.aside}
|
||||
ref={setAsideOutside}
|
||||
>
|
||||
<ScrollArea style={{ height: "80vh" }} scrollbarSize={5} type="scroll">
|
||||
<div style={{ paddingBottom: "50px" }}>
|
||||
{readOnlyEditor && (
|
||||
<TableOfContents isShare={true} editor={readOnlyEditor} />
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</AppShell.Aside>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
20
apps/client/src/features/share/components/share.module.css
Normal file
20
apps/client/src/features/share/components/share.module.css
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
179
apps/client/src/features/share/components/shared-tree.tsx
Normal file
179
apps/client/src/features/share/components/shared-tree.tsx
Normal file
@ -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<OpenMap>({});
|
||||
|
||||
export default function SharedTree({ sharedPageTree }: SharedTree) {
|
||||
const [tree, setTree] = useState<
|
||||
TreeApi<SharedPageTreeNode> | null | undefined
|
||||
>(null);
|
||||
const rootElement = useRef<HTMLDivElement>();
|
||||
const { ref: sizeRef, width, height } = useElementSize();
|
||||
const mergedRef = useMergedRef(rootElement, sizeRef);
|
||||
const { pageSlug } = useParams();
|
||||
const [openTreeNodes, setOpenTreeNodes] = useAtom<OpenMap>(
|
||||
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 (
|
||||
<div ref={mergedRef} className={classes.treeContainer}>
|
||||
{rootElement.current && (
|
||||
<Tree
|
||||
data={treeData}
|
||||
disableDrag={true}
|
||||
disableDrop={true}
|
||||
disableEdit={true}
|
||||
width={width}
|
||||
height={rootElement.current.clientHeight}
|
||||
ref={(t) => 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}
|
||||
</Tree>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Node({ node, style, tree }: NodeRendererProps<any>) {
|
||||
const { shareId } = useParams();
|
||||
const { t } = useTranslation();
|
||||
const [, setMobileSidebarState] = useAtom(mobileSidebarAtom);
|
||||
|
||||
const pageUrl = buildSharedPageUrl({
|
||||
shareId: shareId,
|
||||
pageSlugId: node.data.slugId,
|
||||
pageTitle: node.data.name,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
style={style}
|
||||
className={clsx(classes.node, node.state, styles.treeNode)}
|
||||
component={Link}
|
||||
to={pageUrl}
|
||||
onClick={() => {
|
||||
setMobileSidebarState(false);
|
||||
}}
|
||||
>
|
||||
<PageArrow node={node} />
|
||||
<span className={classes.text}>{node.data.name || t("untitled")}</span>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface PageArrowProps {
|
||||
node: NodeApi<SpaceTreeNode>;
|
||||
}
|
||||
|
||||
function PageArrow({ node }: PageArrowProps) {
|
||||
return (
|
||||
<ActionIcon
|
||||
size={20}
|
||||
variant="subtle"
|
||||
c="gray"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
node.toggle();
|
||||
}}
|
||||
>
|
||||
{node.isInternal ? (
|
||||
node.children && (node.children.length > 0 || node.data.hasChildren) ? (
|
||||
node.isOpen ? (
|
||||
<IconChevronDown stroke={2} size={16} />
|
||||
) : (
|
||||
<IconChevronRight stroke={2} size={16} />
|
||||
)
|
||||
) : (
|
||||
<IconPointFilled size={4} />
|
||||
)
|
||||
) : null}
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
8
apps/client/src/features/share/hooks/use-toggle-toc.ts
Normal file
8
apps/client/src/features/share/hooks/use-toggle-toc.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { useAtom } from "jotai";
|
||||
|
||||
export function useToggleToc(tocAtom: any) {
|
||||
const [tocState, setTocState] = useAtom(tocAtom);
|
||||
return () => {
|
||||
setTocState(!tocState);
|
||||
}
|
||||
}
|
||||
179
apps/client/src/features/share/queries/share-query.ts
Normal file
179
apps/client/src/features/share/queries/share-query.ts
Normal file
@ -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<IPagination<ISharedItem>, Error> {
|
||||
return useQuery({
|
||||
queryKey: ["share-list"],
|
||||
queryFn: () => getShares(params),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
|
||||
export function useGetShareByIdQuery(
|
||||
shareId: string,
|
||||
): UseQueryResult<IShare, Error> {
|
||||
const query = useQuery({
|
||||
queryKey: ["share-by-id", shareId],
|
||||
queryFn: () => getShareInfo(shareId),
|
||||
enabled: !!shareId,
|
||||
});
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
export function useSharePageQuery(
|
||||
shareInput: Partial<IShareInfoInput>,
|
||||
): UseQueryResult<ISharedPage, Error> {
|
||||
const query = useQuery({
|
||||
queryKey: ["shares", shareInput],
|
||||
queryFn: () => getSharePageInfo(shareInput),
|
||||
enabled: !!shareInput.pageId,
|
||||
});
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
export function useShareForPageQuery(
|
||||
pageId: string,
|
||||
): UseQueryResult<IShareForPage, Error> {
|
||||
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<any, Error, ICreateShare>({
|
||||
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<any, Error, IUpdateShare>({
|
||||
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<ISharedPageTree, Error> {
|
||||
return useQuery({
|
||||
queryKey: ["shared-page-tree", shareId],
|
||||
queryFn: () => getSharedPageTree(shareId),
|
||||
enabled: !!shareId,
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 60 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
59
apps/client/src/features/share/services/share-service.ts
Normal file
59
apps/client/src/features/share/services/share-service.ts
Normal file
@ -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<IPagination<ISharedItem>> {
|
||||
const req = await api.post("/shares", params);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function createShare(data: ICreateShare): Promise<any> {
|
||||
const req = await api.post<any>("/shares/create", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getShareInfo(shareId: string): Promise<IShare> {
|
||||
const req = await api.post<IShare>("/shares/info", { shareId });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function updateShare(data: IUpdateShare): Promise<any> {
|
||||
const req = await api.post<any>("/shares/update", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getShareForPage(pageId: string): Promise<IShareForPage> {
|
||||
const req = await api.post<any>("/shares/for-page", { pageId });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getSharePageInfo(
|
||||
shareInput: Partial<IShareInfoInput>,
|
||||
): Promise<ISharedPage> {
|
||||
const req = await api.post<ISharedPage>("/shares/page-info", shareInput);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function deleteShare(shareId: string): Promise<void> {
|
||||
await api.post("/shares/delete", { shareId });
|
||||
}
|
||||
|
||||
export async function getSharedPageTree(
|
||||
shareId: string,
|
||||
): Promise<ISharedPageTree> {
|
||||
const req = await api.post<ISharedPageTree>("/shares/tree", { shareId });
|
||||
return req.data;
|
||||
}
|
||||
73
apps/client/src/features/share/types/share.types.ts
Normal file
73
apps/client/src/features/share/types/share.types.ts
Normal file
@ -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<IPage[]>;
|
||||
}
|
||||
60
apps/client/src/features/share/utils.ts
Normal file
60
apps/client/src/features/share/utils.ts
Normal file
@ -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<IPage[]>): SharedPageTreeNode[] {
|
||||
const pageMap: Record<string, SharedPageTreeNode> = {};
|
||||
|
||||
// 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);
|
||||
}
|
||||
@ -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() {
|
||||
) && (
|
||||
<UnstyledButton
|
||||
className={classes.menu}
|
||||
onClick={handleCreatePage}
|
||||
onClick={() => {
|
||||
handleCreatePage();
|
||||
if (mobileSidebarOpened) {
|
||||
toggleMobileSidebar();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={classes.menuItemInner}>
|
||||
<IconPlus
|
||||
|
||||
@ -26,6 +26,7 @@ api.interceptors.response.use(
|
||||
case 401: {
|
||||
const url = new URL(error.request.responseURL)?.pathname;
|
||||
if (url === "/api/auth/collab-token") return;
|
||||
if (window.location.pathname.startsWith("/share/")) return;
|
||||
|
||||
// Handle unauthorized error
|
||||
redirectToLogin();
|
||||
|
||||
31
apps/client/src/pages/settings/shares/shares.tsx
Normal file
31
apps/client/src/pages/settings/shares/shares.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import SettingsTitle from "@/components/settings/settings-title.tsx";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { getAppName } from "@/lib/config.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ShareList from "@/features/share/components/share-list.tsx";
|
||||
import { Alert, Text } from "@mantine/core";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
import React from "react";
|
||||
|
||||
export default function Shares() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>
|
||||
{t("Public sharing")} - {getAppName()}
|
||||
</title>
|
||||
</Helmet>
|
||||
<SettingsTitle title={t("Public sharing")} />
|
||||
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle />}>
|
||||
{t(
|
||||
"Publicly shared pages from spaces you are a member of will appear here",
|
||||
)}
|
||||
</Alert>
|
||||
|
||||
<ShareList />
|
||||
</>
|
||||
);
|
||||
}
|
||||
35
apps/client/src/pages/share/share-redirect.tsx
Normal file
35
apps/client/src/pages/share/share-redirect.tsx
Normal file
@ -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 <Error404 />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
58
apps/client/src/pages/share/shared-page.tsx
Normal file
58
apps/client/src/pages/share/shared-page.tsx
Normal file
@ -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 <Error404 />;
|
||||
}
|
||||
return <div>{t("Error fetching page data.")}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Helmet>
|
||||
<title>{`${data?.page?.title || t("untitled")}`}</title>
|
||||
{!data?.share.searchIndexing && (
|
||||
<meta name="robots" content="noindex" />
|
||||
)}
|
||||
</Helmet>
|
||||
|
||||
<Container size={900} p={0}>
|
||||
<ReadonlyPageEditor
|
||||
key={data.page.id}
|
||||
title={data.page.title}
|
||||
content={data.page.content}
|
||||
/>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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],
|
||||
})
|
||||
|
||||
@ -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';
|
||||
};
|
||||
|
||||
|
||||
@ -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<string> {
|
||||
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(),
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 },
|
||||
|
||||
58
apps/server/src/core/share/dto/share.dto.ts
Normal file
58
apps/server/src/core/share/dto/share.dto.ts
Normal file
@ -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;
|
||||
}
|
||||
109
apps/server/src/core/share/share-seo.controller.ts
Normal file
109
apps/server/src/core/share/share-seo.controller.ts
Normal file
@ -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 = '<!--meta-tags-->';
|
||||
|
||||
const metaTags = [
|
||||
`<meta property="og:title" content="${metaTitle}" />`,
|
||||
`<meta property="twitter:title" content="${metaTitle}" />`,
|
||||
!share.searchIndexing ? `<meta name="robots" content="noindex" />` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n ');
|
||||
|
||||
const html = fs.readFileSync(indexFilePath, 'utf8');
|
||||
const transformedHtml = html
|
||||
.replace(/<title>[\s\S]*?<\/title>/i, `<title>${metaTitle}</title>`)
|
||||
.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;
|
||||
}
|
||||
}
|
||||
171
apps/server/src/core/share/share.controller.ts
Normal file
171
apps/server/src/core/share/share.controller.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
13
apps/server/src/core/share/share.module.ts
Normal file
13
apps/server/src/core/share/share.module.ts
Normal file
@ -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 {}
|
||||
297
apps/server/src/core/share/share.service.ts
Normal file
297
apps/server/src/core/share/share.service.ts
Normal file
@ -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<any> {
|
||||
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<any> {
|
||||
const prosemirrorJson = getProsemirrorContent(page.content);
|
||||
const attachmentIds = getAttachmentIds(prosemirrorJson);
|
||||
const attachmentMap = new Map<string, string>();
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
22
apps/server/src/core/share/share.util.ts
Normal file
22
apps/server/src/core/share/share.util.ts
Normal file
@ -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}`;
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -0,0 +1,38 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
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<any>): Promise<void> {
|
||||
await db.schema.dropTable('shares').execute();
|
||||
}
|
||||
@ -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'),
|
||||
),
|
||||
)
|
||||
|
||||
242
apps/server/src/database/repos/share/share.repo.ts
Normal file
242
apps/server/src/database/repos/share/share.repo.ts
Normal file
@ -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<keyof Share> = [
|
||||
'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<Share> {
|
||||
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<Share> {
|
||||
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<Share> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.insertInto('shares')
|
||||
.values(insertableShare)
|
||||
.returning(this.baseFields)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async deleteShare(shareId: string): Promise<void> {
|
||||
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<DB, 'shares'>) {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom('pages')
|
||||
.select(['pages.id', 'pages.title', 'pages.slugId', 'pages.icon'])
|
||||
.whereRef('pages.id', '=', 'shares.pageId'),
|
||||
).as('page');
|
||||
}
|
||||
|
||||
withSpace(eb: ExpressionBuilder<DB, 'shares'>, 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<DB, 'spaces'>, 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<DB, 'shares'>) {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom('users')
|
||||
.select(['users.id', 'users.name', 'users.avatarUrl'])
|
||||
.whereRef('users.id', '=', 'shares.creatorId'),
|
||||
).as('creator');
|
||||
}
|
||||
|
||||
withSharedPage(eb: ExpressionBuilder<DB, 'shares'>) {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom('pages')
|
||||
.select([
|
||||
'pages.id',
|
||||
'pages.slugId',
|
||||
'pages.title',
|
||||
'pages.icon',
|
||||
'pages.parentPageId',
|
||||
])
|
||||
.whereRef('pages.id', '=', 'shares.pageId'),
|
||||
).as('sharedPage');
|
||||
}
|
||||
}
|
||||
15
apps/server/src/database/types/db.d.ts
vendored
15
apps/server/src/database/types/db.d.ts
vendored
@ -183,6 +183,20 @@ export interface Pages {
|
||||
ydoc: Buffer | null;
|
||||
}
|
||||
|
||||
export interface Shares {
|
||||
createdAt: Generated<Timestamp>;
|
||||
creatorId: string | null;
|
||||
deletedAt: Timestamp | null;
|
||||
id: Generated<string>;
|
||||
includeSubPages: Generated<boolean | null>;
|
||||
key: string;
|
||||
pageId: string | null;
|
||||
searchIndexing: Generated<boolean | null>;
|
||||
spaceId: string;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface SpaceMembers {
|
||||
addedById: string | null;
|
||||
createdAt: Generated<Timestamp>;
|
||||
@ -288,6 +302,7 @@ export interface DB {
|
||||
groupUsers: GroupUsers;
|
||||
pageHistory: PageHistory;
|
||||
pages: Pages;
|
||||
shares: Shares;
|
||||
spaceMembers: SpaceMembers;
|
||||
spaces: Spaces;
|
||||
users: Users;
|
||||
|
||||
@ -16,6 +16,7 @@ import {
|
||||
Billing as BillingSubscription,
|
||||
AuthProviders,
|
||||
AuthAccounts,
|
||||
Shares,
|
||||
} from './db';
|
||||
|
||||
// Workspace
|
||||
@ -101,3 +102,8 @@ export type UpdatableAuthProvider = Updateable<Omit<AuthProviders, 'id'>>;
|
||||
export type AuthAccount = Selectable<AuthAccounts>;
|
||||
export type InsertableAuthAccount = Insertable<AuthAccounts>;
|
||||
export type UpdatableAuthAccount = Updateable<Omit<AuthAccounts, 'id'>>;
|
||||
|
||||
// Share
|
||||
export type Share = Selectable<Shares>;
|
||||
export type InsertableShare = Insertable<Shares>;
|
||||
export type UpdatableShare = Updateable<Omit<Shares, 'id'>>;
|
||||
|
||||
@ -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 {
|
||||
</html>`;
|
||||
}
|
||||
|
||||
if (format === ExportFormat.Markdown) {
|
||||
const newPageHtml = pageHtml.replace(/<colgroup[^>]*>[\s\S]*?<\/colgroup>/gmi, '');
|
||||
if (format === ExportFormat.Markdown) {
|
||||
const newPageHtml = pageHtml.replace(
|
||||
/<colgroup[^>]*>[\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)
|
||||
|
||||
@ -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<string, Page[]>;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
309
pnpm-lock.yaml
generated
309
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
Reference in New Issue
Block a user