From 6c1bb2494c7fcbcc87f7fa999ddabe56fdce127d Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Tue, 21 Apr 2026 14:14:30 +0100 Subject: [PATCH] fix: tighten export --- .../src/integrations/export/export.service.ts | 99 +++++++++++++++---- 1 file changed, 78 insertions(+), 21 deletions(-) diff --git a/apps/server/src/integrations/export/export.service.ts b/apps/server/src/integrations/export/export.service.ts index 3f8da1bf..b4bc73fa 100644 --- a/apps/server/src/integrations/export/export.service.ts +++ b/apps/server/src/integrations/export/export.service.ts @@ -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(); + 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, + ) { 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> { + const allAttachmentIds = new Set(); + 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(