Compare commits

...

14 Commits

Author SHA1 Message Date
Philipinho b99e3d67f5 v0.80.2 2026-05-02 11:25:11 +01:00
Philipinho 184811911f fix: parse AWS_S3_FORCE_PATH_STYLE env var as boolean 2026-05-02 11:21:42 +01:00
Philipinho 980521f957 v0.80.1 2026-04-27 16:06:32 +01:00
Philipinho fe44dc92a9 sync 2026-04-27 15:51:23 +01:00
Philip Okugbe fad410ef23 chore: add undici for oidc proxy support (#2132) 2026-04-27 15:50:42 +01:00
Philipinho 15b8908b1a update postcss 2026-04-27 15:23:47 +01:00
Philipinho 8e15b22d8c package updates 2026-04-27 15:22:02 +01:00
Philipinho ec83fc82d5 fix: refactor sanitize 2026-04-27 15:16:26 +01:00
Philipinho a573acedd0 fix: local storage, and package overrides 2026-04-22 14:13:25 +01:00
Philipinho dba8e315ab override 2026-04-14 17:59:59 +01:00
Philipinho 81ae7a17a6 confirm dialog 2026-04-14 17:56:36 +01:00
Philipinho 271f855761 v0.80.0 2026-04-14 17:08:44 +01:00
Philipinho 3e6d915227 sync 2026-04-14 16:34:44 +01:00
Philip Okugbe a6a7e4370a feat(ee): PDF export api (#2112)
* feat(ee): server side PDF export

* feat: pdf export queue

* sync

* sync
2026-04-14 16:26:54 +01:00
41 changed files with 1416 additions and 1009 deletions
+3
View File
@@ -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)
+6 -6
View File
@@ -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": "评论更新成功",
+2
View File
@@ -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
View File
@@ -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"
],
+1
View File
@@ -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.
+28 -6
View File
@@ -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
View File
@@ -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;
@@ -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
View File
@@ -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": []
}
+890 -898
View File
File diff suppressed because it is too large Load Diff