Merge branch 'main' into feat/resolve-comment

This commit is contained in:
Philipinho
2025-07-29 13:22:23 -07:00
36 changed files with 1222 additions and 239 deletions

View File

@ -414,7 +414,7 @@
"Replace (Enter)": "Replace (Enter)",
"Replace all (Ctrl+Alt+Enter)": "Replace all (Ctrl+Alt+Enter)",
"Replace all": "Replace all",
"View all spaces": "View all spaces"
"View all spaces": "View all spaces",
"Error": "Error",
"Failed to disable MFA": "Failed to disable MFA",
"Disable two-factor authentication": "Disable two-factor authentication",
@ -480,5 +480,20 @@
"Enter one of your backup codes": "Enter one of your backup codes",
"Backup code": "Backup code",
"Enter one of your backup codes. Each backup code can only be used once.": "Enter one of your backup codes. Each backup code can only be used once.",
"Verify": "Verify"
"Verify": "Verify",
"Trash": "Trash",
"Pages in trash will be permanently deleted after 30 days.": "Pages in trash will be permanently deleted after 30 days.",
"Deleted": "Deleted",
"No pages in trash": "No pages in trash",
"Permanently delete page?": "Permanently delete page?",
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.",
"Restore '{{title}}' and its sub-pages?": "Restore '{{title}}' and its sub-pages?",
"Move to trash": "Move to trash",
"Move this page to trash?": "Move this page to trash?",
"Restore page": "Restore page",
"Page moved to trash": "Page moved to trash",
"Page restored successfully": "Page restored successfully",
"Deleted by": "Deleted by",
"Deleted at": "Deleted at",
"Preview": "Preview"
}

View File

@ -34,6 +34,7 @@ import { useTrackOrigin } from "@/hooks/use-track-origin";
import SpacesPage from "@/pages/spaces/spaces.tsx";
import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page";
import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page";
import SpaceTrash from "@/pages/space/trash.tsx";
export default function App() {
const { t } = useTranslation();
@ -80,6 +81,7 @@ export default function App() {
<Route path={"/home"} element={<Home />} />
<Route path={"/spaces"} element={<SpacesPage />} />
<Route path={"/s/:spaceSlug"} element={<SpaceHome />} />
<Route path={"/s/:spaceSlug/trash"} element={<SpaceTrash />} />
<Route
path={"/s/:spaceSlug/p/:pageSlug"}
element={

View File

@ -0,0 +1,24 @@
import { Group, Text } from "@mantine/core";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import React from "react";
import { User } from "server/dist/database/types/entity.types";
interface UserInfoProps {
user: User;
size?: string;
}
export function UserInfo({ user, size }: UserInfoProps) {
return (
<Group gap="sm" wrap="nowrap">
<CustomAvatar avatarUrl={user?.avatarUrl} name={user?.name} size={size} />
<div>
<Text fz="sm" fw={500} lineClamp={1}>
{user?.name}
</Text>
<Text fz="xs" c="dimmed">
{user?.email}
</Text>
</div>
</Group>
);
}

View File

@ -4,14 +4,10 @@ import mermaid from "mermaid";
import { v4 as uuidv4 } from "uuid";
import classes from "./code-block.module.css";
import { useTranslation } from "react-i18next";
import { useComputedColorScheme } from "@mantine/core";
const computedColorScheme = useComputedColorScheme();
mermaid.initialize({
startOnLoad: false,
suppressErrorRendering: true,
theme: computedColorScheme === "light" ? "default" : "dark",
});
interface MermaidViewProps {

View File

@ -21,6 +21,7 @@ import i18n from "i18next";
import {
getEmbedProviderById,
getEmbedUrlAndProvider,
sanitizeUrl,
} from "@docmost/editor-ext";
import { ResizableWrapper } from "../common/resizable-wrapper";
import classes from "./embed-view.module.css";
@ -51,9 +52,12 @@ export default function EmbedView(props: NodeViewProps) {
validate: zodResolver(schema),
});
const handleResize = useCallback((newHeight: number) => {
updateAttributes({ height: newHeight });
}, [updateAttributes]);
const handleResize = useCallback(
(newHeight: number) => {
updateAttributes({ height: newHeight });
},
[updateAttributes],
);
async function onSubmit(data: { url: string }) {
if (!editor.isEditable) {
@ -63,11 +67,11 @@ export default function EmbedView(props: NodeViewProps) {
if (provider) {
const embedProvider = getEmbedProviderById(provider);
if (embedProvider.id === "iframe") {
updateAttributes({ src: data.url });
updateAttributes({ src: sanitizeUrl(data.url) });
return;
}
if (embedProvider.regex.test(data.url)) {
updateAttributes({ src: data.url });
updateAttributes({ src: sanitizeUrl(data.url) });
} else {
notifications.show({
message: t("Invalid {{provider}} embed link", {
@ -95,7 +99,7 @@ export default function EmbedView(props: NodeViewProps) {
>
<iframe
className={classes.embedIframe}
src={embedUrl}
src={sanitizeUrl(embedUrl)}
allow="encrypted-media"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
allowFullScreen

View File

@ -10,7 +10,6 @@ import { Highlight } from "@tiptap/extension-highlight";
import { Typography } from "@tiptap/extension-typography";
import { TextStyle } from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color";
import Table from "@tiptap/extension-table";
import SlashCommand from "@/features/editor/extensions/slash-command";
import { Collaboration } from "@tiptap/extension-collaboration";
import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor";
@ -25,6 +24,7 @@ import {
TableCell,
TableRow,
TableHeader,
CustomTable,
TrailingNode,
TiptapImage,
Callout,
@ -160,7 +160,7 @@ export const mainExtensions = [
return ReactNodeViewRenderer(MentionView);
},
}),
Table.configure({
CustomTable.configure({
resizable: true,
lastColumnResizable: false,
allowTableNodeSelection: true,

View File

@ -231,7 +231,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
leftSection={<IconTrash size={16} />}
onClick={handleDeletePage}
>
{t("Delete")}
{t("Move to trash")}
</Menu.Item>
</>
)}

View File

@ -4,26 +4,37 @@ import { useTranslation } from "react-i18next";
type UseDeleteModalProps = {
onConfirm: () => void;
isPermanent?: boolean;
};
export function useDeletePageModal() {
const { t } = useTranslation();
const openDeleteModal = ({ onConfirm }: UseDeleteModalProps) => {
const openDeleteModal = ({
onConfirm,
isPermanent = false,
}: UseDeleteModalProps) => {
modals.openConfirmModal({
title: t("Are you sure you want to delete this page?"),
title: isPermanent
? t("Are you sure you want to delete this page?")
: t("Move this page to trash?"),
children: (
<Text size="sm">
{t(
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.",
)}
{isPermanent
? t(
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.",
)
: t("Pages in trash will be permanently deleted after 30 days.")}
</Text>
),
centered: true,
labels: { confirm: t("Delete"), cancel: t("Cancel") },
labels: {
confirm: isPermanent ? t("Delete") : t("Move to trash"),
cancel: t("Cancel"),
},
confirmProps: { color: "red" },
onConfirm,
});
};
return { openDeleteModal } as const;
}
}

View File

@ -5,8 +5,8 @@ import {
UseInfiniteQueryResult,
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
keepPreviousData,
} from "@tanstack/react-query";
import {
createPage,
@ -18,6 +18,8 @@ import {
getPageBreadcrumbs,
getRecentChanges,
getAllSidebarPages,
getDeletedPages,
restorePage,
} from "@/features/page/services/page-service";
import {
IMovePage,
@ -26,12 +28,17 @@ import {
SidebarPagesParams,
} from "@/features/page/types/page.types";
import { notifications } from "@mantine/notifications";
import { IPagination } from "@/lib/types.ts";
import { IPagination, QueryParams } from "@/lib/types.ts";
import { queryClient } from "@/main.tsx";
import { buildTree } from "@/features/page/tree/utils";
import { useEffect } from "react";
import { validate as isValidUuid } from "uuid";
import { useTranslation } from "react-i18next";
import { useAtom } from "jotai";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
import { SimpleTree } from "react-arborist";
import { SpaceTreeNode } from "@/features/page/tree/types";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
export function usePageQuery(
pageInput: Partial<IPageInput>,
@ -70,10 +77,7 @@ export function useCreatePageMutation() {
}
export function updatePageData(data: IPage) {
const pageBySlug = queryClient.getQueryData<IPage>([
"pages",
data.slugId,
]);
const pageBySlug = queryClient.getQueryData<IPage>(["pages", data.slugId]);
const pageById = queryClient.getQueryData<IPage>(["pages", data.id]);
if (pageBySlug) {
@ -87,7 +91,13 @@ export function updatePageData(data: IPage) {
queryClient.setQueryData(["pages", data.id], { ...pageById, ...data });
}
invalidateOnUpdatePage(data.spaceId, data.parentPageId, data.id, data.title, data.icon);
invalidateOnUpdatePage(
data.spaceId,
data.parentPageId,
data.id,
data.title,
data.icon,
);
}
export function useUpdateTitlePageMutation() {
@ -102,7 +112,29 @@ export function useUpdatePageMutation() {
onSuccess: (data) => {
updatePage(data);
invalidateOnUpdatePage(data.spaceId, data.parentPageId, data.id, data.title, data.icon);
invalidateOnUpdatePage(
data.spaceId,
data.parentPageId,
data.id,
data.title,
data.icon,
);
},
});
}
export function useRemovePageMutation() {
return useMutation({
mutationFn: (pageId: string) => deletePage(pageId, false),
onSuccess: () => {
notifications.show({ message: "Page moved to trash" });
queryClient.invalidateQueries({
predicate: (item) =>
["trash-list"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
notifications.show({ message: "Failed to delete page", color: "red" });
},
});
}
@ -110,10 +142,16 @@ export function useUpdatePageMutation() {
export function useDeletePageMutation() {
const { t } = useTranslation();
return useMutation({
mutationFn: (pageId: string) => deletePage(pageId),
mutationFn: (pageId: string) => deletePage(pageId, true),
onSuccess: (data, pageId) => {
notifications.show({ message: t("Page deleted successfully") });
invalidateOnDeletePage(pageId);
// Invalidate to refresh trash lists
queryClient.invalidateQueries({
predicate: (item) =>
["trash-list"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
notifications.show({ message: t("Failed to delete page"), color: "red" });
@ -130,7 +168,87 @@ export function useMovePageMutation() {
});
}
export function useGetSidebarPagesQuery(data: SidebarPagesParams|null): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, unknown>> {
export function useRestorePageMutation() {
const [treeData, setTreeData] = useAtom(treeDataAtom);
const emit = useQueryEmit();
return useMutation({
mutationFn: (pageId: string) => restorePage(pageId),
onSuccess: async (restoredPage) => {
notifications.show({ message: "Page restored successfully" });
// Add the restored page back to the tree
const treeApi = new SimpleTree<SpaceTreeNode>(treeData);
// Check if the page already exists in the tree (it shouldn't)
if (!treeApi.find(restoredPage.id)) {
// Create the tree node data with hasChildren from backend
const nodeData: SpaceTreeNode = {
id: restoredPage.id,
slugId: restoredPage.slugId,
name: restoredPage.title || "Untitled",
icon: restoredPage.icon,
position: restoredPage.position,
spaceId: restoredPage.spaceId,
parentPageId: restoredPage.parentPageId,
hasChildren: restoredPage.hasChildren || false,
children: [],
};
// Determine the parent and index
const parentId = restoredPage.parentPageId || null;
let index = 0;
if (parentId) {
const parentNode = treeApi.find(parentId);
if (parentNode) {
index = parentNode.children?.length || 0;
}
} else {
// Root level page
index = treeApi.data.length;
}
// Add the node to the tree
treeApi.create({
parentId,
index,
data: nodeData,
});
// Update the tree data
setTreeData(treeApi.data);
// Emit websocket event to sync with other users
setTimeout(() => {
emit({
operation: "addTreeNode",
spaceId: restoredPage.spaceId,
payload: {
parentId,
index,
data: nodeData,
},
});
}, 50);
}
// await queryClient.invalidateQueries({ queryKey: ["sidebar-pages", restoredPage.spaceId] });
// Also invalidate deleted pages query to refresh the trash list
await queryClient.invalidateQueries({
queryKey: ["trash-list", restoredPage.spaceId],
});
},
onError: (error) => {
notifications.show({ message: "Failed to restore page", color: "red" });
},
});
}
export function useGetSidebarPagesQuery(
data: SidebarPagesParams | null,
): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, unknown>> {
return useInfiniteQuery({
queryKey: ["sidebar-pages", data],
queryFn: ({ pageParam }) => getSidebarPages({ ...data, page: pageParam }),
@ -188,6 +306,20 @@ export function useRecentChangesQuery(
});
}
export function useDeletedPagesQuery(
spaceId: string,
params?: QueryParams,
): UseQueryResult<IPagination<IPage>, Error> {
return useQuery({
queryKey: ["trash-list", spaceId, params],
queryFn: () => getDeletedPages(spaceId, params),
enabled: !!spaceId,
placeholderData: keepPreviousData,
refetchOnMount: true,
staleTime: 0,
});
}
export function invalidateOnCreatePage(data: Partial<IPage>) {
const newPage: Partial<IPage> = {
creatorId: data.creatorId,
@ -202,34 +334,40 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
};
let queryKey: QueryKey = null;
if (data.parentPageId===null) {
queryKey = ['root-sidebar-pages', data.spaceId];
}else{
queryKey = ['sidebar-pages', {pageId: data.parentPageId, spaceId: data.spaceId}]
if (data.parentPageId === null) {
queryKey = ["root-sidebar-pages", data.spaceId];
} else {
queryKey = [
"sidebar-pages",
{ pageId: data.parentPageId, spaceId: data.spaceId },
];
}
//update all sidebar pages
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(queryKey, (old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page,index) => {
if (index === old.pages.length - 1) {
return {
...page,
items: [...page.items, newPage],
};
}
return page;
}),
};
});
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(
queryKey,
(old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page, index) => {
if (index === old.pages.length - 1) {
return {
...page,
items: [...page.items, newPage],
};
}
return page;
}),
};
},
);
//update sidebar haschildren
if (data.parentPageId!==null){
if (data.parentPageId !== null) {
//update sub sidebar pages haschildern
const subSideBarMatches = queryClient.getQueriesData({
queryKey: ['sidebar-pages'],
queryKey: ["sidebar-pages"],
exact: false,
});
@ -241,8 +379,10 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
pages: old.pages.map((page) => ({
...page,
items: page.items.map((sidebarPage: IPage) =>
sidebarPage.id === data.parentPageId ? { ...sidebarPage, hasChildren: true } : sidebarPage
)
sidebarPage.id === data.parentPageId
? { ...sidebarPage, hasChildren: true }
: sidebarPage,
),
})),
};
});
@ -250,7 +390,7 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
//update root sidebar pages haschildern
const rootSideBarMatches = queryClient.getQueriesData({
queryKey: ['root-sidebar-pages', data.spaceId],
queryKey: ["root-sidebar-pages", data.spaceId],
exact: false,
});
@ -262,8 +402,10 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
pages: old.pages.map((page) => ({
...page,
items: page.items.map((sidebarPage: IPage) =>
sidebarPage.id === data.parentPageId ? { ...sidebarPage, hasChildren: true } : sidebarPage
)
sidebarPage.id === data.parentPageId
? { ...sidebarPage, hasChildren: true }
: sidebarPage,
),
})),
};
});
@ -276,27 +418,38 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
});
}
export function invalidateOnUpdatePage(spaceId: string, parentPageId: string, id: string, title: string, icon: string) {
export function invalidateOnUpdatePage(
spaceId: string,
parentPageId: string,
id: string,
title: string,
icon: string,
) {
let queryKey: QueryKey = null;
if(parentPageId===null){
queryKey = ['root-sidebar-pages', spaceId];
}else{
queryKey = ['sidebar-pages', {pageId: parentPageId, spaceId: spaceId}]
if (parentPageId === null) {
queryKey = ["root-sidebar-pages", spaceId];
} else {
queryKey = ["sidebar-pages", { pageId: parentPageId, spaceId: spaceId }];
}
//update all sidebar pages
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(queryKey, (old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.map((sidebarPage: IPage) =>
sidebarPage.id === id ? { ...sidebarPage, title: title, icon: icon } : sidebarPage
)
})),
};
});
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
queryKey,
(old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.map((sidebarPage: IPage) =>
sidebarPage.id === id
? { ...sidebarPage, title: title, icon: icon }
: sidebarPage,
),
})),
};
},
);
//update recent changes
queryClient.invalidateQueries({
queryKey: ["recent-changes", spaceId],
@ -311,7 +464,7 @@ export function invalidateOnMovePage() {
});
//invalidate all sub sidebar pages
queryClient.invalidateQueries({
queryKey: ['sidebar-pages'],
queryKey: ["sidebar-pages"],
});
// ---
}
@ -320,7 +473,8 @@ export function invalidateOnDeletePage(pageId: string) {
//update all sidebar pages
const allSideBarMatches = queryClient.getQueriesData({
predicate: (query) =>
query.queryKey[0] === 'root-sidebar-pages' || query.queryKey[0] === 'sidebar-pages',
query.queryKey[0] === "root-sidebar-pages" ||
query.queryKey[0] === "sidebar-pages",
});
allSideBarMatches.forEach(([key, d]) => {
@ -330,14 +484,16 @@ export function invalidateOnDeletePage(pageId: string) {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.filter((sidebarPage: IPage) => sidebarPage.id !== pageId),
items: page.items.filter(
(sidebarPage: IPage) => sidebarPage.id !== pageId,
),
})),
};
});
});
//update recent changes
queryClient.invalidateQueries({
queryKey: ["recent-changes"],
});
}
}

View File

@ -8,6 +8,7 @@ import {
IPageInput,
SidebarPagesParams,
} from '@/features/page/types/page.types';
import { QueryParams } from "@/lib/types";
import { IAttachment, IPagination } from "@/lib/types.ts";
import { saveAs } from "file-saver";
import { InfiniteData } from "@tanstack/react-query";
@ -30,8 +31,21 @@ export async function updatePage(data: Partial<IPageInput>): Promise<IPage> {
return req.data;
}
export async function deletePage(pageId: string): Promise<void> {
await api.post("/pages/delete", { pageId });
export async function deletePage(pageId: string, permanentlyDelete = false): Promise<void> {
await api.post("/pages/delete", { pageId, permanentlyDelete });
}
export async function getDeletedPages(
spaceId: string,
params?: QueryParams,
): Promise<IPagination<IPage>> {
const req = await api.post("/pages/trash", { spaceId, ...params });
return req.data;
}
export async function restorePage(pageId: string): Promise<IPage> {
const response = await api.post<IPage>("/pages/restore", { pageId });
return response.data;
}
export async function movePage(data: IMovePage): Promise<void> {

View File

@ -0,0 +1,41 @@
import { Modal, Text, ScrollArea } from "@mantine/core";
import { useTranslation } from "react-i18next";
import ReadonlyPageEditor from "@/features/editor/readonly-page-editor.tsx";
interface Props {
opened: boolean;
onClose: () => void;
pageTitle: string;
pageContent: any;
}
export default function TrashPageContentModal({
opened,
onClose,
pageTitle,
pageContent,
}: Props) {
const { t } = useTranslation();
const title = pageTitle || t("Untitled");
return (
<Modal.Root size={1200} opened={opened} onClose={onClose}>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Header>
<Modal.Title>
<Text size="md" fw={500}>
{t("Preview")}
</Text>
</Modal.Title>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body p={0}>
<ScrollArea h="650" w="100%" scrollbarSize={5}>
<ReadonlyPageEditor title={title} content={pageContent} />
</ScrollArea>
</Modal.Body>
</Modal.Content>
</Modal.Root>
);
}

View File

@ -633,7 +633,7 @@ function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) {
openDeleteModal({ onConfirm: () => treeApi?.delete(node) });
}}
>
{t("Delete")}
{t("Move to trash")}
</Menu.Item>
</>
)}

View File

@ -13,7 +13,7 @@ import { IMovePage, IPage } from "@/features/page/types/page.types.ts";
import { useNavigate, useParams } from "react-router-dom";
import {
useCreatePageMutation,
useDeletePageMutation,
useRemovePageMutation,
useMovePageMutation,
useUpdatePageMutation,
} from "@/features/page/queries/page-query.ts";
@ -28,7 +28,7 @@ export function useTreeMutation<T>(spaceId: string) {
const tree = useMemo(() => new SimpleTree<SpaceTreeNode>(data), [data]);
const createPageMutation = useCreatePageMutation();
const updatePageMutation = useUpdatePageMutation();
const deletePageMutation = useDeletePageMutation();
const removePageMutation = useRemovePageMutation();
const movePageMutation = useMovePageMutation();
const navigate = useNavigate();
const { spaceSlug } = useParams();
@ -225,7 +225,7 @@ export function useTreeMutation<T>(spaceId: string) {
const onDelete: DeleteHandler<T> = async (args: { ids: string[] }) => {
try {
await deletePageMutation.mutateAsync(args.ids[0]);
await removePageMutation.mutateAsync(args.ids[0]);
const node = tree.find(args.ids[0]);
if (!node) {

View File

@ -20,6 +20,7 @@ export interface IPage {
hasChildren: boolean;
creator: ICreator;
lastUpdatedBy: ILastUpdatedBy;
deletedBy: IDeletedBy;
space: Partial<ISpace>;
}
@ -34,6 +35,12 @@ interface ILastUpdatedBy {
avatarUrl: string;
}
interface IDeletedBy {
id: string;
name: string;
avatarUrl: string;
}
export interface IMovePage {
pageId: string;
position?: string;

View File

@ -14,6 +14,7 @@ import {
IconPlus,
IconSearch,
IconSettings,
IconTrash,
} from "@tabler/icons-react";
import classes from "./space-sidebar.module.css";
import React from "react";
@ -206,6 +207,7 @@ interface SpaceMenuProps {
}
function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
const { t } = useTranslation();
const { spaceSlug } = useParams();
const [importOpened, { open: openImportModal, close: closeImportModal }] =
useDisclosure(false);
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
@ -253,6 +255,14 @@ function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
>
{t("Space settings")}
</Menu.Item>
<Menu.Item
component={Link}
to={`/s/${spaceSlug}/trash`}
leftSection={<IconTrash size={16} />}
>
{t("Trash")}
</Menu.Item>
</Menu.Dropdown>
</Menu>

View File

@ -0,0 +1,227 @@
import { useParams } from "react-router-dom";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query";
import {
Container,
Title,
Table,
Group,
ActionIcon,
Text,
Alert,
Stack,
Menu,
} from "@mantine/core";
import {
IconInfoCircle,
IconDots,
IconRestore,
IconTrash,
IconFileDescription,
} from "@tabler/icons-react";
import {
useDeletedPagesQuery,
useRestorePageMutation,
useDeletePageMutation,
} from "@/features/page/queries/page-query";
import { modals } from "@mantine/modals";
import { useTranslation } from "react-i18next";
import { formattedDate } from "@/lib/time";
import { useState } from "react";
import TrashPageContentModal from "@/features/page/trash/components/trash-page-content-modal";
import { UserInfo } from "@/components/common/user-info.tsx";
import Paginate from "@/components/common/paginate.tsx";
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
export default function SpaceTrash() {
const { t } = useTranslation();
const { spaceSlug } = useParams();
const { page, setPage } = usePaginateAndSearch();
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
const { data: deletedPages, isLoading } = useDeletedPagesQuery(space?.id, {
page, limit: 50
});
const restorePageMutation = useRestorePageMutation();
const deletePageMutation = useDeletePageMutation();
const [selectedPage, setSelectedPage] = useState<{
title: string;
content: any;
} | null>(null);
const [modalOpened, setModalOpened] = useState(false);
const handleRestorePage = async (pageId: string) => {
await restorePageMutation.mutateAsync(pageId);
};
const handleDeletePage = async (pageId: string) => {
await deletePageMutation.mutateAsync(pageId);
};
const openDeleteModal = (pageId: string, pageTitle: string) => {
modals.openConfirmModal({
title: t("Are you sure you want to delete this page?"),
children: (
<Text size="sm">
{t(
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.",
{ title: pageTitle || "Untitled" },
)}
</Text>
),
centered: true,
labels: { confirm: t("Delete"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => handleDeletePage(pageId),
});
};
const openRestoreModal = (pageId: string, pageTitle: string) => {
modals.openConfirmModal({
title: t("Restore page"),
children: (
<Text size="sm">
{t("Restore '{{title}}' and its sub-pages?", {
title: pageTitle || "Untitled",
})}
</Text>
),
centered: true,
labels: { confirm: t("Restore"), cancel: t("Cancel") },
confirmProps: { color: "blue" },
onConfirm: () => handleRestorePage(pageId),
});
};
const hasPages = deletedPages && deletedPages.items.length > 0;
const handlePageClick = (page: any) => {
setSelectedPage({ title: page.title, content: page.content });
setModalOpened(true);
};
return (
<Container size="lg" py="lg">
<Stack gap="md">
<Group justify="space-between" mb="md">
<Title order={2}>{t("Trash")}</Title>
</Group>
<Alert icon={<IconInfoCircle size={16} />} variant="light" color="red">
<Text size="sm">
{t("Pages in trash will be permanently deleted after 30 days.")}
</Text>
</Alert>
{isLoading || !deletedPages ? (
<></>
) : hasPages ? (
<Table.ScrollContainer minWidth={500}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Page")}</Table.Th>
<Table.Th style={{ whiteSpace: "nowrap" }}>
{t("Deleted by")}
</Table.Th>
<Table.Th style={{ whiteSpace: "nowrap" }}>
{t("Deleted at")}
</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{deletedPages.items.map((page) => (
<Table.Tr key={page.id}>
<Table.Td>
<Group
wrap="nowrap"
style={{ cursor: "pointer" }}
onClick={() => handlePageClick(page)}
>
{page.icon || (
<ActionIcon
variant="transparent"
color="gray"
size={18}
>
<IconFileDescription size={18} />
</ActionIcon>
)}
<div>
<Text fw={500} size="sm" lineClamp={1}>
{page.title || t("Untitled")}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>
<UserInfo user={page.deletedBy} size="sm" />
</Table.Td>
<Table.Td>
<Text
c="dimmed"
style={{ whiteSpace: "nowrap" }}
size="xs"
fw={500}
>
{formattedDate(page.deletedAt)}
</Text>
</Table.Td>
<Table.Td>
<Menu>
<Menu.Target>
<ActionIcon variant="subtle" color="gray">
<IconDots size={20} stroke={1.5} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconRestore size={16} />}
onClick={() =>
openRestoreModal(page.id, page.title)
}
>
{t("Restore")}
</Menu.Item>
<Menu.Item
color="red"
leftSection={<IconTrash size={16} />}
onClick={() => openDeleteModal(page.id, page.title)}
>
{t("Delete")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
) : (
<Text ta="center" py="xl" c="dimmed">
{t("No pages in trash")}
</Text>
)}
{deletedPages && deletedPages.items.length > 0 && (
<Paginate
currentPage={page}
hasPrevPage={deletedPages.meta.hasPrevPage}
hasNextPage={deletedPages.meta.hasNextPage}
onPageChange={setPage}
/>
)}
</Stack>
{selectedPage && (
<TrashPageContentModal
opened={modalOpened}
onClose={() => setModalOpened(false)}
pageTitle={selectedPage.title}
pageContent={selectedPage.content}
/>
)}
</Container>
);
}

View File

@ -50,7 +50,7 @@
"@nestjs/schedule": "^6.0.0",
"@nestjs/terminus": "^11.0.0",
"@nestjs/websockets": "^11.1.3",
"@node-saml/passport-saml": "^5.0.1",
"@node-saml/passport-saml": "^5.1.0",
"@react-email/components": "0.0.28",
"@react-email/render": "1.0.2",
"@socket.io/redis-adapter": "^8.3.0",

View File

@ -10,7 +10,6 @@ import { Typography } from '@tiptap/extension-typography';
import { TextStyle } from '@tiptap/extension-text-style';
import { Color } from '@tiptap/extension-color';
import { Youtube } from '@tiptap/extension-youtube';
import Table from '@tiptap/extension-table';
import {
Callout,
Comment,
@ -24,6 +23,7 @@ import {
TableHeader,
TableCell,
TableRow,
CustomTable,
TiptapImage,
TiptapVideo,
TrailingNode,
@ -65,7 +65,7 @@ export const tiptapExtensions = [
Details,
DetailsContent,
DetailsSummary,
Table,
CustomTable,
TableCell,
TableRow,
TableHeader,

View File

@ -50,6 +50,7 @@ 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';
import * as path from 'path';
@Controller()
export class AttachmentController {
@ -356,6 +357,11 @@ export class AttachmentController {
throw new BadRequestException('Invalid image attachment type');
}
const filenameWithoutExt = path.basename(fileName, path.extname(fileName));
if (!isValidUUID(filenameWithoutExt)) {
throw new BadRequestException('Invalid file id');
}
const filePath = `${getAttachmentFolderPath(attachmentType, workspace.id)}/${fileName}`;
try {

View File

@ -12,7 +12,7 @@ export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy {
super();
}
async process(job: Job<Space, void>): Promise<void> {
async process(job: Job<any, void>): Promise<void> {
try {
if (job.name === QueueJob.DELETE_SPACE_ATTACHMENTS) {
await this.attachmentService.handleDeleteSpaceAttachments(job.data.id);
@ -20,6 +20,11 @@ export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy {
if (job.name === QueueJob.DELETE_USER_AVATARS) {
await this.attachmentService.handleDeleteUserAvatars(job.data.id);
}
if (job.name === QueueJob.DELETE_PAGE_ATTACHMENTS) {
await this.attachmentService.handleDeletePageAttachments(
job.data.pageId,
);
}
} catch (err) {
throw err;
}

View File

@ -321,4 +321,50 @@ export class AttachmentService {
throw err;
}
}
async handleDeletePageAttachments(pageId: string) {
try {
// Fetch attachments for this page from database
const attachments = await this.db
.selectFrom('attachments')
.select(['id', 'filePath'])
.where('pageId', '=', pageId)
.execute();
if (!attachments || attachments.length === 0) {
return;
}
const failedDeletions = [];
await Promise.all(
attachments.map(async (attachment) => {
try {
// Delete from storage
await this.storageService.delete(attachment.filePath);
// Delete from database
await this.attachmentRepo.deleteAttachmentById(attachment.id);
} catch (err) {
failedDeletions.push(attachment.id);
this.logger.error(
`Failed to delete attachment ${attachment.id} for page ${pageId}:`,
err,
);
}
}),
);
if (failedDeletions.length > 0) {
this.logger.warn(
`Failed to delete ${failedDeletions.length} attachments for page ${pageId}`,
);
}
} catch (err) {
this.logger.error(
`Error in handleDeletePageAttachments for page ${pageId}:`,
err,
);
throw err;
}
}
}

View File

@ -0,0 +1,7 @@
import { IsOptional, IsString } from 'class-validator';
export class DeletedPageDto {
@IsOptional()
@IsString()
spaceId: string;
}

View File

@ -31,3 +31,9 @@ export class PageInfoDto extends PageIdDto {
@IsBoolean()
includeContent: boolean;
}
export class DeletePageDto extends PageIdDto {
@IsOptional()
@IsBoolean()
permanentlyDelete?: boolean;
}

View File

@ -13,7 +13,12 @@ import { PageService } from './services/page.service';
import { CreatePageDto } from './dto/create-page.dto';
import { UpdatePageDto } from './dto/update-page.dto';
import { MovePageDto, MovePageToSpaceDto } from './dto/move-page.dto';
import { PageHistoryIdDto, PageIdDto, PageInfoDto } from './dto/page.dto';
import {
PageHistoryIdDto,
PageIdDto,
PageInfoDto,
DeletePageDto,
} from './dto/page.dto';
import { PageHistoryService } from './services/page-history.service';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
@ -29,6 +34,7 @@ import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { RecentPageDto } from './dto/recent-page.dto';
import { DuplicatePageDto } from './dto/duplicate-page.dto';
import { DeletedPageDto } from './dto/deleted-page.dto';
@UseGuards(JwtAuthGuard)
@Controller('pages')
@ -100,7 +106,35 @@ export class PageController {
@HttpCode(HttpStatus.OK)
@Post('delete')
async delete(@Body() pageIdDto: PageIdDto, @AuthUser() user: User) {
async delete(@Body() deletePageDto: DeletePageDto, @AuthUser() user: User) {
const page = await this.pageRepo.findById(deletePageDto.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (deletePageDto.permanentlyDelete) {
// Permanent deletion requires space admin permissions
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
throw new ForbiddenException(
'Only space admins can permanently delete pages',
);
}
await this.pageService.forceDelete(deletePageDto.pageId);
} else {
// Soft delete requires page manage permissions
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
await this.pageService.remove(deletePageDto.pageId, user.id);
}
}
@HttpCode(HttpStatus.OK)
@Post('restore')
async restore(@Body() pageIdDto: PageIdDto, @AuthUser() user: User) {
const page = await this.pageRepo.findById(pageIdDto.pageId);
if (!page) {
@ -111,13 +145,14 @@ export class PageController {
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
await this.pageService.forceDelete(pageIdDto.pageId);
}
@HttpCode(HttpStatus.OK)
@Post('restore')
async restore(@Body() pageIdDto: PageIdDto) {
// await this.pageService.restore(deletePageDto.id);
await this.pageRepo.restorePage(pageIdDto.pageId);
// Return the restored page data with hasChildren info
const restoredPage = await this.pageRepo.findById(pageIdDto.pageId, {
includeHasChildren: true,
});
return restoredPage;
}
@HttpCode(HttpStatus.OK)
@ -146,6 +181,31 @@ export class PageController {
return this.pageService.getRecentPages(user.id, pagination);
}
@HttpCode(HttpStatus.OK)
@Post('trash')
async getDeletedPages(
@Body() deletedPageDto: DeletedPageDto,
@Body() pagination: PaginationOptions,
@AuthUser() user: User,
) {
if (deletedPageDto.spaceId) {
const ability = await this.spaceAbility.createForUser(
user,
deletedPageDto.spaceId,
);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
return this.pageService.getDeletedSpacePages(
deletedPageDto.spaceId,
pagination,
);
}
}
// TODO: scope to workspaces
@HttpCode(HttpStatus.OK)
@Post('/history')
async getPageHistory(

View File

@ -2,11 +2,12 @@ import { Module } from '@nestjs/common';
import { PageService } from './services/page.service';
import { PageController } from './page.controller';
import { PageHistoryService } from './services/page-history.service';
import { TrashCleanupService } from './services/trash-cleanup.service';
import { StorageModule } from '../../integrations/storage/storage.module';
@Module({
controllers: [PageController],
providers: [PageService, PageHistoryService],
providers: [PageService, PageHistoryService, TrashCleanupService],
exports: [PageService, PageHistoryService],
imports: [StorageModule]
})

View File

@ -17,8 +17,6 @@ import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
import { MovePageDto } from '../dto/move-page.dto';
import { ExpressionBuilder } from 'kysely';
import { DB } from '@docmost/db/types/db';
import { generateSlugId } from '../../../common/helpers';
import { executeTx } from '@docmost/db/utils';
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
@ -37,6 +35,9 @@ import {
} from '../dto/duplicate-page.dto';
import { Node as PMNode } from '@tiptap/pm/model';
import { StorageService } from '../../../integrations/storage/storage.service';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
@Injectable()
export class PageService {
@ -47,6 +48,7 @@ export class PageService {
private attachmentRepo: AttachmentRepo,
@InjectKysely() private readonly db: KyselyDB,
private readonly storageService: StorageService,
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
) {}
async findById(
@ -169,23 +171,6 @@ export class PageService {
});
}
withHasChildren(eb: ExpressionBuilder<DB, 'pages'>) {
return eb
.selectFrom('pages as child')
.select((eb) =>
eb
.case()
.when(eb.fn.countAll(), '>', 0)
.then(true)
.else(false)
.end()
.as('count'),
)
.whereRef('child.parentPageId', '=', 'pages.id')
.limit(1)
.as('hasChildren');
}
async getSidebarPages(
spaceId: string,
pagination: PaginationOptions,
@ -202,9 +187,11 @@ export class PageService {
'parentPageId',
'spaceId',
'creatorId',
'deletedAt',
])
.select((eb) => this.withHasChildren(eb))
.select((eb) => this.pageRepo.withHasChildren(eb))
.orderBy('position', 'asc')
.where('deletedAt', 'is', null)
.where('spaceId', '=', spaceId);
if (pageId) {
@ -527,9 +514,11 @@ export class PageService {
'position',
'parentPageId',
'spaceId',
'deletedAt',
])
.select((eb) => this.withHasChildren(eb))
.select((eb) => this.pageRepo.withHasChildren(eb))
.where('id', '=', childPageId)
.where('deletedAt', 'is', null)
.unionAll((exp) =>
exp
.selectFrom('pages as p')
@ -541,6 +530,7 @@ export class PageService {
'p.position',
'p.parentPageId',
'p.spaceId',
'p.deletedAt',
])
.select(
exp
@ -555,11 +545,13 @@ export class PageService {
.as('count'),
)
.whereRef('child.parentPageId', '=', 'id')
.where('child.deletedAt', 'is', null)
.limit(1)
.as('hasChildren'),
)
//.select((eb) => this.withHasChildren(eb))
.innerJoin('page_ancestors as pa', 'pa.parentPageId', 'p.id'),
.innerJoin('page_ancestors as pa', 'pa.parentPageId', 'p.id')
.where('p.deletedAt', 'is', null),
),
)
.selectFrom('page_ancestors')
@ -583,98 +575,58 @@ export class PageService {
return await this.pageRepo.getRecentPages(userId, pagination);
}
async getDeletedSpacePages(
spaceId: string,
pagination: PaginationOptions,
): Promise<PaginationResult<Page>> {
return await this.pageRepo.getDeletedPagesInSpace(spaceId, pagination);
}
async forceDelete(pageId: string): Promise<void> {
await this.pageRepo.deletePage(pageId);
// Get all descendant IDs (including the page itself) using recursive CTE
const descendants = await this.db
.withRecursive('page_descendants', (db) =>
db
.selectFrom('pages')
.select(['id'])
.where('id', '=', pageId)
.unionAll((exp) =>
exp
.selectFrom('pages as p')
.select(['p.id'])
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId'),
),
)
.selectFrom('page_descendants')
.selectAll()
.execute();
const pageIds = descendants.map((d) => d.id);
// Queue attachment deletion for all pages with unique job IDs to prevent duplicates
for (const id of pageIds) {
await this.attachmentQueue.add(
QueueJob.DELETE_PAGE_ATTACHMENTS,
{
pageId: id,
},
{
jobId: `delete-page-attachments-${id}`,
attempts: 3,
backoff: {
type: 'exponential',
delay: 5000,
},
},
);
}
if (pageIds.length > 0) {
await this.db.deleteFrom('pages').where('id', 'in', pageIds).execute();
}
}
async remove(pageId: string, userId: string): Promise<void> {
await this.pageRepo.removePage(pageId, userId);
}
}
/*
// TODO: page deletion and restoration
async delete(pageId: string): Promise<void> {
await this.dataSource.transaction(async (manager: EntityManager) => {
const page = await manager
.createQueryBuilder(Page, 'page')
.where('page.id = :pageId', { pageId })
.select(['page.id', 'page.workspaceId'])
.getOne();
if (!page) {
throw new NotFoundException(`Page not found`);
}
await this.softDeleteChildrenRecursive(page.id, manager);
await this.pageOrderingService.removePageFromHierarchy(page, manager);
await manager.softDelete(Page, pageId);
});
}
private async softDeleteChildrenRecursive(
parentId: string,
manager: EntityManager,
): Promise<void> {
const childrenPage = await manager
.createQueryBuilder(Page, 'page')
.where('page.parentPageId = :parentId', { parentId })
.select(['page.id', 'page.title', 'page.parentPageId'])
.getMany();
for (const child of childrenPage) {
await this.softDeleteChildrenRecursive(child.id, manager);
await manager.softDelete(Page, child.id);
}
}
async restore(pageId: string): Promise<void> {
await this.dataSource.transaction(async (manager: EntityManager) => {
const isDeleted = await manager
.createQueryBuilder(Page, 'page')
.where('page.id = :pageId', { pageId })
.withDeleted()
.getCount();
if (!isDeleted) {
return;
}
await manager.recover(Page, { id: pageId });
await this.restoreChildrenRecursive(pageId, manager);
// Fetch the page details to find out its parent and workspace
const restoredPage = await manager
.createQueryBuilder(Page, 'page')
.where('page.id = :pageId', { pageId })
.select(['page.id', 'page.title', 'page.spaceId', 'page.parentPageId'])
.getOne();
if (!restoredPage) {
throw new NotFoundException(`Restored page not found.`);
}
// add page back to its hierarchy
await this.pageOrderingService.addPageToOrder(
restoredPage.spaceId,
pageId,
restoredPage.parentPageId,
);
});
}
private async restoreChildrenRecursive(
parentId: string,
manager: EntityManager,
): Promise<void> {
const childrenPage = await manager
.createQueryBuilder(Page, 'page')
.setLock('pessimistic_write')
.where('page.parentPageId = :parentId', { parentId })
.select(['page.id', 'page.title', 'page.parentPageId'])
.withDeleted()
.getMany();
for (const child of childrenPage) {
await this.restoreChildrenRecursive(child.id, manager);
await manager.recover(Page, { id: child.id });
}
}
*/

View File

@ -0,0 +1,116 @@
import { Injectable, Logger } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
@Injectable()
export class TrashCleanupService {
private readonly logger = new Logger(TrashCleanupService.name);
private readonly RETENTION_DAYS = 30;
constructor(
@InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
) {}
@Interval('trash-cleanup', 24 * 60 * 60 * 1000) // every 24 hours
async cleanupOldTrash() {
try {
this.logger.log('Starting trash cleanup job');
const retentionDate = new Date();
retentionDate.setDate(retentionDate.getDate() - this.RETENTION_DAYS);
// Get all pages that were deleted more than 30 days ago
const oldDeletedPages = await this.db
.selectFrom('pages')
.select(['id', 'spaceId', 'workspaceId'])
.where('deletedAt', '<', retentionDate)
.execute();
if (oldDeletedPages.length === 0) {
this.logger.debug('No old trash items to clean up');
return;
}
this.logger.debug(`Found ${oldDeletedPages.length} pages to clean up`);
// Process each page
for (const page of oldDeletedPages) {
try {
await this.cleanupPage(page.id);
} catch (error) {
this.logger.error(
`Failed to cleanup page ${page.id}: ${error instanceof Error ? error.message : 'Unknown error'}`,
error instanceof Error ? error.stack : undefined,
);
}
}
this.logger.debug('Trash cleanup job completed');
} catch (error) {
this.logger.error(
'Trash cleanup job failed',
error instanceof Error ? error.stack : undefined,
);
}
}
private async cleanupPage(pageId: string) {
// Get all descendants using recursive CTE (including the page itself)
const descendants = await this.db
.withRecursive('page_descendants', (db) =>
db
.selectFrom('pages')
.select(['id'])
.where('id', '=', pageId)
.unionAll((exp) =>
exp
.selectFrom('pages as p')
.select(['p.id'])
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId'),
),
)
.selectFrom('page_descendants')
.selectAll()
.execute();
const pageIds = descendants.map((d) => d.id);
this.logger.debug(
`Cleaning up page ${pageId} with ${pageIds.length - 1} descendants`,
);
// Queue attachment deletion for all pages with unique job IDs to prevent duplicates
for (const id of pageIds) {
await this.attachmentQueue.add(
QueueJob.DELETE_PAGE_ATTACHMENTS,
{
pageId: id,
},
{
jobId: `delete-page-attachments-${id}`,
attempts: 3,
backoff: {
type: 'exponential',
delay: 5000,
},
},
);
}
try {
if (pageIds.length > 0) {
await this.db.deleteFrom('pages').where('id', 'in', pageIds).execute();
}
} catch (error) {
// Log but don't throw - pages might have been deleted by another node
this.logger.warn(
`Error deleting pages, they may have been already deleted: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
}
}
}

View File

@ -22,6 +22,24 @@ export class PageRepo {
private spaceMemberRepo: SpaceMemberRepo,
) {}
withHasChildren(eb: ExpressionBuilder<DB, 'pages'>) {
return eb
.selectFrom('pages as child')
.select((eb) =>
eb
.case()
.when(eb.fn.countAll(), '>', 0)
.then(true)
.else(false)
.end()
.as('count'),
)
.whereRef('child.parentPageId', '=', 'pages.id')
.where('child.deletedAt', 'is', null)
.limit(1)
.as('hasChildren');
}
private baseFields: Array<keyof Page> = [
'id',
'slugId',
@ -50,6 +68,7 @@ export class PageRepo {
includeCreator?: boolean;
includeLastUpdatedBy?: boolean;
includeContributors?: boolean;
includeHasChildren?: boolean;
withLock?: boolean;
trx?: KyselyTransaction;
},
@ -60,7 +79,10 @@ export class PageRepo {
.selectFrom('pages')
.select(this.baseFields)
.$if(opts?.includeContent, (qb) => qb.select('content'))
.$if(opts?.includeYdoc, (qb) => qb.select('ydoc'));
.$if(opts?.includeYdoc, (qb) => qb.select('ydoc'))
.$if(opts?.includeHasChildren, (qb) =>
qb.select((eb) => this.withHasChildren(eb)),
);
if (opts?.includeCreator) {
query = query.select((eb) => this.withCreator(eb));
@ -139,12 +161,107 @@ export class PageRepo {
await query.execute();
}
async removePage(pageId: string, deletedById: string): Promise<void> {
const currentDate = new Date();
const descendants = await this.db
.withRecursive('page_descendants', (db) =>
db
.selectFrom('pages')
.select(['id'])
.where('id', '=', pageId)
.unionAll((exp) =>
exp
.selectFrom('pages as p')
.select(['p.id'])
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId'),
),
)
.selectFrom('page_descendants')
.selectAll()
.execute();
const pageIds = descendants.map((d) => d.id);
await this.db
.updateTable('pages')
.set({
deletedById: deletedById,
deletedAt: currentDate,
})
.where('id', 'in', pageIds)
.execute();
}
async restorePage(pageId: string): Promise<void> {
// First, check if the page being restored has a deleted parent
const pageToRestore = await this.db
.selectFrom('pages')
.select(['id', 'parentPageId'])
.where('id', '=', pageId)
.executeTakeFirst();
if (!pageToRestore) {
return;
}
// Check if the parent is also deleted
let shouldDetachFromParent = false;
if (pageToRestore.parentPageId) {
const parent = await this.db
.selectFrom('pages')
.select(['id', 'deletedAt'])
.where('id', '=', pageToRestore.parentPageId)
.executeTakeFirst();
// If parent is deleted, we should detach this page from it
shouldDetachFromParent = parent?.deletedAt !== null;
}
// Find all descendants to restore
const pages = await this.db
.withRecursive('page_descendants', (db) =>
db
.selectFrom('pages')
.select(['id'])
.where('id', '=', pageId)
.unionAll((exp) =>
exp
.selectFrom('pages as p')
.select(['p.id'])
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId'),
),
)
.selectFrom('page_descendants')
.selectAll()
.execute();
const pageIds = pages.map((p) => p.id);
// Restore all pages, but only detach the root page if its parent is deleted
await this.db
.updateTable('pages')
.set({ deletedById: null, deletedAt: null })
.where('id', 'in', pageIds)
.execute();
// If we need to detach the restored page from its deleted parent
if (shouldDetachFromParent) {
await this.db
.updateTable('pages')
.set({ parentPageId: null })
.where('id', '=', pageId)
.execute();
}
}
async getRecentPagesInSpace(spaceId: string, pagination: PaginationOptions) {
const query = this.db
.selectFrom('pages')
.select(this.baseFields)
.select((eb) => this.withSpace(eb))
.where('spaceId', '=', spaceId)
.where('deletedAt', 'is', null)
.orderBy('updatedAt', 'desc');
const result = executeWithPagination(query, {
@ -163,6 +280,7 @@ export class PageRepo {
.select(this.baseFields)
.select((eb) => this.withSpace(eb))
.where('spaceId', 'in', userSpaceIds)
.where('deletedAt', 'is', null)
.orderBy('updatedAt', 'desc');
const hasEmptyIds = userSpaceIds.length === 0;
@ -175,6 +293,41 @@ export class PageRepo {
return result;
}
async getDeletedPagesInSpace(spaceId: string, pagination: PaginationOptions) {
const query = this.db
.selectFrom('pages')
.select(this.baseFields)
.select('content')
.select((eb) => this.withSpace(eb))
.select((eb) => this.withDeletedBy(eb))
.where('spaceId', '=', spaceId)
.where('deletedAt', 'is not', null)
// Only include pages that are either root pages (no parent) or whose parent is not deleted
// This prevents showing orphaned pages when their parent has been soft-deleted
.where((eb) =>
eb.or([
eb('parentPageId', 'is', null),
eb.not(
eb.exists(
eb
.selectFrom('pages as parent')
.select('parent.id')
.where('parent.id', '=', eb.ref('pages.parentPageId'))
.where('parent.deletedAt', 'is not', null),
),
),
]),
)
.orderBy('deletedAt', 'desc');
const result = executeWithPagination(query, {
page: pagination.page,
perPage: pagination.limit,
});
return result;
}
withSpace(eb: ExpressionBuilder<DB, 'pages'>) {
return jsonObjectFrom(
eb
@ -202,6 +355,15 @@ export class PageRepo {
).as('lastUpdatedBy');
}
withDeletedBy(eb: ExpressionBuilder<DB, 'pages'>) {
return jsonObjectFrom(
eb
.selectFrom('users')
.select(['users.id', 'users.name', 'users.avatarUrl'])
.whereRef('users.id', '=', 'pages.deletedById'),
).as('deletedBy');
}
withContributors(eb: ExpressionBuilder<DB, 'pages'>) {
return jsonArrayFrom(
eb

View File

@ -18,6 +18,7 @@
"dev": "pnpm concurrently -n \"frontend,backend\" -c \"cyan,green\" \"pnpm run client:dev\" \"pnpm run server:dev\""
},
"dependencies": {
"@braintree/sanitize-url": "^7.1.0",
"@docmost/editor-ext": "workspace:*",
"@hocuspocus/extension-redis": "^2.15.2",
"@hocuspocus/provider": "^2.15.2",

View File

@ -1,5 +1,6 @@
import { Node, mergeAttributes } from '@tiptap/core';
import { ReactNodeViewRenderer } from '@tiptap/react';
import { sanitizeUrl } from './utils';
export interface EmbedOptions {
HTMLAttributes: Record<string, any>;
@ -40,9 +41,12 @@ export const Embed = Node.create<EmbedOptions>({
return {
src: {
default: '',
parseHTML: (element) => element.getAttribute('data-src'),
parseHTML: (element) => {
const src = element.getAttribute('data-src');
return sanitizeUrl(src);
},
renderHTML: (attributes: EmbedAttributes) => ({
'data-src': attributes.src,
'data-src': sanitizeUrl(attributes.src),
}),
},
provider: {
@ -85,6 +89,9 @@ export const Embed = Node.create<EmbedOptions>({
},
renderHTML({ HTMLAttributes }) {
const src = HTMLAttributes["data-src"];
const safeHref = sanitizeUrl(src);
return [
"div",
mergeAttributes(
@ -95,10 +102,10 @@ export const Embed = Node.create<EmbedOptions>({
[
"a",
{
href: HTMLAttributes["data-src"],
href: safeHref,
target: "blank",
},
`${HTMLAttributes["data-src"]}`,
safeHref,
],
];
},
@ -108,9 +115,15 @@ export const Embed = Node.create<EmbedOptions>({
setEmbed:
(attrs: EmbedAttributes) =>
({ commands }) => {
// Validate the URL before inserting
const validatedAttrs = {
...attrs,
src: sanitizeUrl(attrs.src),
};
return commands.insertContent({
type: 'embed',
attrs: attrs,
attrs: validatedAttrs,
});
},
};

View File

@ -2,7 +2,7 @@ import { TableCell as TiptapTableCell } from "@tiptap/extension-table-cell";
export const TableCell = TiptapTableCell.extend({
name: "tableCell",
content: "paragraph+",
content: "(paragraph | heading | bulletList | orderedList | taskList | blockquote | callout | image | video | attachment | mathBlock | details | codeBlock)+",
addAttributes() {
return {

View File

@ -2,7 +2,7 @@ import { TableHeader as TiptapTableHeader } from "@tiptap/extension-table-header
export const TableHeader = TiptapTableHeader.extend({
name: "tableHeader",
content: "paragraph+",
content: "(paragraph | heading | bulletList | orderedList | taskList | blockquote | callout | image | video | attachment | mathBlock | details | codeBlock)+",
addAttributes() {
return {

View File

@ -1,3 +1,4 @@
export * from "./row";
export * from "./cell";
export * from "./header";
export * from "./table";

View File

@ -0,0 +1,65 @@
import Table from "@tiptap/extension-table";
import { Editor } from "@tiptap/core";
const LIST_TYPES = ["bulletList", "orderedList", "taskList"];
function isInList(editor: Editor): boolean {
const { $from } = editor.state.selection;
for (let depth = $from.depth; depth > 0; depth--) {
const node = $from.node(depth);
if (LIST_TYPES.includes(node.type.name)) {
return true;
}
}
return false;
}
function handleListIndent(editor: Editor): boolean {
return editor.commands.sinkListItem("listItem") ||
editor.commands.sinkListItem("taskItem");
}
function handleListOutdent(editor: Editor): boolean {
return editor.commands.liftListItem("listItem") ||
editor.commands.liftListItem("taskItem");
}
export const CustomTable = Table.extend({
addKeyboardShortcuts() {
return {
...this.parent?.(),
Tab: () => {
// If we're in a list within a table, handle list indentation
if (isInList(this.editor) && this.editor.isActive("table")) {
if (handleListIndent(this.editor)) {
return true;
}
}
// Otherwise, use default table navigation
if (this.editor.commands.goToNextCell()) {
return true;
}
if (!this.editor.can().addRowAfter()) {
return false;
}
return this.editor.chain().addRowAfter().goToNextCell().run();
},
"Shift-Tab": () => {
// If we're in a list within a table, handle list outdentation
if (isInList(this.editor) && this.editor.isActive("table")) {
if (handleListOutdent(this.editor)) {
return true;
}
}
// Otherwise, use default table navigation
return this.editor.commands.goToPreviousCell();
},
};
},
});

View File

@ -4,6 +4,7 @@ import { Selection, Transaction } from "@tiptap/pm/state";
import { CellSelection, TableMap } from "@tiptap/pm/tables";
import { Node, ResolvedPos } from "@tiptap/pm/model";
import Table from "@tiptap/extension-table";
import { sanitizeUrl as braintreeSanitizeUrl } from "@braintree/sanitize-url";
export const isRectSelected = (rect: any) => (selection: CellSelection) => {
const map = TableMap.get(selection.$anchorCell.node(-1));
@ -379,3 +380,12 @@ export function setAttributes(
export function icon(name: string) {
return `<span class="ProseMirror-icon ProseMirror-icon-${name}"></span>`;
}
export function sanitizeUrl(url: string | undefined): string {
if (!url) return "";
const sanitized = braintreeSanitizeUrl(url);
// Return empty string instead of "about:blank"
return sanitized === "about:blank" ? "" : sanitized;
}

59
pnpm-lock.yaml generated
View File

@ -16,6 +16,9 @@ importers:
.:
dependencies:
'@braintree/sanitize-url':
specifier: ^7.1.0
version: 7.1.0
'@docmost/editor-ext':
specifier: workspace:*
version: link:packages/editor-ext
@ -481,8 +484,8 @@ importers:
specifier: ^11.1.3
version: 11.1.3(@nestjs/common@11.1.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.3)(@nestjs/platform-socket.io@11.1.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@node-saml/passport-saml':
specifier: ^5.0.1
version: 5.0.1
specifier: ^5.1.0
version: 5.1.0
'@react-email/components':
specifier: 0.0.28
version: 0.0.28(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@ -2862,12 +2865,12 @@ packages:
resolution: {integrity: sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ==}
engines: {node: ^14.21.3 || >=16}
'@node-saml/node-saml@5.0.1':
resolution: {integrity: sha512-YQzFPEC+CnsfO9AFYnwfYZKIzOLx3kITaC1HrjHVLTo6hxcQhc+LgHODOMvW4VCV95Gwrz1MshRUWCPzkDqmnA==}
'@node-saml/node-saml@5.1.0':
resolution: {integrity: sha512-t3cJnZ4aC7HhPZ6MGylGZULvUtBOZ6FzuUndaHGXjmIZHXnLfC/7L8a57O9Q9V7AxJGKAiRM5zu2wNm9EsvQpw==}
engines: {node: '>= 18'}
'@node-saml/passport-saml@5.0.1':
resolution: {integrity: sha512-fMztg3zfSnjLEgxvpl6HaDMNeh0xeQX4QHiF9e2Lsie2dc4qFE37XYbQZhVmn8XJ2awPpSWLQ736UskYgGU8lQ==}
'@node-saml/passport-saml@5.1.0':
resolution: {integrity: sha512-pBm+iFjv9eihcgeJuSUs4c0AuX1QEFdHwP8w1iaWCfDzXdeWZxUBU5HT2bY2S4dvNutcy+A9hYsH7ZLBGtgwDg==}
engines: {node: '>= 18'}
'@nodelib/fs.scandir@2.1.5':
@ -4247,6 +4250,9 @@ packages:
'@types/express@4.17.21':
resolution: {integrity: sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==}
'@types/express@4.17.23':
resolution: {integrity: sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==}
'@types/file-saver@2.0.7':
resolution: {integrity: sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==}
@ -4355,6 +4361,9 @@ packages:
'@types/passport@1.0.16':
resolution: {integrity: sha512-FD0qD5hbPWQzaM0wHUnJ/T0BBCJBxCeemtnCwc/ThhTg3x9jfrAcRUmj5Dopza+MfFS9acTe3wk7rcVnRIp/0A==}
'@types/passport@1.0.17':
resolution: {integrity: sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==}
'@types/pg@8.11.11':
resolution: {integrity: sha512-kGT1qKM8wJQ5qlawUrEkXgvMSXoV213KfMGXcwfDwUIfUHXqXYXOfS1nE1LINRJVVVx5wCm70XnFlMHaIcQAfw==}
@ -4364,6 +4373,9 @@ packages:
'@types/qrcode@1.5.5':
resolution: {integrity: sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==}
'@types/qs@6.14.0':
resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==}
'@types/qs@6.9.14':
resolution: {integrity: sha512-5khscbd3SwWMhFqylJBLQ0zIu7c1K6Vz0uBIt915BI3zV0q1nfjRQD3RqSBcPaO6PHEF4ov/t9y89fSiyThlPA==}
@ -9517,8 +9529,8 @@ packages:
utf-8-validate:
optional: true
xml-crypto@6.0.1:
resolution: {integrity: sha512-v05aU7NS03z4jlZ0iZGRFeZsuKO1UfEbbYiaeRMiATBFs6Jq9+wqKquEMTn4UTrYZ9iGD8yz3KT4L9o2iF682w==}
xml-crypto@6.1.2:
resolution: {integrity: sha512-leBOVQdVi8FvPJrMYoum7Ici9qyxfE4kVi+AkpUoYCSXaQF4IlBm1cneTK9oAxR61LpYxTx7lNcsnBIeRpGW2w==}
engines: {node: '>=16'}
xml-encryption@3.1.0:
@ -12606,16 +12618,16 @@ snapshots:
'@noble/hashes@1.7.1': {}
'@node-saml/node-saml@5.0.1':
'@node-saml/node-saml@5.1.0':
dependencies:
'@types/debug': 4.1.12
'@types/qs': 6.9.14
'@types/qs': 6.14.0
'@types/xml-encryption': 1.2.4
'@types/xml2js': 0.4.14
'@xmldom/is-dom-node': 1.0.1
'@xmldom/xmldom': 0.8.10
debug: 4.3.7
xml-crypto: 6.0.1
debug: 4.4.0
xml-crypto: 6.1.2
xml-encryption: 3.1.0
xml2js: 0.6.2
xmlbuilder: 15.1.1
@ -12623,11 +12635,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@node-saml/passport-saml@5.0.1':
'@node-saml/passport-saml@5.1.0':
dependencies:
'@node-saml/node-saml': 5.0.1
'@types/express': 4.17.21
'@types/passport': 1.0.16
'@node-saml/node-saml': 5.1.0
'@types/express': 4.17.23
'@types/passport': 1.0.17
'@types/passport-strategy': 0.2.38
passport: 0.7.0
passport-strategy: 1.0.0
@ -14107,6 +14119,13 @@ snapshots:
'@types/qs': 6.9.14
'@types/serve-static': 1.15.5
'@types/express@4.17.23':
dependencies:
'@types/body-parser': 1.19.5
'@types/express-serve-static-core': 4.17.43
'@types/qs': 6.9.14
'@types/serve-static': 1.15.5
'@types/file-saver@2.0.7': {}
'@types/fs-extra@11.0.4':
@ -14228,6 +14247,10 @@ snapshots:
dependencies:
'@types/express': 4.17.21
'@types/passport@1.0.17':
dependencies:
'@types/express': 4.17.23
'@types/pg@8.11.11':
dependencies:
'@types/node': 22.13.4
@ -14240,6 +14263,8 @@ snapshots:
dependencies:
'@types/node': 22.13.4
'@types/qs@6.14.0': {}
'@types/qs@6.9.14': {}
'@types/range-parser@1.2.7': {}
@ -20211,7 +20236,7 @@ snapshots:
ws@8.18.2: {}
xml-crypto@6.0.1:
xml-crypto@6.1.2:
dependencies:
'@xmldom/is-dom-node': 1.0.1
'@xmldom/xmldom': 0.8.10