Compare commits

...

9 Commits

Author SHA1 Message Date
Philipinho 6c1bb2494c fix: tighten export 2026-04-21 14:14:30 +01:00
Philipinho 51a019c5c9 fix: remove unused dto objects 2026-04-20 21:36:26 +01:00
Philipinho 174058f2aa fix: tighten local storage access 2026-04-20 20:40:47 +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
Philip Okugbe cc00e77dfb fix: space overview favorites (#2110) 2026-04-14 02:58:24 +01:00
48 changed files with 546 additions and 109 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)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "0.71.1",
"version": "0.80.0",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@@ -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>
);
}
@@ -14,11 +14,11 @@ import {
} from "../services/favorite-service";
import { FavoriteType } from "../types/favorite.types";
export function useFavoritesQuery(type?: FavoriteType) {
export function useFavoritesQuery(type?: FavoriteType, spaceId?: string) {
return useInfiniteQuery({
queryKey: ["favorites", type],
queryKey: ["favorites", type, spaceId],
queryFn: ({ pageParam }) =>
getFavorites({ type, cursor: pageParam, limit: 15 }),
getFavorites({ type, spaceId, cursor: pageParam, limit: 15 }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) =>
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
@@ -26,10 +26,10 @@ export function useFavoritesQuery(type?: FavoriteType) {
});
}
export function useFavoriteIds(type: FavoriteType): Set<string> {
export function useFavoriteIds(type: FavoriteType, spaceId?: string): Set<string> {
const { data } = useQuery({
queryKey: ["favorite-ids", type],
queryFn: () => getFavoriteIds(type),
queryKey: ["favorite-ids", type, spaceId],
queryFn: () => getFavoriteIds(type, spaceId),
refetchOnMount: true,
});
@@ -52,9 +52,9 @@ export function useAddFavoriteMutation() {
onSuccess: (_result, variables) => {
const entityId = getEntityId(variables);
if (entityId) {
queryClient.setQueryData(
["favorite-ids", variables.type],
(old: { items: string[]; meta: any } | undefined) => {
queryClient.setQueriesData<{ items: string[]; meta: any }>(
{ queryKey: ["favorite-ids", variables.type] },
(old) => {
if (!old) return old;
if (old.items.includes(entityId)) return old;
return { ...old, items: [...old.items, entityId] };
@@ -76,9 +76,9 @@ export function useRemoveFavoriteMutation() {
onSuccess: (_result, variables) => {
const entityId = getEntityId(variables);
if (entityId) {
queryClient.setQueryData(
["favorite-ids", variables.type],
(old: { items: string[]; meta: any } | undefined) => {
queryClient.setQueriesData<{ items: string[]; meta: any }>(
{ queryKey: ["favorite-ids", variables.type] },
(old) => {
if (!old) return old;
return { ...old, items: old.items.filter((id) => id !== entityId) };
},
@@ -21,13 +21,14 @@ export async function removeFavorite(
await api.post("/favorites/remove", params);
}
export async function getFavoriteIds(type: FavoriteType): Promise<IPagination<string>> {
const req = await api.post<IPagination<string>>("/favorites/ids", { type });
export async function getFavoriteIds(type: FavoriteType, spaceId?: string): Promise<IPagination<string>> {
const req = await api.post<IPagination<string>>("/favorites/ids", { type, spaceId });
return req.data;
}
export async function getFavorites(params?: {
type?: FavoriteType;
spaceId?: string;
limit?: number;
cursor?: string;
}): Promise<IPagination<IFavorite>> {
@@ -18,7 +18,11 @@ import { getSpaceUrl } from "@/lib/config";
import { useTranslation } from "react-i18next";
import { getInitialsColor } from "@/lib/get-initials-color";
export default function FavoritesPages() {
interface Props {
spaceId?: string;
}
export default function FavoritesPages({ spaceId }: Props) {
const { t } = useTranslation();
const {
data,
@@ -27,7 +31,7 @@ export default function FavoritesPages() {
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = useFavoritesQuery("page");
} = useFavoritesQuery("page", spaceId);
const favorites = data?.pages.flatMap((p) => p.items) ?? [];
@@ -72,19 +76,21 @@ export default function FavoritesPages() {
</Group>
</UnstyledButton>
</Table.Td>
<Table.Td>
{fav.space && (
<Badge
color={getInitialsColor(fav.space.name)}
variant="light"
component={Link}
to={getSpaceUrl(fav.space.slug)}
style={{ cursor: "pointer" }}
>
{fav.space.name}
</Badge>
)}
</Table.Td>
{!spaceId && (
<Table.Td>
{fav.space && (
<Badge
color={getInitialsColor(fav.space.name)}
variant="light"
component={Link}
to={getSpaceUrl(fav.space.slug)}
style={{ cursor: "pointer" }}
>
{fav.space.name}
</Badge>
)}
</Table.Td>
)}
<Table.Td>
<Text
c="dimmed"
@@ -145,7 +145,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
] = useDisclosure(false);
const [pageEditor] = useAtom(pageEditorAtom);
const pageUpdatedAt = useTimeAgo(page?.updatedAt);
const favoriteIds = useFavoriteIds("page");
const favoriteIds = useFavoriteIds("page", page?.spaceId);
const addFavoriteMutation = useAddFavoriteMutation();
const removeFavoriteMutation = useRemoveFavoriteMutation();
const isFavorited = page?.id ? favoriteIds.has(page.id) : false;
@@ -509,7 +509,7 @@ function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) {
copyPageModalOpened,
{ open: openCopyPageModal, close: closeCopySpaceModal },
] = useDisclosure(false);
const favoriteIds = useFavoriteIds("page");
const favoriteIds = useFavoriteIds("page", spaceId);
const addFavorite = useAddFavoriteMutation();
const removeFavorite = useRemoveFavoriteMutation();
const isFavorited = favoriteIds.has(node.data.id);
@@ -47,7 +47,7 @@ export default function SpaceHomeTabs() {
{space?.id && <RecentChanges spaceId={space.id} />}
</Tabs.Panel>
<Tabs.Panel value="favorites">
<FavoritesPages />
{space?.id && <FavoritesPages spaceId={space.id} />}
</Tabs.Panel>
<Tabs.Panel value="created">
{space?.id && <CreatedByMe spaceId={space.id} />}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "server",
"version": "0.71.1",
"version": "0.80.0",
"description": "",
"author": "",
"private": true,
+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];
@@ -53,6 +53,7 @@ import { EnvironmentService } from '../../integrations/environment/environment.s
import { TokenService } from '../auth/services/token.service';
import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload';
import * as path from 'path';
import { sanitize } from 'sanitize-filename-ts';
import { AttachmentInfoDto, RemoveIconDto } from './dto/attachment.dto';
import { PageAccessService } from '../page/page-access/page-access.service';
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
@@ -356,6 +357,10 @@ export class AttachmentController {
throw new BadRequestException('Invalid image attachment type');
}
if (!fileName || sanitize(fileName) !== fileName) {
throw new BadRequestException('Invalid file name');
}
const filenameWithoutExt = path.basename(fileName, path.extname(fileName));
if (!isValidUUID(filenameWithoutExt)) {
throw new BadRequestException('Invalid file id');
@@ -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(),
@@ -1,8 +1,12 @@
import { IsIn, IsNotEmpty, IsString } from 'class-validator';
import { IsIn, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
export class FavoriteIdsDto {
@IsString()
@IsNotEmpty()
@IsIn(['page', 'space', 'template'])
type: 'page' | 'space' | 'template';
@IsOptional()
@IsUUID()
spaceId?: string;
}
@@ -1,8 +1,12 @@
import { IsIn, IsOptional, IsString } from 'class-validator';
import { IsIn, IsOptional, IsString, IsUUID } from 'class-validator';
export class ListFavoritesDto {
@IsOptional()
@IsString()
@IsIn(['page', 'space', 'template'])
type?: 'page' | 'space' | 'template';
@IsOptional()
@IsUUID()
spaceId?: string;
}
@@ -82,6 +82,7 @@ export class FavoriteController {
user.id,
workspace.id,
dto.type as FavoriteType,
dto.spaceId,
);
}
@@ -98,6 +99,7 @@ export class FavoriteController {
workspace.id,
pagination,
dto.type as FavoriteType | undefined,
dto.spaceId,
);
}
@@ -20,11 +20,13 @@ export class FavoriteService {
userId: string,
workspaceId: string,
type: FavoriteType,
spaceId?: string,
) {
const result = await this.favoriteRepo.getFavoriteIds(
userId,
workspaceId,
type,
spaceId,
);
if (result.items.length === 0) {
@@ -95,12 +97,14 @@ export class FavoriteService {
workspaceId: string,
pagination: PaginationOptions,
type?: FavoriteType,
spaceId?: string,
) {
const result = await this.favoriteRepo.findUserFavorites(
userId,
workspaceId,
pagination,
type,
spaceId,
);
if (result.items.length === 0) {
@@ -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();
}
@@ -5,7 +5,7 @@ import { InsertableFavorite, Favorite } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { ExpressionBuilder, sql } from 'kysely';
import { ExpressionBuilder, SelectQueryBuilder, sql } from 'kysely';
import { DB } from '@docmost/db/types/db';
import { dbOrTx } from '@docmost/db/utils';
@@ -66,6 +66,7 @@ export class FavoriteRepo {
userId: string,
workspaceId: string,
type: FavoriteType,
spaceId?: string,
): Promise<{ items: string[]; meta: any }> {
const idColumn =
type === FavoriteType.PAGE
@@ -74,12 +75,16 @@ export class FavoriteRepo {
? 'spaceId'
: 'templateId';
const query = this.db
let query = this.db
.selectFrom('favorites')
.select(['favorites.id', `favorites.${idColumn} as entityId`])
.where('userId', '=', userId)
.where('workspaceId', '=', workspaceId)
.where('type', '=', type);
.where('favorites.userId', '=', userId)
.where('favorites.workspaceId', '=', workspaceId)
.where('favorites.type', '=', type);
if (spaceId) {
query = this.applySpaceFilter(query, type, spaceId);
}
const result = await executeWithCursorPagination(query, {
perPage: 250,
@@ -100,6 +105,7 @@ export class FavoriteRepo {
workspaceId: string,
pagination: PaginationOptions,
type?: FavoriteType,
spaceId?: string,
) {
let query = this.db
.selectFrom('favorites')
@@ -111,6 +117,10 @@ export class FavoriteRepo {
query = query.where('favorites.type', '=', type);
}
if (spaceId) {
query = this.applySpaceFilter(query, type, spaceId);
}
if (type === FavoriteType.PAGE || !type) {
query = query.select((eb) => this.withPage(eb));
}
@@ -184,6 +194,39 @@ export class FavoriteRepo {
.execute();
}
private applySpaceFilter<Q extends SelectQueryBuilder<any, any, any>>(
query: Q,
type: FavoriteType | undefined,
spaceId: string,
): Q {
if (type === FavoriteType.PAGE) {
return query.where((eb: any) =>
eb.exists(
eb
.selectFrom('pages')
.select(sql`1`.as('one'))
.whereRef('pages.id', '=', 'favorites.pageId')
.where('pages.spaceId', '=', spaceId),
),
) as Q;
}
if (type === FavoriteType.SPACE) {
return query.where('favorites.spaceId' as any, '=', spaceId) as Q;
}
if (type === FavoriteType.TEMPLATE) {
return query.where((eb: any) =>
eb.exists(
eb
.selectFrom('templates')
.select(sql`1`.as('one'))
.whereRef('templates.id', '=', 'favorites.templateId')
.where('templates.spaceId', '=', spaceId),
),
) as Q;
}
return query;
}
private withPage(eb: ExpressionBuilder<DB, 'favorites'>) {
return jsonObjectFrom(
eb
+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');
}
@@ -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();
@@ -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> {
+3 -2
View File
@@ -1,7 +1,7 @@
{
"name": "docmost",
"homepage": "https://docmost.com",
"version": "0.71.1",
"version": "0.80.0",
"private": true,
"scripts": {
"build": "nx run-many -t build",
@@ -131,7 +131,8 @@
"@xmldom/xmldom": "0.8.12",
"handlebars": "4.7.9",
"axios": "1.15.0",
"langsmith": "0.5.18"
"langsmith": "0.5.18",
"follow-redirects": "1.16.0"
},
"neverBuiltDependencies": []
}
+5 -4
View File
@@ -39,6 +39,7 @@ overrides:
handlebars: 4.7.9
axios: 1.15.0
langsmith: 0.5.18
follow-redirects: 1.16.0
patchedDependencies:
react-arborist@3.4.0:
@@ -6996,8 +6997,8 @@ packages:
flatted@3.4.2:
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
follow-redirects@1.15.11:
resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
follow-redirects@1.16.0:
resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
@@ -16273,7 +16274,7 @@ snapshots:
axios@1.15.0:
dependencies:
follow-redirects: 1.15.11
follow-redirects: 1.16.0
form-data: 4.0.5
proxy-from-env: 2.1.0
transitivePeerDependencies:
@@ -17939,7 +17940,7 @@ snapshots:
flatted@3.4.2: {}
follow-redirects@1.15.11: {}
follow-redirects@1.16.0: {}
for-each@0.3.3:
dependencies: