mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
Compare commits
14 Commits
pdf-export
...
v0.80.2
| Author | SHA1 | Date | |
|---|---|---|---|
| b99e3d67f5 | |||
| 184811911f | |||
| 980521f957 | |||
| fe44dc92a9 | |||
| fad410ef23 | |||
| 15b8908b1a | |||
| 8e15b22d8c | |||
| ec83fc82d5 | |||
| a573acedd0 | |||
| dba8e315ab | |||
| 81ae7a17a6 | |||
| 271f855761 | |||
| 3e6d915227 | |||
| a6a7e4370a |
@@ -43,6 +43,9 @@ POSTMARK_TOKEN=
|
||||
# for custom drawio server
|
||||
DRAWIO_URL=
|
||||
|
||||
# Gotenberg URL for server-side PDF export
|
||||
GOTENBERG_URL=
|
||||
|
||||
DISABLE_TELEMETRY=false
|
||||
|
||||
# Enable debug logging in production (default: false)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.71.1",
|
||||
"version": "0.80.2",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
@@ -31,8 +31,8 @@
|
||||
"emoji-mart": "^5.6.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"highlightjs-sap-abap": "^0.3.0",
|
||||
"i18next": "^25.10.1",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"i18next": "25.10.1",
|
||||
"i18next-http-backend": "3.0.6",
|
||||
"jotai": "^2.18.1",
|
||||
"jotai-optics": "^0.4.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
@@ -42,7 +42,7 @@
|
||||
"mantine-form-zod-resolver": "^1.3.0",
|
||||
"mermaid": "^11.13.0",
|
||||
"mitt": "^3.0.1",
|
||||
"posthog-js": "1.363.1",
|
||||
"posthog-js": "1.372.2",
|
||||
"react": "^18.3.1",
|
||||
"react-arborist": "3.4.0",
|
||||
"react-clear-modal": "^2.0.18",
|
||||
@@ -50,7 +50,7 @@
|
||||
"react-drawio": "^1.0.7",
|
||||
"react-error-boundary": "^6.1.1",
|
||||
"react-helmet-async": "^3.0.0",
|
||||
"react-i18next": "^16.5.8",
|
||||
"react-i18next": "16.5.8",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"semver": "^7.7.4",
|
||||
"socket.io-client": "^4.8.3",
|
||||
@@ -74,7 +74,7 @@
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^15.13.0",
|
||||
"optics-ts": "^2.4.1",
|
||||
"postcss": "^8.5.8",
|
||||
"postcss": "^8.5.12",
|
||||
"postcss-preset-mantine": "^1.18.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"prettier": "^3.8.1",
|
||||
|
||||
@@ -222,6 +222,8 @@
|
||||
"Edit comment": "Kommentar bearbeiten",
|
||||
"Delete comment": "Kommentar löschen",
|
||||
"Are you sure you want to delete this comment?": "Sind Sie sicher, dass Sie diesen Kommentar löschen möchten?",
|
||||
"Delete chat": "Chat löschen",
|
||||
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "Sind Sie sicher, dass Sie '{{title}}' löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"Comment created successfully": "Kommentar erfolgreich erstellt",
|
||||
"Error creating comment": "Fehler beim Erstellen des Kommentars",
|
||||
"Comment updated successfully": "Kommentar erfolgreich aktualisiert",
|
||||
|
||||
@@ -222,6 +222,8 @@
|
||||
"Edit comment": "Edit comment",
|
||||
"Delete comment": "Delete comment",
|
||||
"Are you sure you want to delete this comment?": "Are you sure you want to delete this comment?",
|
||||
"Delete chat": "Delete chat",
|
||||
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "Are you sure you want to delete '{{title}}'? This action cannot be undone.",
|
||||
"Comment created successfully": "Comment created successfully",
|
||||
"Error creating comment": "Error creating comment",
|
||||
"Comment updated successfully": "Comment updated successfully",
|
||||
|
||||
@@ -222,6 +222,8 @@
|
||||
"Edit comment": "Editar comentario",
|
||||
"Delete comment": "Eliminar comentario",
|
||||
"Are you sure you want to delete this comment?": "¿Está seguro de que desea eliminar este comentario?",
|
||||
"Delete chat": "Eliminar chat",
|
||||
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "¿Está seguro de que desea eliminar '{{title}}'? Esta acción no se puede deshacer.",
|
||||
"Comment created successfully": "Comentario creado con éxito",
|
||||
"Error creating comment": "Error al crear comentario",
|
||||
"Comment updated successfully": "Comentario actualizado con éxito",
|
||||
|
||||
@@ -222,6 +222,8 @@
|
||||
"Edit comment": "Modifier le commentaire",
|
||||
"Delete comment": "Supprimer le commentaire",
|
||||
"Are you sure you want to delete this comment?": "Êtes-vous sûr de vouloir supprimer ce commentaire ?",
|
||||
"Delete chat": "Supprimer la conversation",
|
||||
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "Êtes-vous sûr de vouloir supprimer '{{title}}' ? Cette action est irréversible.",
|
||||
"Comment created successfully": "Commentaire créé avec succès",
|
||||
"Error creating comment": "Erreur lors de la création du commentaire",
|
||||
"Comment updated successfully": "Commentaire mis à jour avec succès",
|
||||
|
||||
@@ -222,6 +222,8 @@
|
||||
"Edit comment": "Modifica commento",
|
||||
"Delete comment": "Elimina commento",
|
||||
"Are you sure you want to delete this comment?": "Sei sicuro di voler eliminare questo commento?",
|
||||
"Delete chat": "Elimina chat",
|
||||
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "Sei sicuro di voler eliminare '{{title}}'? Questa azione non può essere annullata.",
|
||||
"Comment created successfully": "Commento creato con successo",
|
||||
"Error creating comment": "Si è verificato un errore durante la creazione del commento",
|
||||
"Comment updated successfully": "Commento aggiornato con successo",
|
||||
|
||||
@@ -222,6 +222,8 @@
|
||||
"Edit comment": "コメントを編集する",
|
||||
"Delete comment": "コメントを削除する",
|
||||
"Are you sure you want to delete this comment?": "このコメントを削除してもよろしいですか?",
|
||||
"Delete chat": "チャットを削除",
|
||||
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "「{{title}}」を削除してもよろしいですか?この操作は元に戻せません。",
|
||||
"Comment created successfully": "コメントを作成しました",
|
||||
"Error creating comment": "コメントの作成に失敗しました",
|
||||
"Comment updated successfully": "コメントを更新しました",
|
||||
|
||||
@@ -222,6 +222,8 @@
|
||||
"Edit comment": "댓글 수정",
|
||||
"Delete comment": "댓글 삭제",
|
||||
"Are you sure you want to delete this comment?": "이 댓글을 삭제하시겠습니까?",
|
||||
"Delete chat": "채팅 삭제",
|
||||
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "'{{title}}'을(를) 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
|
||||
"Comment created successfully": "댓글 생성 완료",
|
||||
"Error creating comment": "댓글 생성 오류",
|
||||
"Comment updated successfully": "댓글 업데이트 완료",
|
||||
|
||||
@@ -222,6 +222,8 @@
|
||||
"Edit comment": "Bewerk reactie",
|
||||
"Delete comment": "Verwijder reactie",
|
||||
"Are you sure you want to delete this comment?": "Weet je zeker dat je deze reactie wilt verwijderen?",
|
||||
"Delete chat": "Chat verwijderen",
|
||||
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "Weet je zeker dat je '{{title}}' wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
|
||||
"Comment created successfully": "Reactie succesvol aangemaakt",
|
||||
"Error creating comment": "Fout bij het aanmaken van reactie",
|
||||
"Comment updated successfully": "Opmerking succesvol bijgewerkt",
|
||||
|
||||
@@ -222,6 +222,8 @@
|
||||
"Edit comment": "Editar comentário",
|
||||
"Delete comment": "Excluir comentário",
|
||||
"Are you sure you want to delete this comment?": "Você tem certeza de que deseja excluir este comentário?",
|
||||
"Delete chat": "Excluir chat",
|
||||
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "Tem certeza de que deseja excluir '{{title}}'? Esta ação não pode ser desfeita.",
|
||||
"Comment created successfully": "Comentário criado com sucesso",
|
||||
"Error creating comment": "Erro ao criar comentário",
|
||||
"Comment updated successfully": "Comentário atualizado com sucesso",
|
||||
|
||||
@@ -222,6 +222,8 @@
|
||||
"Edit comment": "Редактировать комментарий",
|
||||
"Delete comment": "Удалить комментарий",
|
||||
"Are you sure you want to delete this comment?": "Вы уверены, что хотите удалить этот комментарий?",
|
||||
"Delete chat": "Удалить чат",
|
||||
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "Вы уверены, что хотите удалить '{{title}}'? Это действие нельзя отменить.",
|
||||
"Comment created successfully": "Комментарий успешно создан",
|
||||
"Error creating comment": "Ошибка при создании комментария",
|
||||
"Comment updated successfully": "Комментарий успешно обновлён",
|
||||
|
||||
@@ -222,6 +222,8 @@
|
||||
"Edit comment": "Редагувати коментар",
|
||||
"Delete comment": "Видалити коментар",
|
||||
"Are you sure you want to delete this comment?": "Ви впевнені, що хочете видалити цей коментар?",
|
||||
"Delete chat": "Видалити чат",
|
||||
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "Ви впевнені, що хочете видалити '{{title}}'? Цю дію неможливо скасувати.",
|
||||
"Comment created successfully": "Коментар успішно створено",
|
||||
"Error creating comment": "Помилка при створенні коментаря",
|
||||
"Comment updated successfully": "Коментар успішно оновлено",
|
||||
|
||||
@@ -222,6 +222,8 @@
|
||||
"Edit comment": "编辑评论",
|
||||
"Delete comment": "删除评论",
|
||||
"Are you sure you want to delete this comment?": "你确定要删除这条评论吗?",
|
||||
"Delete chat": "删除聊天",
|
||||
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "您确定要删除「{{title}}」吗?此操作无法撤销。",
|
||||
"Comment created successfully": "成功创建评论",
|
||||
"Error creating comment": "创建评论时出错",
|
||||
"Comment updated successfully": "评论更新成功",
|
||||
|
||||
@@ -26,6 +26,7 @@ import Security from "@/ee/security/pages/security.tsx";
|
||||
import License from "@/ee/licence/pages/license.tsx";
|
||||
import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx";
|
||||
import SharedPage from "@/pages/share/shared-page.tsx";
|
||||
import PdfRenderPage from "@/ee/pdf-export/pdf-render-page.tsx";
|
||||
import Shares from "@/pages/settings/shares/shares.tsx";
|
||||
import ShareLayout from "@/features/share/components/share-layout.tsx";
|
||||
import ShareRedirect from "@/pages/share/share-redirect.tsx";
|
||||
@@ -81,6 +82,7 @@ export default function App() {
|
||||
<Route path={"/share/p/:pageSlug"} element={<SharedPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path={"/pdf-render/:pageId"} element={<PdfRenderPage />} />
|
||||
<Route path={"/share/:shareId"} element={<ShareRedirect />} />
|
||||
<Route path={"/p/:pageSlug"} element={<PageRedirect />} />
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import classes from "../styles/chat-sidebar.module.css";
|
||||
type Props = {
|
||||
chat: AiChat;
|
||||
isActive: boolean;
|
||||
onDelete: (chatId: string) => void;
|
||||
onDelete: (chatId: string, title: string | null) => void;
|
||||
onRename: (chatId: string, title: string) => void;
|
||||
};
|
||||
|
||||
@@ -153,7 +153,7 @@ export default function AiChatSidebarItem({
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onDelete(chat.id);
|
||||
onDelete(chat.id, chat.title);
|
||||
}}
|
||||
>
|
||||
{t("Delete")}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { ActionIcon, Center, TextInput, Loader, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
ActionIcon,
|
||||
Center,
|
||||
Text,
|
||||
TextInput,
|
||||
Loader,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { IconPlus, IconSearch, IconMessageCircle2 } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -73,16 +81,31 @@ export default function AiChatSidebar() {
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(id: string) => {
|
||||
deleteMutation.mutate(id, {
|
||||
onSuccess: () => {
|
||||
if (chatId === id) {
|
||||
navigate("/ai");
|
||||
}
|
||||
(id: string, title: string | null) => {
|
||||
modals.openConfirmModal({
|
||||
title: t("Delete chat"),
|
||||
centered: true,
|
||||
children: (
|
||||
<Text size="sm">
|
||||
{t("Are you sure you want to delete '{{title}}'? This action cannot be undone.", {
|
||||
title: title || t("Untitled"),
|
||||
})}
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: t("Delete"), cancel: t("Cancel") },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: () => {
|
||||
deleteMutation.mutate(id, {
|
||||
onSuccess: () => {
|
||||
if (chatId === id) {
|
||||
navigate("/ai");
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
[deleteMutation, chatId, navigate],
|
||||
[deleteMutation, chatId, navigate, t],
|
||||
);
|
||||
|
||||
const handleRename = useCallback(
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import "@/features/editor/styles/index.css";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
import ReadonlyPageEditor from "@/features/editor/readonly-page-editor";
|
||||
import { Container } from "@mantine/core";
|
||||
|
||||
type PdfRenderData = {
|
||||
pageId: string;
|
||||
title: string;
|
||||
content: any;
|
||||
};
|
||||
|
||||
export default function PdfRenderPage() {
|
||||
const { pageId } = useParams<{ pageId: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const token = searchParams.get("token");
|
||||
|
||||
const [data, setData] = useState<PdfRenderData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pageId || !token) {
|
||||
setError("Missing page ID or token");
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/api/pdf-export/render', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pageId, token }),
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
})
|
||||
.then((result) => setData(result.data))
|
||||
.catch((err) => setError(err.message));
|
||||
}, [pageId, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.title) {
|
||||
document.title = data.title;
|
||||
}
|
||||
}, [data?.title]);
|
||||
|
||||
if (error) {
|
||||
return <div>{error}</div>;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size={900} p={0}>
|
||||
<ReadonlyPageEditor
|
||||
key={data.pageId}
|
||||
title={data.title}
|
||||
content={data.content}
|
||||
pageId={data.pageId}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
+21
-17
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "0.71.1",
|
||||
"version": "0.80.2",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@@ -33,13 +33,13 @@
|
||||
"@ai-sdk/google": "^3.0.52",
|
||||
"@ai-sdk/openai": "^3.0.47",
|
||||
"@ai-sdk/openai-compatible": "^2.0.37",
|
||||
"@aws-sdk/client-s3": "3.1014.0",
|
||||
"@aws-sdk/lib-storage": "3.1014.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.1014.0",
|
||||
"@aws-sdk/client-s3": "3.1041.0",
|
||||
"@aws-sdk/lib-storage": "3.1041.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.1041.0",
|
||||
"@clickhouse/client": "^1.18.2",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/multipart": "^9.4.0",
|
||||
"@fastify/static": "^9.0.0",
|
||||
"@fastify/multipart": "^10.0.0",
|
||||
"@fastify/static": "^9.1.3",
|
||||
"@keyv/redis": "^5.1.6",
|
||||
"@langchain/core": "1.1.39",
|
||||
"@langchain/textsplitters": "1.0.1",
|
||||
@@ -48,19 +48,19 @@
|
||||
"@nestjs-labs/nestjs-ioredis": "^11.0.4",
|
||||
"@nestjs/bullmq": "^11.0.4",
|
||||
"@nestjs/cache-manager": "^3.1.0",
|
||||
"@nestjs/common": "^11.1.18",
|
||||
"@nestjs/config": "^4.0.3",
|
||||
"@nestjs/core": "^11.1.18",
|
||||
"@nestjs/common": "^11.1.19",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^11.1.19",
|
||||
"@nestjs/event-emitter": "^3.0.1",
|
||||
"@nestjs/jwt": "11.0.2",
|
||||
"@nestjs/mapped-types": "^2.1.1",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-fastify": "^11.1.18",
|
||||
"@nestjs/platform-socket.io": "^11.1.18",
|
||||
"@nestjs/schedule": "^6.1.1",
|
||||
"@nestjs/platform-fastify": "^11.1.19",
|
||||
"@nestjs/platform-socket.io": "^11.1.19",
|
||||
"@nestjs/schedule": "^6.1.3",
|
||||
"@nestjs/terminus": "^11.1.1",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@nestjs/websockets": "^11.1.18",
|
||||
"@nestjs/websockets": "^11.1.19",
|
||||
"@node-saml/passport-saml": "^5.1.0",
|
||||
"@react-email/components": "1.0.10",
|
||||
"@react-email/render": "2.0.4",
|
||||
@@ -69,7 +69,7 @@
|
||||
"ai-sdk-ollama": "^3.8.1",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bowser": "^2.14.1",
|
||||
"bullmq": "^5.71.0",
|
||||
"bullmq": "^5.76.0",
|
||||
"cache-manager": "^7.2.8",
|
||||
"cheerio": "^1.2.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
@@ -110,22 +110,23 @@
|
||||
"react": "^18.3.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"sanitize-filename-ts": "1.0.2",
|
||||
"sanitize-filename": "1.6.3",
|
||||
"socket.io": "^4.8.3",
|
||||
"stripe": "^17.7.0",
|
||||
"tlds": "^1.261.0",
|
||||
"tmp-promise": "^3.0.3",
|
||||
"tseep": "^1.3.1",
|
||||
"typesense": "^3.0.5",
|
||||
"undici": "7.24.0",
|
||||
"ws": "^8.20.0",
|
||||
"yauzl": "^3.2.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.28.0",
|
||||
"@nestjs/cli": "^11.0.18",
|
||||
"@nestjs/cli": "^11.0.21",
|
||||
"@nestjs/schematics": "^11.0.10",
|
||||
"@nestjs/testing": "^11.1.18",
|
||||
"@nestjs/testing": "^11.1.19",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/debounce": "^1.2.4",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
@@ -165,6 +166,9 @@
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"transformIgnorePatterns": [
|
||||
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked)(@|/))"
|
||||
],
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
|
||||
@@ -18,6 +18,7 @@ export const Feature = {
|
||||
SHARING_CONTROLS: 'sharing:controls',
|
||||
VIEWER_COMMENTS: 'comment:viewer',
|
||||
TEMPLATES: 'templates',
|
||||
PDF_EXPORT: 'export:pdf',
|
||||
} as const;
|
||||
|
||||
export type FeatureKey = (typeof Feature)[keyof typeof Feature];
|
||||
|
||||
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
import * as path from 'path';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { sanitize } from 'sanitize-filename-ts';
|
||||
import sanitize = require('sanitize-filename');
|
||||
import { FastifyRequest } from 'fastify';
|
||||
import { Readable, Transform } from 'stream';
|
||||
|
||||
@@ -72,11 +72,33 @@ export function extractDateFromUuid7(uuid7: string) {
|
||||
return new Date(timestamp);
|
||||
}
|
||||
|
||||
export function sanitizeFileName(fileName: string): string {
|
||||
const sanitizedFilename = sanitize(fileName)
|
||||
.replace(/ /g, '_')
|
||||
.replace(/#/g, '_');
|
||||
return sanitizedFilename.slice(0, 255);
|
||||
export type SanitizeFileNameOptions = {
|
||||
/** Keep spaces and `#` instead of replacing them with `_`. Useful for
|
||||
* download filenames where readability matters. Defaults to false. */
|
||||
preserveSpaces?: boolean;
|
||||
};
|
||||
|
||||
export function sanitizeFileName(
|
||||
fileName: string,
|
||||
options: SanitizeFileNameOptions = {},
|
||||
): string {
|
||||
// Decode percent-encoded sequences so that bypasses like "..%2F" reach
|
||||
// sanitize() as literal "../" and get stripped. sanitize-filename only
|
||||
// strips literal characters and won't catch encoded path separators
|
||||
// on its own.
|
||||
const decoded = fileName.replace(/%[0-9a-fA-F]{2}/g, (m) => {
|
||||
try {
|
||||
return decodeURIComponent(m);
|
||||
} catch {
|
||||
return m;
|
||||
}
|
||||
});
|
||||
|
||||
const sanitized = sanitize(decoded);
|
||||
if (options.preserveSpaces) {
|
||||
return sanitized;
|
||||
}
|
||||
return sanitized.replace(/ /g, '_').replace(/#/g, '_');
|
||||
}
|
||||
|
||||
export function removeAccent(str: string): string {
|
||||
|
||||
@@ -356,9 +356,19 @@ 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');
|
||||
if (!fileName) {
|
||||
throw new BadRequestException('Invalid file name');
|
||||
}
|
||||
|
||||
const ext = path.extname(fileName);
|
||||
const filenameWithoutExt = path.basename(fileName, ext);
|
||||
|
||||
if (
|
||||
!ext ||
|
||||
!isValidUUID(filenameWithoutExt) ||
|
||||
`${filenameWithoutExt}${ext}` !== fileName
|
||||
) {
|
||||
throw new BadRequestException('Invalid file name');
|
||||
}
|
||||
|
||||
const filePath = `${getAttachmentFolderPath(attachmentType, workspace.id)}/${fileName}`;
|
||||
|
||||
@@ -5,6 +5,8 @@ export enum JwtType {
|
||||
ATTACHMENT = 'attachment',
|
||||
MFA_TOKEN = 'mfa_token',
|
||||
API_KEY = 'api_key',
|
||||
PDF_RENDER = 'pdf_render',
|
||||
PDF_EXPORT_DOWNLOAD = 'pdf_export_download',
|
||||
}
|
||||
export type JwtPayload = {
|
||||
sub: string;
|
||||
@@ -45,3 +47,15 @@ export type JwtApiKeyPayload = {
|
||||
apiKeyId: string;
|
||||
type: 'api_key';
|
||||
};
|
||||
|
||||
export type JwtPdfRenderPayload = {
|
||||
pageId: string;
|
||||
workspaceId: string;
|
||||
type: 'pdf_render';
|
||||
};
|
||||
|
||||
export type JwtPdfExportDownloadPayload = {
|
||||
fileTaskId: string;
|
||||
workspaceId: string;
|
||||
type: 'pdf_export_download';
|
||||
};
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
JwtExchangePayload,
|
||||
JwtMfaTokenPayload,
|
||||
JwtPayload,
|
||||
JwtPdfExportDownloadPayload,
|
||||
JwtPdfRenderPayload,
|
||||
JwtType,
|
||||
} from '../dto/jwt-payload';
|
||||
import { User } from '@docmost/db/types/entity.types';
|
||||
@@ -115,6 +117,30 @@ export class TokenService {
|
||||
return this.jwtService.sign(payload, expiresIn ? { expiresIn } : {});
|
||||
}
|
||||
|
||||
async generatePdfRenderToken(
|
||||
pageId: string,
|
||||
workspaceId: string,
|
||||
): Promise<string> {
|
||||
const payload: JwtPdfRenderPayload = {
|
||||
pageId,
|
||||
workspaceId,
|
||||
type: JwtType.PDF_RENDER,
|
||||
};
|
||||
return this.jwtService.sign(payload, { expiresIn: '60s' });
|
||||
}
|
||||
|
||||
async generatePdfExportDownloadToken(
|
||||
fileTaskId: string,
|
||||
workspaceId: string,
|
||||
): Promise<string> {
|
||||
const payload: JwtPdfExportDownloadPayload = {
|
||||
fileTaskId,
|
||||
workspaceId,
|
||||
type: JwtType.PDF_EXPORT_DOWNLOAD,
|
||||
};
|
||||
return this.jwtService.sign(payload, { expiresIn: '1h' });
|
||||
}
|
||||
|
||||
async verifyJwt(token: string, tokenType: string) {
|
||||
const payload = await this.jwtService.verifyAsync(token, {
|
||||
secret: this.environmentService.getAppSecret(),
|
||||
|
||||
@@ -13,10 +13,6 @@ import { CreateUserDto } from '../../auth/dto/create-user.dto';
|
||||
export class UpdateUserDto extends PartialType(
|
||||
OmitType(CreateUserDto, ['password'] as const),
|
||||
) {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
avatarUrl: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
fullPageWidth: boolean;
|
||||
|
||||
@@ -110,10 +110,6 @@ export class UserService {
|
||||
user.email = updateUserDto.email;
|
||||
}
|
||||
|
||||
if (updateUserDto.avatarUrl) {
|
||||
user.avatarUrl = updateUserDto.avatarUrl;
|
||||
}
|
||||
|
||||
if (updateUserDto.locale) {
|
||||
user.locale = updateUserDto.locale;
|
||||
}
|
||||
|
||||
@@ -5,15 +5,10 @@ import {
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
logo: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
emailDomains: string[];
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.alterTable('file_tasks')
|
||||
.addColumn('page_id', 'uuid', (col) =>
|
||||
col.references('pages.id').onDelete('set null').ifNotExists(),
|
||||
)
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.alterTable('file_tasks')
|
||||
.addColumn('metadata', 'jsonb', (col) => col.ifNotExists())
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_file_tasks_page_export')
|
||||
.ifNotExists()
|
||||
.on('file_tasks')
|
||||
.columns(['page_id', 'workspace_id'])
|
||||
.where(sql.ref('type'), '=', 'export')
|
||||
.where(sql.ref('deleted_at'), 'is', null)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropIndex('idx_file_tasks_page_export').execute();
|
||||
|
||||
await db.schema.alterTable('file_tasks').dropColumn('page_id').execute();
|
||||
|
||||
await db.schema.alterTable('file_tasks').dropColumn('metadata').execute();
|
||||
}
|
||||
+2
@@ -196,6 +196,8 @@ export interface FileTasks {
|
||||
filePath: string;
|
||||
fileSize: Int8 | null;
|
||||
id: Generated<string>;
|
||||
metadata: Json | null;
|
||||
pageId: string | null;
|
||||
source: string | null;
|
||||
spaceId: string | null;
|
||||
status: string | null;
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: a5b5e10eec...4101fc427b
@@ -75,6 +75,10 @@ export class EnvironmentService {
|
||||
return new Date(Date.now() + msUntilExpiry);
|
||||
}
|
||||
|
||||
getGotenbergUrl(): string | undefined {
|
||||
return this.configService.get<string>('GOTENBERG_URL');
|
||||
}
|
||||
|
||||
getStorageDriver(): string {
|
||||
return this.configService.get<string>('STORAGE_DRIVER', 'local');
|
||||
}
|
||||
@@ -108,7 +112,10 @@ export class EnvironmentService {
|
||||
}
|
||||
|
||||
getAwsS3ForcePathStyle(): boolean {
|
||||
return this.configService.get<boolean>('AWS_S3_FORCE_PATH_STYLE');
|
||||
const forcePathStyle = this.configService
|
||||
.get<string>('AWS_S3_FORCE_PATH_STYLE', 'false')
|
||||
.toLowerCase();
|
||||
return forcePathStyle === 'true';
|
||||
}
|
||||
|
||||
getAwsS3Url(): string {
|
||||
|
||||
@@ -23,9 +23,12 @@ import {
|
||||
SpaceCaslSubject,
|
||||
} from '../../core/casl/interfaces/space-ability.type';
|
||||
import { FastifyReply } from 'fastify';
|
||||
import { sanitize } from 'sanitize-filename-ts';
|
||||
import { getExportExtension } from './utils';
|
||||
import { getMimeType, getPageTitle } from '../../common/helpers';
|
||||
import {
|
||||
getMimeType,
|
||||
getPageTitle,
|
||||
sanitizeFileName,
|
||||
} from '../../common/helpers';
|
||||
import * as path from 'path';
|
||||
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
|
||||
import {
|
||||
@@ -85,7 +88,9 @@ export class ExportController {
|
||||
|
||||
if (result.type === 'file') {
|
||||
const ext = getExportExtension(dto.format);
|
||||
const fileName = sanitize(page.title || 'untitled') + ext;
|
||||
const fileName =
|
||||
sanitizeFileName(page.title || 'untitled', { preserveSpaces: true }) +
|
||||
ext;
|
||||
const contentType = getMimeType(path.extname(fileName));
|
||||
|
||||
res.headers({
|
||||
@@ -96,7 +101,9 @@ export class ExportController {
|
||||
|
||||
res.send(result.content);
|
||||
} else {
|
||||
const fileName = sanitize(page.title || 'untitled') + '.zip';
|
||||
const fileName =
|
||||
sanitizeFileName(page.title || 'untitled', { preserveSpaces: true }) +
|
||||
'.zip';
|
||||
|
||||
res.headers({
|
||||
'Content-Type': 'application/zip',
|
||||
@@ -144,7 +151,9 @@ export class ExportController {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition':
|
||||
'attachment; filename="' +
|
||||
encodeURIComponent(sanitize(exportFile.fileName)) +
|
||||
encodeURIComponent(
|
||||
sanitizeFileName(exportFile.fileName, { preserveSpaces: true }),
|
||||
) +
|
||||
'"',
|
||||
});
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ import {
|
||||
} from '../../common/helpers/prosemirror/utils';
|
||||
import { htmlToMarkdown } from '@docmost/editor-ext';
|
||||
|
||||
type AllowedAttachment = { id: string; fileName: string; filePath: string };
|
||||
|
||||
@Injectable()
|
||||
export class ExportService {
|
||||
private readonly logger = new Logger(ExportService.name);
|
||||
@@ -272,6 +274,12 @@ export class ExportService {
|
||||
|
||||
computeLocalPath(tree, format, null, '', slugIdToPath);
|
||||
|
||||
// Batch resolve attachments once for the whole export so we only run the
|
||||
// owning-page view check a single time, regardless of page count.
|
||||
const allowedAttachments = includeAttachments
|
||||
? await this.resolveAccessibleAttachments(tree, userId, ignorePermissions)
|
||||
: new Map<string, AllowedAttachment>();
|
||||
|
||||
const stack: { folder: JSZip; parentPageId: string | null }[] = [
|
||||
{ folder: zip, parentPageId: null },
|
||||
];
|
||||
@@ -301,7 +309,7 @@ export class ExportService {
|
||||
);
|
||||
|
||||
if (includeAttachments) {
|
||||
await this.zipAttachments(updatedJsonContent, page.spaceId, folder);
|
||||
await this.zipAttachments(updatedJsonContent, folder, allowedAttachments);
|
||||
updatedJsonContent =
|
||||
updateAttachmentUrlsToLocalPaths(updatedJsonContent);
|
||||
}
|
||||
@@ -347,31 +355,80 @@ export class ExportService {
|
||||
zip.file('docmost-metadata.json', JSON.stringify(metadata, null, 2));
|
||||
}
|
||||
|
||||
async zipAttachments(prosemirrorJson: any, spaceId: string, zip: JSZip) {
|
||||
async zipAttachments(
|
||||
prosemirrorJson: any,
|
||||
zip: JSZip,
|
||||
allowed: Map<string, AllowedAttachment>,
|
||||
) {
|
||||
const attachmentIds = getAttachmentIds(prosemirrorJson);
|
||||
|
||||
if (attachmentIds.length > 0) {
|
||||
const attachments = await this.db
|
||||
.selectFrom('attachments')
|
||||
.select(['id', 'fileName', 'filePath'])
|
||||
.where('id', 'in', attachmentIds)
|
||||
.where('spaceId', '=', spaceId)
|
||||
.execute();
|
||||
await Promise.all(
|
||||
attachmentIds.map(async (id) => {
|
||||
const attachment = allowed.get(id);
|
||||
if (!attachment) return;
|
||||
try {
|
||||
const fileBuffer = await this.storageService.read(
|
||||
attachment.filePath,
|
||||
);
|
||||
const filePath = `/files/${attachment.id}/${attachment.fileName}`;
|
||||
zip.file(filePath, fileBuffer);
|
||||
} catch (err) {
|
||||
this.logger.debug(`Attachment export error ${attachment.id}`, err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
attachments.map(async (attachment) => {
|
||||
try {
|
||||
const fileBuffer = await this.storageService.read(
|
||||
attachment.filePath,
|
||||
);
|
||||
const filePath = `/files/${attachment.id}/${attachment.fileName}`;
|
||||
zip.file(filePath, fileBuffer);
|
||||
} catch (err) {
|
||||
this.logger.debug(`Attachment export error ${attachment.id}`, err);
|
||||
}
|
||||
}),
|
||||
private async resolveAccessibleAttachments(
|
||||
tree: PageExportTree,
|
||||
userId: string | undefined,
|
||||
ignorePermissions: boolean,
|
||||
): Promise<Map<string, AllowedAttachment>> {
|
||||
const allAttachmentIds = new Set<string>();
|
||||
let spaceId: string | undefined;
|
||||
for (const siblings of Object.values(tree)) {
|
||||
for (const page of siblings) {
|
||||
if (!spaceId) spaceId = page.spaceId;
|
||||
for (const id of getAttachmentIds(getProsemirrorContent(page.content))) {
|
||||
allAttachmentIds.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allAttachmentIds.size === 0 || !spaceId) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const attachments = await this.db
|
||||
.selectFrom('attachments')
|
||||
.select(['id', 'fileName', 'filePath', 'pageId'])
|
||||
.where('id', 'in', [...allAttachmentIds])
|
||||
.where('spaceId', '=', spaceId)
|
||||
.execute();
|
||||
|
||||
let visible = attachments;
|
||||
if (!ignorePermissions && userId) {
|
||||
const ownerPageIds = [
|
||||
...new Set(
|
||||
attachments
|
||||
.map((a) => a.pageId)
|
||||
.filter((id): id is string => !!id),
|
||||
),
|
||||
];
|
||||
const accessible = ownerPageIds.length
|
||||
? await this.pagePermissionRepo.filterAccessiblePageIds({
|
||||
pageIds: ownerPageIds,
|
||||
userId,
|
||||
spaceId,
|
||||
})
|
||||
: [];
|
||||
const accessibleSet = new Set(accessible);
|
||||
visible = attachments.filter(
|
||||
(a) => a.pageId && accessibleSet.has(a.pageId),
|
||||
);
|
||||
}
|
||||
|
||||
return new Map(visible.map((a) => [a.id, a]));
|
||||
}
|
||||
|
||||
async turnPageMentionsToLinks(
|
||||
|
||||
@@ -5,6 +5,9 @@ import { QueueJob, QueueName } from 'src/integrations/queue/constants';
|
||||
import { FileImportTaskService } from '../services/file-import-task.service';
|
||||
import { FileTaskStatus } from '../utils/file.utils';
|
||||
import { StorageService } from '../../storage/storage.service';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
|
||||
@Processor(QueueName.FILE_TASK_QUEUE)
|
||||
export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
@@ -13,6 +16,8 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
constructor(
|
||||
private readonly fileTaskService: FileImportTaskService,
|
||||
private readonly storageService: StorageService,
|
||||
private readonly moduleRef: ModuleRef,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -23,8 +28,11 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
case QueueJob.IMPORT_TASK:
|
||||
await this.fileTaskService.processZIpImport(job.data.fileTaskId);
|
||||
break;
|
||||
case QueueJob.EXPORT_TASK:
|
||||
// TODO: export task
|
||||
case QueueJob.PDF_EXPORT_TASK:
|
||||
await this.processExportTask(job.data.fileTaskId);
|
||||
break;
|
||||
case QueueJob.PDF_EXPORT_CLEANUP:
|
||||
await this.processExportCleanup();
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -33,6 +41,24 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private getPdfExportService() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const PdfExportModule = require('./../../../ee/pdf-export/pdf-export.service');
|
||||
return this.moduleRef.get(PdfExportModule.PdfExportService, {
|
||||
strict: false,
|
||||
});
|
||||
}
|
||||
|
||||
private async processExportTask(fileTaskId: string): Promise<void> {
|
||||
const pdfExportService = this.getPdfExportService();
|
||||
await pdfExportService.generateAndStorePdf(fileTaskId);
|
||||
}
|
||||
|
||||
private async processExportCleanup(): Promise<void> {
|
||||
const pdfExportService = this.getPdfExportService();
|
||||
await pdfExportService.cleanupExpiredExports();
|
||||
}
|
||||
|
||||
@OnWorkerEvent('active')
|
||||
onActive(job: Job) {
|
||||
this.logger.debug(`Processing ${job.name} job`);
|
||||
@@ -41,32 +67,39 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
@OnWorkerEvent('failed')
|
||||
async onFailed(job: Job) {
|
||||
this.logger.error(
|
||||
`Error processing ${job.name} job. Import Task ID: ${job.data.fileTaskId}. Reason: ${job.failedReason}`,
|
||||
`Error processing ${job.name} job. File Task ID: ${job.data?.fileTaskId}. Reason: ${job.failedReason}`,
|
||||
);
|
||||
|
||||
await this.handleFailedJob(job);
|
||||
if (job.name === QueueJob.IMPORT_TASK) {
|
||||
await this.handleFailedImportJob(job);
|
||||
} else if (job.name === QueueJob.PDF_EXPORT_TASK) {
|
||||
await this.handleFailedExportJob(job);
|
||||
}
|
||||
}
|
||||
|
||||
@OnWorkerEvent('completed')
|
||||
async onCompleted(job: Job) {
|
||||
this.logger.log(
|
||||
`Completed ${job.name} job for File task ID ${job.data.fileTaskId}`,
|
||||
`Completed ${job.name} job for File task ID ${job.data?.fileTaskId}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const fileTask = await this.fileTaskService.getFileTask(
|
||||
job.data.fileTaskId,
|
||||
);
|
||||
if (fileTask) {
|
||||
await this.storageService.delete(fileTask.filePath);
|
||||
this.logger.debug(`Deleted imported zip file: ${fileTask.filePath}`);
|
||||
if (job.name === QueueJob.IMPORT_TASK) {
|
||||
try {
|
||||
const fileTask = await this.fileTaskService.getFileTask(
|
||||
job.data.fileTaskId,
|
||||
);
|
||||
if (fileTask) {
|
||||
await this.storageService.delete(fileTask.filePath);
|
||||
this.logger.debug(`Deleted imported zip file: ${fileTask.filePath}`);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to delete imported zip file:`, err);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to delete imported zip file:`, err);
|
||||
}
|
||||
// Export tasks: do NOT delete the file on completion (kept for 24h cache)
|
||||
}
|
||||
|
||||
private async handleFailedJob(job: Job) {
|
||||
private async handleFailedImportJob(job: Job) {
|
||||
try {
|
||||
const fileTaskId = job.data.fileTaskId;
|
||||
const reason = job.failedReason || 'Unknown error';
|
||||
@@ -86,6 +119,25 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleFailedExportJob(job: Job) {
|
||||
try {
|
||||
const fileTaskId = job.data.fileTaskId;
|
||||
const reason = job.failedReason || 'Unknown error';
|
||||
|
||||
await this.db
|
||||
.updateTable('fileTasks')
|
||||
.set({
|
||||
status: FileTaskStatus.Failed,
|
||||
errorMessage: reason,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where('id', '=', fileTaskId)
|
||||
.execute();
|
||||
} catch (err) {
|
||||
this.logger.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
if (this.worker) {
|
||||
await this.worker.close();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { MultipartFile } from '@fastify/multipart';
|
||||
import { sanitize } from 'sanitize-filename-ts';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
htmlToJson,
|
||||
@@ -53,8 +52,8 @@ export class ImportService {
|
||||
const file = await filePromise;
|
||||
const fileBuffer = await file.toBuffer();
|
||||
const fileExtension = path.extname(file.filename).toLowerCase();
|
||||
const fileName = sanitize(
|
||||
path.basename(file.filename, fileExtension).slice(0, 255),
|
||||
const fileName = sanitizeFileName(
|
||||
path.basename(file.filename, fileExtension),
|
||||
);
|
||||
const fileContent = fileBuffer.toString();
|
||||
|
||||
|
||||
@@ -80,4 +80,7 @@ export enum QueueJob {
|
||||
|
||||
AUDIT_LOG = 'audit-log',
|
||||
AUDIT_CLEANUP = 'audit-cleanup',
|
||||
|
||||
PDF_EXPORT_TASK = 'pdf-export-task',
|
||||
PDF_EXPORT_CLEANUP = 'pdf-export-cleanup',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { resolve, sep } from 'path';
|
||||
import { LocalDriver } from './local.driver';
|
||||
|
||||
type FullPath = (filePath: string) => string;
|
||||
|
||||
describe('LocalDriver._fullPath', () => {
|
||||
const ROOT = resolve('/data/storage');
|
||||
const driver = new LocalDriver({ storagePath: ROOT });
|
||||
const fullPath = ((driver as any)._fullPath as FullPath).bind(driver);
|
||||
|
||||
describe('legitimate inputs (behavior preserved)', () => {
|
||||
it.each([
|
||||
['workspace-id/avatars/uuid.png', `${ROOT}${sep}workspace-id${sep}avatars${sep}uuid.png`],
|
||||
['workspace-id/files/uuid/file.pdf', `${ROOT}${sep}workspace-id${sep}files${sep}uuid${sep}file.pdf`],
|
||||
['a/b/c/d/e.bin', `${ROOT}${sep}a${sep}b${sep}c${sep}d${sep}e.bin`],
|
||||
['', ROOT],
|
||||
['.', ROOT],
|
||||
['./x/y.png', `${ROOT}${sep}x${sep}y.png`],
|
||||
['a//b', `${ROOT}${sep}a${sep}b`],
|
||||
['a/b/../c', `${ROOT}${sep}a${sep}c`],
|
||||
])('resolves %j to %j', (input, expected) => {
|
||||
expect(fullPath(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('traversal rejected', () => {
|
||||
it.each([
|
||||
'../etc/passwd',
|
||||
'../../../etc/passwd',
|
||||
'workspace/../../../etc/passwd',
|
||||
'..',
|
||||
'../..',
|
||||
'a/../../..',
|
||||
])('throws for %j', (input) => {
|
||||
expect(() => fullPath(input)).toThrow('Invalid file path');
|
||||
});
|
||||
});
|
||||
|
||||
describe('absolute path rejected', () => {
|
||||
it.each([
|
||||
'/etc/passwd',
|
||||
'/root/.ssh/id_rsa',
|
||||
sep + 'absolute',
|
||||
])('throws for %j', (input) => {
|
||||
expect(() => fullPath(input)).toThrow('Invalid file path');
|
||||
});
|
||||
});
|
||||
|
||||
describe('prefix-confusion rejected', () => {
|
||||
it('rejects a sibling directory whose name starts with the storage root', () => {
|
||||
const siblingDriver = new LocalDriver({ storagePath: '/data/storage' });
|
||||
const siblingFullPath = ((siblingDriver as any)._fullPath as FullPath).bind(siblingDriver);
|
||||
// Attempt to reach /data/storage-evil/secret by traversal:
|
||||
// resolve('/data/storage', '../storage-evil/secret') === '/data/storage-evil/secret'
|
||||
// Without the `+ sep` guard, a startsWith check would match.
|
||||
expect(() => siblingFullPath('../storage-evil/secret')).toThrow('Invalid file path');
|
||||
});
|
||||
});
|
||||
|
||||
describe('storage root itself', () => {
|
||||
it('accepts the root when input resolves to it', () => {
|
||||
expect(fullPath('')).toBe(ROOT);
|
||||
expect(fullPath('.')).toBe(ROOT);
|
||||
expect(fullPath('a/..')).toBe(ROOT);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
LocalStorageConfig,
|
||||
StorageOption,
|
||||
} from '../interfaces';
|
||||
import { join, dirname } from 'path';
|
||||
import { dirname, resolve, sep } from 'path';
|
||||
import * as fs from 'fs-extra';
|
||||
import { Readable } from 'stream';
|
||||
import { createReadStream, createWriteStream } from 'node:fs';
|
||||
@@ -17,7 +17,12 @@ export class LocalDriver implements StorageDriver {
|
||||
}
|
||||
|
||||
private _fullPath(filePath: string): string {
|
||||
return join(this.config.storagePath, filePath);
|
||||
const storageRoot = resolve(this.config.storagePath);
|
||||
const fullPath = resolve(storageRoot, filePath);
|
||||
if (fullPath !== storageRoot && !fullPath.startsWith(storageRoot + sep)) {
|
||||
throw new Error('Invalid file path');
|
||||
}
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
async upload(filePath: string, file: Buffer | Readable): Promise<void> {
|
||||
|
||||
+10
-8
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "docmost",
|
||||
"homepage": "https://docmost.com",
|
||||
"version": "0.71.1",
|
||||
"version": "0.80.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nx run-many -t build",
|
||||
@@ -62,7 +62,7 @@
|
||||
"cross-env": "^10.1.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"diff": "8.0.3",
|
||||
"dompurify": "^3.3.3",
|
||||
"dompurify": "3.4.1",
|
||||
"fractional-indexing-jittered": "^1.0.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"image-dimensions": "^2.5.0",
|
||||
@@ -72,7 +72,7 @@
|
||||
"ms": "3.0.0-canary.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"rfc6902": "5.2.0",
|
||||
"uuid": "^13.0.0",
|
||||
"uuid": "^14.0.0",
|
||||
"y-indexeddb": "^9.0.12",
|
||||
"y-prosemirror": "1.3.7",
|
||||
"yjs": "^13.6.30"
|
||||
@@ -102,9 +102,9 @@
|
||||
"y-prosemirror": "1.3.7",
|
||||
"glob": "13.0.6",
|
||||
"ws": "8.20.0",
|
||||
"dompurify": "3.3.3",
|
||||
"dompurify": "3.4.1",
|
||||
"tmp": "0.2.5",
|
||||
"hono": "4.12.12",
|
||||
"hono": "4.12.14",
|
||||
"mermaid": "11.13.0",
|
||||
"nanoid@^3": "3.3.8",
|
||||
"socket.io-parser": "4.2.6",
|
||||
@@ -123,15 +123,17 @@
|
||||
"flatted": "3.4.2",
|
||||
"picomatch@<2.3.2": "2.3.2",
|
||||
"picomatch@>=4.0.0 <4.0.4": "4.0.4",
|
||||
"fastify": "5.8.3",
|
||||
"fastify": "5.8.5",
|
||||
"yaml@>=1.0.0 <1.10.3": "1.10.3",
|
||||
"yaml@>=2.0.0 <2.8.3": "2.8.3",
|
||||
"path-to-regexp@^8": "8.4.0",
|
||||
"brace-expansion@^5": "5.0.5",
|
||||
"@xmldom/xmldom": "0.8.12",
|
||||
"@xmldom/xmldom": "0.8.13",
|
||||
"handlebars": "4.7.9",
|
||||
"axios": "1.15.0",
|
||||
"langsmith": "0.5.18"
|
||||
"langsmith": "0.5.19",
|
||||
"follow-redirects": "1.16.0",
|
||||
"protobufjs": "7.5.5"
|
||||
},
|
||||
"neverBuiltDependencies": []
|
||||
}
|
||||
|
||||
Generated
+890
-898
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user