Compare commits

...

34 Commits

Author SHA1 Message Date
Philipinho a2ae341934 v0.2.5 2024-07-12 18:05:59 +01:00
Philipinho 3c70e40d16 add table header by default 2024-07-12 16:53:33 +01:00
Philipinho 14197d7365 add cookie duration 2024-07-12 14:52:09 +01:00
Philip Okugbe f388540293 feat: Individual page export in Markdown and HTML formats (#80)
* fix maths node

* render default html width

* Add page export module
* with support for html and markdown exports

* Page export UI
* Add PDF print too

* remove unused import
2024-07-12 14:45:09 +01:00
Philipinho b43de81013 cleanup 2024-07-07 16:36:14 +01:00
Philipinho 6659adc7fe v0.2.4 2024-07-07 16:31:28 +01:00
Philip Okugbe 24adff9679 Merge pull request #74 from docmost/remove-redundant-page-slug_id-index
remove redundant page slug_id index
2024-07-07 16:29:21 +01:00
Philipinho e960b8c1a9 create migration 2024-07-07 16:27:43 +01:00
Philipinho 1958067110 Revert "remove redundant slug_id index"
This reverts commit 3e519ebcd8.
2024-07-07 16:16:13 +01:00
Philipinho 3e519ebcd8 remove redundant slug_id index 2024-07-07 16:07:43 +01:00
Philip Okugbe 07cd650205 Merge pull request #73 from docmost/fix/case-insensitive-email
make emails case-insensitive
2024-07-07 15:51:14 +01:00
Philipinho 949d782a28 make emails case-insensitive 2024-07-07 15:49:43 +01:00
Philipinho 295d4325bf update tiptap hocuspocus 2024-07-07 15:36:36 +01:00
Philip Okugbe 40a40bb3c7 Merge pull request #71 from docmost/aws_env_option
Add support for AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables
2024-07-07 09:49:31 +01:00
Philipinho bc1579b022 fix condition check 2024-07-07 09:48:43 +01:00
Philip Okugbe 85b3073681 Merge pull request #57 from will2hew/will/url-setup
CORS setup and Vite devServer proxy
2024-07-07 09:42:51 +01:00
Philipinho 4af3a54649 Allow AWS SDK use AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY 2024-07-07 09:24:19 +01:00
Philip Okugbe c4c169b17a Merge pull request #64 from docmost/feat/health
Add health check
2024-07-06 01:06:17 +01:00
Philipinho a0536d852f cleanup indicators 2024-07-05 23:05:19 +01:00
Philipinho f12f93b373 increase startup db retry limit 2024-07-05 19:00:55 +01:00
Philipinho 35dcd5f254 refactor health module 2024-07-05 18:59:26 +01:00
Philipinho 9496ec9b57 prevent database error from crashing server 2024-07-05 18:59:16 +01:00
Will H 5ace7616d0 cors setup and dev server proxy 2024-07-05 16:25:00 +12:00
Will H ce6a05ab66 cr - add redis check and logging 2024-07-05 13:40:51 +12:00
Philipinho 7383673636 v0.2.3 2024-07-05 00:49:08 +01:00
Philip Okugbe 3e7b2495c5 Merge pull request #51 from docmost/private-attachments
make page attachments private
2024-07-05 00:47:51 +01:00
Philip Okugbe 0fc8edeb52 Merge pull request #55 from docmost/fix/bug-fixes
Fix: missing tree, editor font and responsive recent pages
2024-07-05 00:45:32 +01:00
Philipinho bbf865b2f6 cleanup debug log 2024-07-05 00:41:30 +01:00
Philipinho 0c622a0dc1 use sane font-weight 2024-07-05 00:33:59 +01:00
Philipinho f52cd011a4 remove unused imports 2024-07-05 00:33:12 +01:00
Philipinho a4d53468c3 fix tree state 2024-07-05 00:30:56 +01:00
Will H 66773dfaca Add health check and dev script 2024-07-05 10:10:08 +12:00
Philipinho cc93abfb7e make pages table responsive 2024-07-04 21:13:43 +01:00
Philipinho 13f26f9c31 make page attachments private 2024-07-04 16:01:35 +01:00
54 changed files with 1238 additions and 195 deletions
+2 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "client", "name": "client",
"private": true, "private": true,
"version": "0.2.2", "version": "0.2.5",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
@@ -26,6 +26,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"emoji-mart": "^5.6.0", "emoji-mart": "^5.6.0",
"file-saver": "^2.0.5",
"jotai": "^2.8.3", "jotai": "^2.8.3",
"jotai-optics": "^0.4.0", "jotai-optics": "^0.4.0",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
@@ -1,4 +1,11 @@
import { Text, Group, UnstyledButton, Badge, Table } from "@mantine/core"; import {
Text,
Group,
UnstyledButton,
Badge,
Table,
ScrollArea,
} from "@mantine/core";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx"; import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx";
import { buildPageUrl } from "@/features/page/page.utils.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts";
@@ -22,46 +29,48 @@ export default function RecentChanges({ spaceId }: Props) {
} }
return pages && pages.items.length > 0 ? ( return pages && pages.items.length > 0 ? (
<Table highlightOnHover verticalSpacing="sm"> <ScrollArea>
<Table.Tbody> <Table highlightOnHover verticalSpacing="sm">
{pages.items.map((page) => ( <Table.Tbody>
<Table.Tr key={page.id}> {pages.items.map((page) => (
<Table.Td> <Table.Tr key={page.id}>
<UnstyledButton
component={Link}
to={buildPageUrl(page?.space.slug, page.slugId, page.title)}
>
<Group wrap="nowrap">
{page.icon || <IconFileDescription size={18} />}
<Text fw={500} size="md" lineClamp={1}>
{page.title || "Untitled"}
</Text>
</Group>
</UnstyledButton>
</Table.Td>
{!spaceId && (
<Table.Td> <Table.Td>
<Badge <UnstyledButton
color="blue"
variant="light"
component={Link} component={Link}
to={getSpaceUrl(page?.space.slug)} to={buildPageUrl(page?.space.slug, page.slugId, page.title)}
style={{ cursor: "pointer" }}
> >
{page?.space.name} <Group wrap="nowrap">
</Badge> {page.icon || <IconFileDescription size={18} />}
<Text fw={500} size="md" lineClamp={1}>
{page.title || "Untitled"}
</Text>
</Group>
</UnstyledButton>
</Table.Td> </Table.Td>
)} {!spaceId && (
<Table.Td> <Table.Td>
<Text c="dimmed" size="xs" fw={500}> <Badge
{formattedDate(page.updatedAt)} color="blue"
</Text> variant="light"
</Table.Td> component={Link}
</Table.Tr> to={getSpaceUrl(page?.space.slug)}
))} style={{ cursor: "pointer" }}
</Table.Tbody> >
</Table> {page?.space.name}
</Badge>
</Table.Td>
)}
<Table.Td>
<Text c="dimmed" size="xs" fw={500}>
{formattedDate(page.updatedAt)}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea>
) : ( ) : (
<Text size="md" ta="center"> <Text size="md" ta="center">
No pages yet No pages yet
@@ -1,14 +1,17 @@
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { createJSONStorage, atomWithStorage } from "jotai/utils"; import { createJSONStorage, atomWithStorage } from "jotai/utils";
import { ITokens } from '../types/auth.types'; import { ITokens } from "../types/auth.types";
const cookieStorage = createJSONStorage<ITokens>(() => { const cookieStorage = createJSONStorage<ITokens>(() => {
return { return {
getItem: () => Cookies.get("authTokens"), getItem: () => Cookies.get("authTokens"),
setItem: (key, value) => Cookies.set(key, value), setItem: (key, value) => Cookies.set(key, value, { expires: 30 }),
removeItem: (key) => Cookies.remove(key), removeItem: (key) => Cookies.remove(key),
}; };
}); });
export const authTokensAtom = atomWithStorage<ITokens | null>("authTokens", null, cookieStorage); export const authTokensAtom = atomWithStorage<ITokens | null>(
"authTokens",
null,
cookieStorage,
);
@@ -4,11 +4,7 @@ import { useNavigate } from "react-router-dom";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom"; import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom"; import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { import { ILogin, ISetupWorkspace } from "@/features/auth/types/auth.types";
ILogin,
IRegister,
ISetupWorkspace,
} from "@/features/auth/types/auth.types";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { IAcceptInvite } from "@/features/workspace/types/workspace.types.ts"; import { IAcceptInvite } from "@/features/workspace/types/workspace.types.ts";
import { acceptInvitation } from "@/features/workspace/services/workspace-service.ts"; import { acceptInvitation } from "@/features/workspace/services/workspace-service.ts";
@@ -49,7 +45,6 @@ export default function useAuth() {
const res = await acceptInvitation(data); const res = await acceptInvitation(data);
setIsLoading(false); setIsLoading(false);
console.log(res);
setAuthToken(res.tokens); setAuthToken(res.tokens);
navigate(APP_ROUTE.HOME); navigate(APP_ROUTE.HOME);
@@ -184,7 +184,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
.chain() .chain()
.focus() .focus()
.deleteRange(range) .deleteRange(range)
.insertTable({ rows: 3, cols: 3, withHeaderRow: false }) .insertTable({ rows: 3, cols: 3, withHeaderRow: true })
.run(), .run(),
}, },
{ {
@@ -3,7 +3,7 @@ import React from "react";
import { TitleEditor } from "@/features/editor/title-editor"; import { TitleEditor } from "@/features/editor/title-editor";
import PageEditor from "@/features/editor/page-editor"; import PageEditor from "@/features/editor/page-editor";
import { Container } from "@mantine/core"; import { Container } from "@mantine/core";
import { useAtom } from "jotai/index"; import { useAtom } from "jotai";
import { userAtom } from "@/features/user/atoms/current-user-atom.ts"; import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
const MemoizedTitleEditor = React.memo(TitleEditor); const MemoizedTitleEditor = React.memo(TitleEditor);
@@ -9,7 +9,7 @@
); );
font-size: var(--mantine-font-size-md); font-size: var(--mantine-font-size-md);
line-height: var(--mantine-line-height-xl); line-height: var(--mantine-line-height-xl);
font-weight: 415; font-weight: 400;
width: 100%; width: 100%;
> * + * { > * + * {
@@ -163,3 +163,4 @@
.actionIconGroup { .actionIconGroup {
background: var(--mantine-color-body); background: var(--mantine-color-body);
} }
@@ -2,5 +2,10 @@
height: 100%; height: 100%;
padding: 8px 20px; padding: 8px 20px;
margin: 64px auto; margin: 64px auto;
@media print {
padding: 0;
margin: 0;
}
} }
@@ -8,5 +8,7 @@
@import "./youtube.css"; @import "./youtube.css";
@import "./media.css"; @import "./media.css";
@import "./code.css"; @import "./code.css";
@import "./print.css";
@@ -4,6 +4,10 @@
color: #adb5bd; color: #adb5bd;
pointer-events: none; pointer-events: none;
height: 0; height: 0;
@media print {
display: none;
}
} }
.ProseMirror .is-empty::before { .ProseMirror .is-empty::before {
@@ -12,9 +16,17 @@
color: #adb5bd; color: #adb5bd;
pointer-events: none; pointer-events: none;
height: 0; height: 0;
@media print {
display: none;
}
} }
.ProseMirror table .is-editor-empty:first-child::before, .ProseMirror table .is-editor-empty:first-child::before,
.ProseMirror table .is-empty::before { .ProseMirror table .is-empty::before {
content: ''; content: '';
@media print {
display: none;
}
} }
@@ -0,0 +1,11 @@
@media print {
.mantine-AppShell-header,
.mantine-AppShell-navbar,
.mantine-AppShell-aside{
display: none !important;
}
.mantine-AppShell-main {
padding-top: 0 !important;
}
}
@@ -4,7 +4,7 @@
iframe { iframe {
display: block; display: block;
outline: 0px solid transparent; outline: 0 solid transparent;
border-radius: var(--mantine-radius-md); border-radius: var(--mantine-radius-md);
width: 100%; width: 100%;
} }
@@ -17,5 +17,9 @@
&.ProseMirror-selectednode { &.ProseMirror-selectednode {
background-color: transparent; background-color: transparent;
} }
@media print {
display: none;
}
} }
} }
@@ -2,16 +2,18 @@ import { ActionIcon, Group, Menu, Tooltip } from "@mantine/core";
import { import {
IconArrowsHorizontal, IconArrowsHorizontal,
IconDots, IconDots,
IconDownload,
IconHistory, IconHistory,
IconLink, IconLink,
IconMessage, IconMessage,
IconPrinter,
IconTrash, IconTrash,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import React from "react"; import React from "react";
import useToggleAside from "@/hooks/use-toggle-aside.tsx"; import useToggleAside from "@/hooks/use-toggle-aside.tsx";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts"; import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
import { useClipboard } from "@mantine/hooks"; import { useClipboard, useDisclosure } from "@mantine/hooks";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts"; import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts";
@@ -21,6 +23,7 @@ import { extractPageSlugId } from "@/lib";
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts"; import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx"; import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx"; import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx";
import PageExportModal from "@/features/page/components/page-export-modal.tsx";
interface PageHeaderMenuProps { interface PageHeaderMenuProps {
readOnly?: boolean; readOnly?: boolean;
@@ -57,6 +60,8 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
}); });
const { openDeleteModal } = useDeletePageModal(); const { openDeleteModal } = useDeletePageModal();
const [tree] = useAtom(treeApiAtom); const [tree] = useAtom(treeApiAtom);
const [opened, { open: openExportModal, close: closeExportModal }] =
useDisclosure(false);
const handleCopyLink = () => { const handleCopyLink = () => {
const pageUrl = const pageUrl =
@@ -66,6 +71,12 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
notifications.show({ message: "Link copied" }); notifications.show({ message: "Link copied" });
}; };
const handlePrint = () => {
setTimeout(() => {
window.print();
}, 250);
};
const openHistoryModal = () => { const openHistoryModal = () => {
setHistoryModalOpen(true); setHistoryModalOpen(true);
}; };
@@ -75,55 +86,79 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
}; };
return ( return (
<Menu <>
shadow="xl" <Menu
position="bottom-end" shadow="xl"
offset={20} position="bottom-end"
width={200} offset={20}
withArrow width={200}
arrowPosition="center" withArrow
> arrowPosition="center"
<Menu.Target> >
<ActionIcon variant="default" style={{ border: "none" }}> <Menu.Target>
<IconDots size={20} stroke={2} /> <ActionIcon variant="default" style={{ border: "none" }}>
</ActionIcon> <IconDots size={20} />
</Menu.Target> </ActionIcon>
</Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Item <Menu.Item
leftSection={<IconLink size={16} stroke={2} />} leftSection={<IconLink size={16} />}
onClick={handleCopyLink} onClick={handleCopyLink}
> >
Copy link Copy link
</Menu.Item> </Menu.Item>
<Menu.Divider /> <Menu.Divider />
<Menu.Item leftSection={<IconArrowsHorizontal size={16} stroke={2} />}> <Menu.Item leftSection={<IconArrowsHorizontal size={16} />}>
<Group wrap="nowrap"> <Group wrap="nowrap">
<PageWidthToggle label="Full width" /> <PageWidthToggle label="Full width" />
</Group> </Group>
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item
leftSection={<IconHistory size={16} stroke={2} />} leftSection={<IconHistory size={16} />}
onClick={openHistoryModal} onClick={openHistoryModal}
> >
Page history Page history
</Menu.Item> </Menu.Item>
{!readOnly && ( <Menu.Divider />
<>
<Menu.Divider /> <Menu.Item
<Menu.Item leftSection={<IconDownload size={16} />}
color={"red"} onClick={openExportModal}
leftSection={<IconTrash size={16} stroke={2} />} >
onClick={handleDeletePage} Export
> </Menu.Item>
Delete
</Menu.Item> <Menu.Item
</> leftSection={<IconPrinter size={16} />}
)} onClick={handlePrint}
</Menu.Dropdown> >
</Menu> Print PDF
</Menu.Item>
{!readOnly && (
<>
<Menu.Divider />
<Menu.Item
color={"red"}
leftSection={<IconTrash size={16} />}
onClick={handleDeletePage}
>
Delete
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
<PageExportModal
pageId={page.id}
open={opened}
onClose={closeExportModal}
/>
</>
); );
} }
@@ -8,4 +8,8 @@
top: var(--app-shell-header-offset, 0rem); top: var(--app-shell-header-offset, 0rem);
inset-inline-start: var(--app-shell-navbar-offset, 0rem); inset-inline-start: var(--app-shell-navbar-offset, 0rem);
inset-inline-end: var(--app-shell-aside-offset, 0rem); inset-inline-end: var(--app-shell-aside-offset, 0rem);
@media print {
display: none;
}
} }
@@ -0,0 +1,84 @@
import { Modal, Button, Group, Text, Select } from "@mantine/core";
import { exportPage } from "@/features/page/services/page-service.ts";
import { useState } from "react";
import * as React from "react";
import { ExportFormat } from "@/features/page/types/page.types.ts";
import { notifications } from "@mantine/notifications";
interface PageExportModalProps {
pageId: string;
open: boolean;
onClose: () => void;
}
export default function PageExportModal({
pageId,
open,
onClose,
}: PageExportModalProps) {
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
const handleExport = async () => {
try {
await exportPage({ pageId: pageId, format });
onClose();
} catch (err) {
notifications.show({
message: "Export failed:" + err.response?.data.message,
color: "red",
});
console.error("export error", err);
}
};
const handleChange = (format: ExportFormat) => {
setFormat(format);
};
return (
<>
<Modal
opened={open}
onClose={onClose}
size="350"
centered
withCloseButton={false}
>
<Group justify="space-between" wrap="nowrap">
<div>
<Text size="md">Export format</Text>
</div>
<ExportFormatSelection onChange={handleChange} />
</Group>
<Group justify="flex-start" mt="md">
<Button onClick={onClose} variant="default">
Cancel
</Button>
<Button onClick={handleExport}>Export</Button>
</Group>
</Modal>
</>
);
}
interface ExportFormatSelection {
onChange: (value: string) => void;
}
function ExportFormatSelection({ onChange }: ExportFormatSelection) {
return (
<Select
data={[
{ value: "markdown", label: "Markdown" },
{ value: "html", label: "HTML" },
]}
defaultValue={ExportFormat.Markdown}
onChange={onChange}
styles={{ wrapper: { maxWidth: 120 } }}
comboboxProps={{ width: "120" }}
allowDeselect={false}
withCheckIcon={false}
aria-label="Select export format"
/>
);
}
@@ -1,11 +1,13 @@
import api from "@/lib/api-client"; import api from "@/lib/api-client";
import { import {
IExportPageParams,
IMovePage, IMovePage,
IPage, IPage,
IPageInput, IPageInput,
SidebarPagesParams, SidebarPagesParams,
} from "@/features/page/types/page.types"; } from "@/features/page/types/page.types";
import { IAttachment, IPagination } from "@/lib/types.ts"; import { IAttachment, IPagination } from "@/lib/types.ts";
import { saveAs } from "file-saver";
export async function createPage(data: Partial<IPage>): Promise<IPage> { export async function createPage(data: Partial<IPage>): Promise<IPage> {
const req = await api.post<IPage>("/pages/create", data); const req = await api.post<IPage>("/pages/create", data);
@@ -53,18 +55,28 @@ export async function getRecentChanges(
return req.data; return req.data;
} }
export async function exportPage(data: IExportPageParams): Promise<void> {
const req = await api.post("/pages/export", data, {
responseType: "blob",
});
const fileName = req?.headers["content-disposition"]
.split("filename=")[1]
.replace(/"/g, "");
saveAs(req.data, fileName);
}
export async function uploadFile(file: File, pageId: string) { export async function uploadFile(file: File, pageId: string) {
const formData = new FormData(); const formData = new FormData();
formData.append("pageId", pageId); formData.append("pageId", pageId);
formData.append("file", file); formData.append("file", file);
// should be file endpoint
const req = await api.post<IAttachment>("/files/upload", formData, { const req = await api.post<IAttachment>("/files/upload", formData, {
headers: { headers: {
"Content-Type": "multipart/form-data", "Content-Type": "multipart/form-data",
}, },
}); });
// console.log("req", req);
return req; return req;
} }
@@ -88,6 +88,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
if (pagesData?.pages && !hasNextPage) { if (pagesData?.pages && !hasNextPage) {
const allItems = pagesData.pages.flatMap((page) => page.items); const allItems = pagesData.pages.flatMap((page) => page.items);
const treeData = buildTree(allItems); const treeData = buildTree(allItems);
if (data.length < 1 || data?.[0].spaceId !== spaceId) { if (data.length < 1 || data?.[0].spaceId !== spaceId) {
//Thoughts //Thoughts
// don't reset if there is data in state // don't reset if there is data in state
@@ -106,7 +107,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
const fetchData = async () => { const fetchData = async () => {
if (isDataLoaded.current && currentPage) { if (isDataLoaded.current && currentPage) {
// check if pageId node is present in the tree // check if pageId node is present in the tree
const node = dfs(treeApiRef.current.root, currentPage.id); const node = dfs(treeApiRef.current?.root, currentPage.id);
if (node) { if (node) {
// if node is found, no need to traverse its ancestors // if node is found, no need to traverse its ancestors
return; return;
@@ -52,6 +52,8 @@ export function useTreeMutation<T>(spaceId: string) {
slugId: createdPage.slugId, slugId: createdPage.slugId,
name: "", name: "",
position: createdPage.position, position: createdPage.position,
spaceId: createdPage.spaceId,
parentPageId: createdPage.parentPageId,
children: [], children: [],
} as any; } as any;
@@ -44,3 +44,13 @@ export interface IPageInput {
coverPhoto: string; coverPhoto: string;
position: string; position: string;
} }
export interface IExportPageParams {
pageId: string;
format: ExportFormat;
}
export enum ExportFormat {
HTML = "html",
Markdown = "markdown",
}
@@ -1,17 +1,10 @@
import { Group, Center, Text } from "@mantine/core"; import { Group, Center, Text } from "@mantine/core";
import { Spotlight } from "@mantine/spotlight"; import { Spotlight } from "@mantine/spotlight";
import { import { IconFileDescription, IconSearch } from "@tabler/icons-react";
IconFileDescription,
IconHome,
IconSearch,
IconSettings,
} from "@tabler/icons-react";
import React, { useState } from "react"; import React, { useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
import { usePageSearchQuery } from "@/features/search/queries/search-query"; import { usePageSearchQuery } from "@/features/search/queries/search-query";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { useRecentChangesQuery } from "@/features/page/queries/page-query.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts";
interface SearchSpotlightProps { interface SearchSpotlightProps {
@@ -95,9 +95,11 @@ function ChangePasswordForm({ onClose }: ChangePasswordFormProps) {
{...form.getInputProps("newPassword")} {...form.getInputProps("newPassword")}
/> />
<Button type="submit" disabled={isLoading} loading={isLoading}> <Group justify="flex-end" mt="md">
Change password <Button type="submit" disabled={isLoading} loading={isLoading}>
</Button> Change password
</Button>
</Group>
</form> </form>
); );
} }
+7 -2
View File
@@ -1,10 +1,10 @@
import axios, { AxiosInstance } from "axios"; import axios, { AxiosInstance } from "axios";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import Routes from "@/lib/app-route.ts"; import Routes from "@/lib/app-route.ts";
import { getBackendUrl } from "@/lib/config.ts";
const api: AxiosInstance = axios.create({ const api: AxiosInstance = axios.create({
baseURL: getBackendUrl(), baseURL: "/api",
withCredentials: true,
}); });
api.interceptors.request.use( api.interceptors.request.use(
@@ -31,6 +31,11 @@ api.interceptors.request.use(
api.interceptors.response.use( api.interceptors.response.use(
(response) => { (response) => {
// we need the response headers
if (response.request.responseURL.includes("/api/pages/export")) {
return response;
}
return response.data; return response.data;
}, },
(error) => { (error) => {
+4
View File
@@ -22,6 +22,10 @@ export interface IRoleData {
description: string; description: string;
} }
export interface ApiResponse<T> {
data: T;
}
export type IPaginationMeta = { export type IPaginationMeta = {
limit: number; limit: number;
page: number; page: number;
+8
View File
@@ -19,5 +19,13 @@ export default defineConfig(({ mode }) => {
"@": "/src", "@": "/src",
}, },
}, },
server: {
proxy: {
"/api": {
target: APP_URL,
changeOrigin: true,
},
},
},
}; };
}); });
+3 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "server", "name": "server",
"version": "0.2.2", "version": "0.2.5",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@@ -31,6 +31,7 @@
"@aws-sdk/client-s3": "^3.600.0", "@aws-sdk/client-s3": "^3.600.0",
"@aws-sdk/s3-request-presigner": "^3.600.0", "@aws-sdk/s3-request-presigner": "^3.600.0",
"@casl/ability": "^6.7.1", "@casl/ability": "^6.7.1",
"@fastify/cookie": "^9.3.1",
"@fastify/multipart": "^8.3.0", "@fastify/multipart": "^8.3.0",
"@fastify/static": "^7.0.4", "@fastify/static": "^7.0.4",
"@nestjs/bullmq": "^10.1.1", "@nestjs/bullmq": "^10.1.1",
@@ -43,6 +44,7 @@
"@nestjs/passport": "^10.0.3", "@nestjs/passport": "^10.0.3",
"@nestjs/platform-fastify": "^10.3.9", "@nestjs/platform-fastify": "^10.3.9",
"@nestjs/platform-socket.io": "^10.3.9", "@nestjs/platform-socket.io": "^10.3.9",
"@nestjs/terminus": "^10.2.3",
"@nestjs/websockets": "^10.3.9", "@nestjs/websockets": "^10.3.9",
"@react-email/components": "0.0.19", "@react-email/components": "0.0.19",
"@react-email/render": "^0.0.15", "@react-email/render": "^0.0.15",
+4
View File
@@ -11,6 +11,8 @@ import { MailModule } from './integrations/mail/mail.module';
import { QueueModule } from './integrations/queue/queue.module'; import { QueueModule } from './integrations/queue/queue.module';
import { StaticModule } from './integrations/static/static.module'; import { StaticModule } from './integrations/static/static.module';
import { EventEmitterModule } from '@nestjs/event-emitter'; import { EventEmitterModule } from '@nestjs/event-emitter';
import { HealthModule } from './integrations/health/health.module';
import { ExportModule } from './integrations/export/export.module';
@Module({ @Module({
imports: [ imports: [
@@ -21,6 +23,8 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
WsModule, WsModule,
QueueModule, QueueModule,
StaticModule, StaticModule,
HealthModule,
ExportModule,
StorageModule.forRootAsync({ StorageModule.forRootAsync({
imports: [EnvironmentModule], imports: [EnvironmentModule],
}), }),
@@ -60,7 +60,7 @@ export const tiptapExtensions = [
Callout, Callout,
] as any; ] as any;
export function jsonToHtml(tiptapJson: JSONContent) { export function jsonToHtml(tiptapJson: any) {
return generateHTML(tiptapJson, tiptapExtensions); return generateHTML(tiptapJson, tiptapExtensions);
} }
@@ -16,7 +16,15 @@ export class TransformHttpResponseInterceptor<T>
intercept( intercept(
context: ExecutionContext, context: ExecutionContext,
next: CallHandler<T>, next: CallHandler<T>,
): Observable<Response<T>> { ): Observable<Response<T> | any> {
const request = context.switchToHttp().getRequest();
const path = request.url;
// Skip interceptor for the /api/health path
if (path === '/api/health') {
return next.handle();
}
return next.handle().pipe( return next.handle().pipe(
map((data) => { map((data) => {
const status = context.switchToHttp().getResponse().statusCode; const status = context.switchToHttp().getResponse().statusCode;
@@ -45,7 +45,6 @@ import {
import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory'; import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory';
import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo'; import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
import { Public } from '../../common/decorators/public.decorator';
import { validate as isValidUUID } from 'uuid'; import { validate as isValidUUID } from 'uuid';
@Controller() @Controller()
@@ -129,12 +128,11 @@ export class AttachmentController {
} }
} }
@Public()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Get('/files/:fileId/:fileName') @Get('/files/:fileId/:fileName')
async getFile( async getFile(
@Res() res: FastifyReply, @Res() res: FastifyReply,
//@AuthUser() user: User, @AuthUser() user: User,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
@Param('fileId') fileId: string, @Param('fileId') fileId: string,
@Param('fileName') fileName?: string, @Param('fileName') fileName?: string,
@@ -144,18 +142,29 @@ export class AttachmentController {
} }
const attachment = await this.attachmentRepo.findById(fileId); const attachment = await this.attachmentRepo.findById(fileId);
if (attachment.workspaceId !== workspace.id) { if (
!attachment ||
attachment.workspaceId !== workspace.id ||
!attachment.pageId ||
!attachment.spaceId
) {
throw new NotFoundException(); throw new NotFoundException();
} }
if (!attachment || !attachment.pageId) { const spaceAbility = await this.spaceAbility.createForUser(
throw new NotFoundException('File record not found'); user,
attachment.spaceId,
);
if (spaceAbility.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
} }
try { try {
const fileStream = await this.storageService.read(attachment.filePath); const fileStream = await this.storageService.read(attachment.filePath);
res.headers({ res.headers({
'Content-Type': getMimeType(attachment.filePath), 'Content-Type': getMimeType(attachment.filePath),
'Cache-Control': 'public, max-age=3600',
}); });
return res.send(fileStream); return res.send(fileStream);
} catch (err) { } catch (err) {
@@ -268,6 +277,7 @@ export class AttachmentController {
const fileStream = await this.storageService.read(filePath); const fileStream = await this.storageService.read(filePath);
res.headers({ res.headers({
'Content-Type': getMimeType(filePath), 'Content-Type': getMimeType(filePath),
'Cache-Control': 'public, max-age=86400',
}); });
return res.send(fileStream); return res.send(fileStream);
} catch (err) { } catch (err) {
@@ -4,11 +4,12 @@ import {
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt'; import { Strategy } from 'passport-jwt';
import { EnvironmentService } from '../../../integrations/environment/environment.service'; import { EnvironmentService } from '../../../integrations/environment/environment.service';
import { JwtPayload, JwtType } from '../dto/jwt-payload'; import { JwtPayload, JwtType } from '../dto/jwt-payload';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { UserRepo } from '@docmost/db/repos/user/user.repo'; import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { FastifyRequest } from 'fastify';
@Injectable() @Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
@@ -18,7 +19,15 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
private readonly environmentService: EnvironmentService, private readonly environmentService: EnvironmentService,
) { ) {
super({ super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), jwtFromRequest: (req: FastifyRequest) => {
let accessToken = null;
try {
accessToken = JSON.parse(req.cookies?.authTokens)?.accessToken;
} catch {}
return accessToken || this.extractTokenFromHeader(req);
},
ignoreExpiration: false, ignoreExpiration: false,
secretOrKey: environmentService.getAppSecret(), secretOrKey: environmentService.getAppSecret(),
passReqToCallback: true, passReqToCallback: true,
@@ -50,4 +59,9 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
return { user, workspace }; return { user, workspace };
} }
private extractTokenFromHeader(request: FastifyRequest): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
} }
+4 -1
View File
@@ -34,7 +34,10 @@ export class CoreModule implements NestModule {
configure(consumer: MiddlewareConsumer) { configure(consumer: MiddlewareConsumer) {
consumer consumer
.apply(DomainMiddleware) .apply(DomainMiddleware)
.exclude({ path: 'auth/setup', method: RequestMethod.POST }) .exclude(
{ path: 'auth/setup', method: RequestMethod.POST },
{ path: 'health', method: RequestMethod.GET },
)
.forRoutes('*'); .forRoutes('*');
} }
} }
+3 -1
View File
@@ -36,6 +36,8 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
dialect: new PostgresDialect({ dialect: new PostgresDialect({
pool: new Pool({ pool: new Pool({
connectionString: environmentService.getDatabaseURL(), connectionString: environmentService.getDatabaseURL(),
}).on('error', (err) => {
console.error('Database error:', err.message);
}), }),
}), }),
plugins: [new CamelCasePlugin()], plugins: [new CamelCasePlugin()],
@@ -102,7 +104,7 @@ export class DatabaseModule implements OnModuleDestroy, OnApplicationBootstrap {
} }
async establishConnection() { async establishConnection() {
const retryAttempts = 10; const retryAttempts = 15;
const retryDelay = 3000; const retryDelay = 3000;
this.logger.log('Establishing database connection'); this.logger.log('Establishing database connection');
@@ -0,0 +1,7 @@
import { type Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema.dropIndex('pages_slug_id_idx').ifExists().execute();
}
export async function down(db: Kysely<any>): Promise<void> {}
@@ -6,15 +6,11 @@ import { hashPassword } from '../../../common/helpers';
import { dbOrTx } from '@docmost/db/utils'; import { dbOrTx } from '@docmost/db/utils';
import { import {
InsertableUser, InsertableUser,
Space,
UpdatableUser, UpdatableUser,
User, User,
} from '@docmost/db/types/entity.types'; } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '../../pagination/pagination-options'; import { PaginationOptions } from '../../pagination/pagination-options';
import { import { executeWithPagination } from '@docmost/db/pagination/pagination';
executeWithPagination,
PaginationResult,
} from '@docmost/db/pagination/pagination';
import { sql } from 'kysely'; import { sql } from 'kysely';
@Injectable() @Injectable()
@@ -66,7 +62,7 @@ export class UserRepo {
.selectFrom('users') .selectFrom('users')
.select(this.baseFields) .select(this.baseFields)
.$if(includePassword, (qb) => qb.select('password')) .$if(includePassword, (qb) => qb.select('password'))
.where('email', '=', email) .where(sql`LOWER(email)`, '=', sql`LOWER(${email})`)
.where('workspaceId', '=', workspaceId) .where('workspaceId', '=', workspaceId)
.executeTakeFirst(); .executeTakeFirst();
} }
@@ -0,0 +1,26 @@
import {
IsBoolean,
IsIn,
IsNotEmpty,
IsOptional,
IsString,
} from 'class-validator';
export enum ExportFormat {
HTML = 'html',
Markdown = 'markdown',
}
export class ExportPageDto {
@IsString()
@IsNotEmpty()
pageId: string;
@IsString()
@IsIn(['html', 'markdown'])
format: ExportFormat;
@IsOptional()
@IsBoolean()
includeFiles?: boolean;
}
@@ -0,0 +1,69 @@
import {
Body,
Controller,
ForbiddenException,
HttpCode,
HttpStatus,
NotFoundException,
Post,
Res,
UseGuards,
} from '@nestjs/common';
import { ExportService } from './export.service';
import { ExportPageDto } from './dto/export-dto';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { User } from '@docmost/db/types/entity.types';
import SpaceAbilityFactory from '../../core/casl/abilities/space-ability.factory';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import {
SpaceCaslAction,
SpaceCaslSubject,
} from '../../core/casl/interfaces/space-ability.type';
import { FastifyReply } from 'fastify';
import { sanitize } from 'sanitize-filename-ts';
import { getExportExtension } from './utils';
import { getMimeType } from '../../common/helpers';
@Controller()
export class ImportController {
constructor(
private readonly importService: ExportService,
private readonly pageRepo: PageRepo,
private readonly spaceAbility: SpaceAbilityFactory,
) {}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('pages/export')
async exportPage(
@Body() dto: ExportPageDto,
@AuthUser() user: User,
@Res() res: FastifyReply,
) {
const page = await this.pageRepo.findById(dto.pageId, {
includeContent: true,
});
if (!page) {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
const rawContent = await this.importService.exportPage(dto.format, page);
const fileExt = getExportExtension(dto.format);
const fileName = sanitize(page.title || 'Untitled') + fileExt;
res.headers({
'Content-Type': getMimeType(fileExt),
'Content-Disposition': 'attachment; filename="' + fileName + '"',
});
res.send(rawContent);
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ExportService } from './export.service';
import { ImportController } from './export.controller';
@Module({
providers: [ExportService],
controllers: [ImportController],
})
export class ExportModule {}
@@ -0,0 +1,34 @@
import { Injectable } from '@nestjs/common';
import { jsonToHtml } from '../../collaboration/collaboration.util';
import { turndown } from './turndown-utils';
import { ExportFormat } from './dto/export-dto';
import { Page } from '@docmost/db/types/entity.types';
@Injectable()
export class ExportService {
async exportPage(format: string, page: Page) {
const titleNode = {
type: 'heading',
attrs: { level: 1 },
content: [{ type: 'text', text: page.title }],
};
let prosemirrorJson: any = page.content || { type: 'doc', content: [] };
if (page.title) {
prosemirrorJson.content.unshift(titleNode);
}
const pageHtml = jsonToHtml(prosemirrorJson);
if (format === ExportFormat.HTML) {
return `<!DOCTYPE html><html><head><title>${page.title}</title></head><body>${pageHtml}</body></html>`;
}
if (format === ExportFormat.Markdown) {
return turndown(pageHtml);
}
return;
}
}
@@ -0,0 +1,100 @@
import * as TurndownService from '@joplin/turndown';
import * as TurndownPluginGfm from '@joplin/turndown-plugin-gfm';
export function turndown(html: string): string {
const turndownService = new TurndownService({
headingStyle: 'atx',
codeBlockStyle: 'fenced',
hr: '---',
bulletListMarker: '-',
});
const tables = TurndownPluginGfm.tables;
const strikethrough = TurndownPluginGfm.strikethrough;
const highlightedCodeBlock = TurndownPluginGfm.highlightedCodeBlock;
turndownService.use([
tables,
strikethrough,
highlightedCodeBlock,
taskList,
callout,
toggleListTitle,
toggleListBody,
listParagraph,
]);
return turndownService.turndown(html).replaceAll('<br>', ' ');
}
function listParagraph(turndownService: TurndownService) {
turndownService.addRule('paragraph', {
filter: ['p'],
replacement: (content: any, node: HTMLInputElement) => {
if (node.parentElement?.nodeName === 'LI') {
return content;
}
return `\n\n${content}\n\n`;
},
});
}
function callout(turndownService: TurndownService) {
turndownService.addRule('callout', {
filter: function (node: HTMLInputElement) {
return (
node.nodeName === 'DIV' && node.getAttribute('data-type') === 'callout'
);
},
replacement: function (content: any, node: HTMLInputElement) {
const calloutType = node.getAttribute('data-callout-type');
return `\n\n:::${calloutType}\n${content.trim()}\n:::\n\n`;
},
});
}
function taskList(turndownService: TurndownService) {
turndownService.addRule('taskListItem', {
filter: function (node: HTMLInputElement) {
return (
node.getAttribute('data-type') === 'taskItem' &&
node.parentNode.nodeName === 'UL'
);
},
replacement: function (content: any, node: HTMLInputElement) {
const checkbox = node.querySelector(
'input[type="checkbox"]',
) as HTMLInputElement;
const isChecked = checkbox.checked;
return `- ${isChecked ? '[x]' : '[ ]'} ${content.trim()} \n`;
},
});
}
function toggleListTitle(turndownService: TurndownService) {
turndownService.addRule('toggleListTitle', {
filter: function (node: HTMLInputElement) {
return (
node.nodeName === 'SUMMARY' && node.parentNode.nodeName === 'DETAILS'
);
},
replacement: function (content: any, node: HTMLInputElement) {
return '- ' + content;
},
});
}
function toggleListBody(turndownService: TurndownService) {
turndownService.addRule('toggleListContent', {
filter: function (node: HTMLInputElement) {
return (
node.getAttribute('data-type') === 'detailsContent' &&
node.parentNode.nodeName === 'DETAILS'
);
},
replacement: function (content: any, node: HTMLInputElement) {
return ` ${content.replace(/\n/g, '\n ')} `;
},
});
}
@@ -0,0 +1,12 @@
import { ExportFormat } from './dto/export-dto';
export function getExportExtension(format: string) {
if (format === ExportFormat.HTML) {
return '.html';
}
if (format === ExportFormat.Markdown) {
return '.md';
}
return;
}
@@ -0,0 +1,22 @@
import { Controller, Get } from '@nestjs/common';
import { HealthCheck, HealthCheckService } from '@nestjs/terminus';
import { PostgresHealthIndicator } from './postgres.health';
import { RedisHealthIndicator } from './redis.health';
@Controller('health')
export class HealthController {
constructor(
private health: HealthCheckService,
private postgres: PostgresHealthIndicator,
private redis: RedisHealthIndicator,
) {}
@Get()
@HealthCheck()
async check() {
return this.health.check([
() => this.postgres.pingCheck('database'),
() => this.redis.pingCheck('redis'),
]);
}
}
@@ -0,0 +1,13 @@
import { Global, Module } from '@nestjs/common';
import { HealthController } from './health.controller';
import { TerminusModule } from '@nestjs/terminus';
import { PostgresHealthIndicator } from './postgres.health';
import { RedisHealthIndicator } from './redis.health';
@Global()
@Module({
controllers: [HealthController],
providers: [PostgresHealthIndicator, RedisHealthIndicator],
imports: [TerminusModule],
})
export class HealthModule {}
@@ -0,0 +1,31 @@
import { InjectKysely } from 'nestjs-kysely';
import {
HealthCheckError,
HealthIndicator,
HealthIndicatorResult,
} from '@nestjs/terminus';
import { Injectable, Logger } from '@nestjs/common';
import { sql } from 'kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
@Injectable()
export class PostgresHealthIndicator extends HealthIndicator {
private readonly logger = new Logger(PostgresHealthIndicator.name);
constructor(@InjectKysely() private readonly db: KyselyDB) {
super();
}
async pingCheck(key: string): Promise<HealthIndicatorResult> {
try {
await sql`SELECT 1=1`.execute(this.db);
return this.getStatus(key, true);
} catch (e) {
this.logger.error(JSON.stringify(e));
throw new HealthCheckError(
`${key} is not available`,
this.getStatus(key, false),
);
}
}
}
@@ -0,0 +1,34 @@
import {
HealthCheckError,
HealthIndicator,
HealthIndicatorResult,
} from '@nestjs/terminus';
import { Injectable, Logger } from '@nestjs/common';
import { EnvironmentService } from '../environment/environment.service';
import { Redis } from 'ioredis';
@Injectable()
export class RedisHealthIndicator extends HealthIndicator {
private readonly logger = new Logger(RedisHealthIndicator.name);
constructor(private environmentService: EnvironmentService) {
super();
}
async pingCheck(key: string): Promise<HealthIndicatorResult> {
try {
const redis = new Redis(this.environmentService.getRedisUrl(), {
maxRetriesPerRequest: 15,
});
await redis.ping();
return this.getStatus(key, true);
} catch (e) {
this.logger.error(e);
throw new HealthCheckError(
`${key} is not available`,
this.getStatus(key, false),
);
}
}
}
@@ -34,9 +34,6 @@ export class S3Driver implements StorageDriver {
}); });
await this.s3Client.send(command); await this.s3Client.send(command);
// we can get the path from location
console.log(`File uploaded successfully: ${filePath}`);
} catch (err) { } catch (err) {
throw new Error(`Failed to upload file: ${(err as Error).message}`); throw new Error(`Failed to upload file: ${(err as Error).message}`);
} }
@@ -41,20 +41,34 @@ export const storageDriverConfigProvider = {
}; };
case StorageOption.S3: case StorageOption.S3:
return { const s3Config = {
driver, driver,
config: { config: {
region: environmentService.getAwsS3Region(), region: environmentService.getAwsS3Region(),
endpoint: environmentService.getAwsS3Endpoint(), endpoint: environmentService.getAwsS3Endpoint(),
bucket: environmentService.getAwsS3Bucket(), bucket: environmentService.getAwsS3Bucket(),
baseUrl: environmentService.getAwsS3Url(), baseUrl: environmentService.getAwsS3Url(),
credentials: { credentials: undefined,
accessKeyId: environmentService.getAwsS3AccessKeyId(),
secretAccessKey: environmentService.getAwsS3SecretAccessKey(),
},
}, },
}; };
/**
* This makes use of AWS_S3_ACCESS_KEY_ID and AWS_S3_SECRET_ACCESS_KEY if present,
* If not present, it makes it lenient for the AWS SDK to use
* AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY if they are present in the environment
*/
if (
environmentService.getAwsS3AccessKeyId() ||
environmentService.getAwsS3SecretAccessKey()
) {
s3Config.config.credentials = {
accessKeyId: environmentService.getAwsS3AccessKeyId(),
secretAccessKey: environmentService.getAwsS3SecretAccessKey(),
};
}
return s3Config;
default: default:
throw new Error(`Unknown storage driver: ${driver}`); throw new Error(`Unknown storage driver: ${driver}`);
} }
@@ -1,15 +1,17 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { STORAGE_DRIVER_TOKEN } from './constants/storage.constants'; import { STORAGE_DRIVER_TOKEN } from './constants/storage.constants';
import { StorageDriver } from './interfaces'; import { StorageDriver } from './interfaces';
@Injectable() @Injectable()
export class StorageService { export class StorageService {
private readonly logger = new Logger(StorageService.name);
constructor( constructor(
@Inject(STORAGE_DRIVER_TOKEN) private storageDriver: StorageDriver, @Inject(STORAGE_DRIVER_TOKEN) private storageDriver: StorageDriver,
) {} ) {}
async upload(filePath: string, fileContent: Buffer | any) { async upload(filePath: string, fileContent: Buffer | any) {
await this.storageDriver.upload(filePath, fileContent); await this.storageDriver.upload(filePath, fileContent);
this.logger.debug(`File uploaded successfully. Path: ${filePath}`);
} }
async read(filePath: string): Promise<Buffer> { async read(filePath: string): Promise<Buffer> {
+5 -1
View File
@@ -9,6 +9,7 @@ import { TransformHttpResponseInterceptor } from './common/interceptors/http-res
import fastifyMultipart from '@fastify/multipart'; import fastifyMultipart from '@fastify/multipart';
import { WsRedisIoAdapter } from './ws/adapter/ws-redis.adapter'; import { WsRedisIoAdapter } from './ws/adapter/ws-redis.adapter';
import { InternalLogFilter } from './common/logger/internal-log-filter'; import { InternalLogFilter } from './common/logger/internal-log-filter';
import fastifyCookie from '@fastify/cookie';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>( const app = await NestFactory.create<NestFastifyApplication>(
@@ -31,6 +32,7 @@ async function bootstrap() {
app.useWebSocketAdapter(redisIoAdapter); app.useWebSocketAdapter(redisIoAdapter);
await app.register(fastifyMultipart as any); await app.register(fastifyMultipart as any);
await app.register(fastifyCookie as any);
app app
.getHttpAdapter() .getHttpAdapter()
@@ -38,7 +40,8 @@ async function bootstrap() {
.addHook('preHandler', function (req, reply, done) { .addHook('preHandler', function (req, reply, done) {
if ( if (
req.originalUrl.startsWith('/api') && req.originalUrl.startsWith('/api') &&
!req.originalUrl.startsWith('/api/auth/setup') !req.originalUrl.startsWith('/api/auth/setup') &&
!req.originalUrl.startsWith('/api/health')
) { ) {
if (!req.raw?.['workspaceId']) { if (!req.raw?.['workspaceId']) {
throw new NotFoundException('Workspace not found'); throw new NotFoundException('Workspace not found');
@@ -56,6 +59,7 @@ async function bootstrap() {
transform: true, transform: true,
}), }),
); );
app.enableCors(); app.enableCors();
app.useGlobalInterceptors(new TransformHttpResponseInterceptor()); app.useGlobalInterceptors(new TransformHttpResponseInterceptor());
+10 -6
View File
@@ -1,7 +1,7 @@
{ {
"name": "docmost", "name": "docmost",
"homepage": "https://docmost.com", "homepage": "https://docmost.com",
"version": "0.2.2", "version": "0.2.5",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "nx run-many -t build", "build": "nx run-many -t build",
@@ -12,14 +12,17 @@
"client:dev": "nx run client:dev", "client:dev": "nx run client:dev",
"server:dev": "nx run server:start:dev", "server:dev": "nx run server:start:dev",
"server:start": "nx run server:start:prod", "server:start": "nx run server:start:prod",
"email:dev": "nx run @docmost/transactional:dev" "email:dev": "nx run @docmost/transactional:dev",
"dev": "pnpm concurrently -n \"frontend,backend\" -c \"cyan,green\" \"pnpm run client:dev\" \"pnpm run server:dev\""
}, },
"dependencies": { "dependencies": {
"@docmost/editor-ext": "workspace:*", "@docmost/editor-ext": "workspace:*",
"@hocuspocus/extension-redis": "^2.13.2", "@hocuspocus/extension-redis": "^2.13.5",
"@hocuspocus/provider": "^2.13.2", "@hocuspocus/provider": "^2.13.5",
"@hocuspocus/server": "^2.13.2", "@hocuspocus/server": "^2.13.5",
"@hocuspocus/transformer": "^2.13.2", "@hocuspocus/transformer": "^2.13.5",
"@joplin/turndown": "^4.0.74",
"@joplin/turndown-plugin-gfm": "^1.0.56",
"@sindresorhus/slugify": "^2.2.1", "@sindresorhus/slugify": "^2.2.1",
"@tiptap/core": "^2.4.0", "@tiptap/core": "^2.4.0",
"@tiptap/extension-code-block": "^2.4.0", "@tiptap/extension-code-block": "^2.4.0",
@@ -65,6 +68,7 @@
"devDependencies": { "devDependencies": {
"@nx/js": "19.3.2", "@nx/js": "19.3.2",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"concurrently": "^8.2.2",
"nx": "19.3.2", "nx": "19.3.2",
"tsx": "^4.15.7" "tsx": "^4.15.7"
}, },
+2 -2
View File
@@ -57,9 +57,9 @@ export const TiptapImage = Image.extend<ImageOptions>({
}, },
width: { width: {
default: "100%", default: "100%",
parseHTML: (element) => element.getAttribute("data-width"), parseHTML: (element) => element.getAttribute("width"),
renderHTML: (attributes: ImageAttributes) => ({ renderHTML: (attributes: ImageAttributes) => ({
"data-width": attributes.width, width: attributes.width,
}), }),
}, },
align: { align: {
@@ -56,7 +56,7 @@ export const MathBlock = Node.create({
return [ return [
"div", "div",
{}, {},
["div", { "data-katex": true }, `$${HTMLAttributes.text}$`], ["div", { "data-katex": true }, `$$${HTMLAttributes.text}$$`],
]; ];
}, },
@@ -64,10 +64,6 @@ export const MathBlock = Node.create({
return ReactNodeViewRenderer(this.options.view); return ReactNodeViewRenderer(this.options.view);
}, },
renderText({ node }) {
return node.attrs.text;
},
addCommands() { addCommands() {
return { return {
setMathBlock: setMathBlock:
@@ -54,15 +54,7 @@ export const MathInline = Node.create<MathInlineOption>({
}, },
renderHTML({ HTMLAttributes }) { renderHTML({ HTMLAttributes }) {
return [ return ["span", { "data-katex": true }, `$${HTMLAttributes.text}$` || {}];
"div",
{},
["span", { "data-katex": true }, `$${HTMLAttributes.text}$`],
];
},
renderText({ node }) {
return node.attrs.text;
}, },
addNodeView() { addNodeView() {
+2 -2
View File
@@ -62,9 +62,9 @@ export const TiptapVideo = Node.create<VideoOptions>({
}, },
width: { width: {
default: "100%", default: "100%",
parseHTML: (element) => element.getAttribute("data-width"), parseHTML: (element) => element.getAttribute("width"),
renderHTML: (attributes: VideoAttributes) => ({ renderHTML: (attributes: VideoAttributes) => ({
"data-width": attributes.width, width: attributes.width,
}), }),
}, },
size: { size: {
+462 -27
View File
File diff suppressed because it is too large Load Diff