-
Notifications
You must be signed in to change notification settings - Fork 0
feat: "download all" zip + scrollable list for envelope downloads #88
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -12,6 +12,7 @@ import { generatePartialDocumentPdf } from '@documenso/lib/server-only/pdf/gener | |||||||||
| import { getTeamById } from '@documenso/lib/server-only/team/get-team'; | ||||||||||
| import { sha256 } from '@documenso/lib/universal/crypto'; | ||||||||||
| import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server'; | ||||||||||
| import { type ZipFile, createZip } from '@documenso/lib/universal/zip'; | ||||||||||
| import { prisma } from '@documenso/prisma'; | ||||||||||
|
|
||||||||||
| import type { HonoEnv } from '../../router'; | ||||||||||
|
|
@@ -130,6 +131,112 @@ export const handleEnvelopeItemFileRequest = async ({ | |||||||||
| return c.body(file); | ||||||||||
| }; | ||||||||||
|
|
||||||||||
| type EnvelopeZipItem = { | ||||||||||
| title: string; | ||||||||||
| documentData: { | ||||||||||
| type: DocumentDataType; | ||||||||||
| data: string; | ||||||||||
| initialData: string; | ||||||||||
| } | null; | ||||||||||
| }; | ||||||||||
|
|
||||||||||
| type BuildEnvelopeZipResponseOptions = { | ||||||||||
| envelopeTitle: string; | ||||||||||
| items: EnvelopeZipItem[]; | ||||||||||
| version: 'signed' | 'original'; | ||||||||||
| context: Context<HonoEnv>; | ||||||||||
| }; | ||||||||||
|
|
||||||||||
| /** | ||||||||||
| * Ensures every entry in the archive has a unique name. PDF titles within an | ||||||||||
| * envelope are not guaranteed to be unique, so colliding names get a numeric | ||||||||||
| * suffix (e.g. "report (2).pdf") to avoid silently overwriting an entry. | ||||||||||
| */ | ||||||||||
| const dedupeFileName = (name: string, usedNames: Set<string>): string => { | ||||||||||
| if (!usedNames.has(name)) { | ||||||||||
| usedNames.add(name); | ||||||||||
|
|
||||||||||
| return name; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| const extensionMatch = /\.[^.]+$/.exec(name); | ||||||||||
| const extension = extensionMatch ? extensionMatch[0] : ''; | ||||||||||
| const base = extension ? name.slice(0, -extension.length) : name; | ||||||||||
|
|
||||||||||
| let counter = 2; | ||||||||||
| let candidate = `${base} (${counter})${extension}`; | ||||||||||
|
|
||||||||||
| while (usedNames.has(candidate)) { | ||||||||||
| counter += 1; | ||||||||||
| candidate = `${base} (${counter})${extension}`; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| usedNames.add(candidate); | ||||||||||
|
|
||||||||||
| return candidate; | ||||||||||
| }; | ||||||||||
|
|
||||||||||
| /** | ||||||||||
| * Bundles every document in an envelope into a single ZIP archive and returns | ||||||||||
| * it as a download. Used by the "Download all" action so a user does not have | ||||||||||
| * to download each document in a multi-document envelope individually. | ||||||||||
| */ | ||||||||||
| export const buildEnvelopeZipResponse = async ({ | ||||||||||
| envelopeTitle, | ||||||||||
| items, | ||||||||||
| version, | ||||||||||
| context: c, | ||||||||||
| }: BuildEnvelopeZipResponseOptions) => { | ||||||||||
| const usedNames = new Set<string>(); | ||||||||||
| const suffix = version === 'signed' ? '_signed.pdf' : '.pdf'; | ||||||||||
|
|
||||||||||
| const files: ZipFile[] = []; | ||||||||||
|
|
||||||||||
| for (const item of items) { | ||||||||||
| if (!item.documentData) { | ||||||||||
| continue; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| const documentDataToUse = | ||||||||||
| version === 'signed' ? item.documentData.data : item.documentData.initialData; | ||||||||||
|
|
||||||||||
| const file = await getFileServerSide({ | ||||||||||
| type: item.documentData.type, | ||||||||||
| data: documentDataToUse, | ||||||||||
| }).catch((error) => { | ||||||||||
| console.error(error); | ||||||||||
|
|
||||||||||
| return null; | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| if (!file) { | ||||||||||
| continue; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| const baseTitle = item.title.replace(/\.pdf$/, ''); | ||||||||||
| const name = dedupeFileName(`${baseTitle}${suffix}`, usedNames); | ||||||||||
|
|
||||||||||
| files.push({ name, data: file }); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| if (files.length === 0) { | ||||||||||
| return c.json({ error: 'No files available to download' }, 404); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| const zip = createZip(files); | ||||||||||
|
|
||||||||||
| const baseZipTitle = envelopeTitle.replace(/\.pdf$/, '') || 'documents'; | ||||||||||
| const zipFilename = `${baseZipTitle}.zip`; | ||||||||||
|
Comment on lines
+228
to
+229
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sanitize ZIP Filename:
Suggested change
|
||||||||||
|
|
||||||||||
| c.header('Content-Type', 'application/zip'); | ||||||||||
| c.header('Content-Disposition', contentDisposition(zipFilename)); | ||||||||||
| c.header('Cache-Control', 'no-cache, no-store, must-revalidate'); | ||||||||||
| c.header('Pragma', 'no-cache'); | ||||||||||
| c.header('Expires', '0'); | ||||||||||
|
|
||||||||||
| return c.body(zip); | ||||||||||
| }; | ||||||||||
|
|
||||||||||
| type CheckEnvelopeFileAccessOptions = { | ||||||||||
| userId: number; | ||||||||||
| teamId: number; | ||||||||||
|
|
||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -11,9 +11,15 @@ import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-action | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { prisma } from '@documenso/prisma'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import type { HonoEnv } from '../../router'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { checkEnvelopeFileAccess, handleEnvelopeItemFileRequest } from './files.helpers'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| buildEnvelopeZipResponse, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| checkEnvelopeFileAccess, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| handleEnvelopeItemFileRequest, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } from './files.helpers'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| type TGetPresignedPostUrlResponse, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ZDownloadAllEnvelopeFilesRequestParamsSchema, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ZDownloadAllEnvelopeFilesTokenRequestParamsSchema, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ZGetEnvelopeItemFileDownloadRequestParamsSchema, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ZGetEnvelopeItemFileRequestParamsSchema, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ZGetEnvelopeItemFileRequestQuerySchema, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -216,6 +222,60 @@ export const filesRoute = new Hono<HonoEnv>() | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .get( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| '/envelope/:envelopeId/download-all/:version?', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sValidator('param', ZDownloadAllEnvelopeFilesRequestParamsSchema), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async (c) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const { envelopeId, version } = c.req.valid('param'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const session = await getOptionalSession(c); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!session.user) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return c.json({ error: 'Unauthorized' }, 401); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const envelope = await prisma.envelope.findFirst({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| where: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| id: envelopeId, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| include: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| envelopeItems: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| orderBy: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| order: 'asc', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| include: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| documentData: true, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+237
to
+251
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!envelope) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return c.json({ error: 'Envelope not found' }, 404); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const hasDownloadAccess = await checkEnvelopeFileAccess({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| userId: session.user.id, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| teamId: envelope.teamId, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| envelopeType: envelope.type, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| templateType: envelope.templateType, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!hasDownloadAccess) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return c.json( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { error: 'User does not have access to the team that this envelope is associated with' }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 403, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return await buildEnvelopeZipResponse({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| envelopeTitle: envelope.title, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| items: envelope.envelopeItems, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| version, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| context: c, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .get( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| '/token/:token/envelopeItem/:envelopeItemId', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sValidator('param', ZGetEnvelopeItemFileTokenRequestParamsSchema), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -323,6 +383,52 @@ export const filesRoute = new Hono<HonoEnv>() | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| context: c, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .get( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| '/token/:token/download-all/:version?', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sValidator('param', ZDownloadAllEnvelopeFilesTokenRequestParamsSchema), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async (c) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const { token, version } = c.req.valid('param'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let envelopeWhereQuery: Prisma.EnvelopeWhereInput = { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| recipients: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| some: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| token, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (token.startsWith('qr_')) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| envelopeWhereQuery = { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| qrToken: token, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const envelope = await prisma.envelope.findFirst({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| where: envelopeWhereQuery, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| include: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| envelopeItems: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| orderBy: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| order: 'asc', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| include: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| documentData: true, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!envelope) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return c.json({ error: 'Envelope not found' }, 404); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return await buildEnvelopeZipResponse({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| envelopeTitle: envelope.title, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| items: envelope.envelopeItems, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| version, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| context: c, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // PDF routes for both tokens and auth based | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Performance & Security Improvements
getFileServerSideis awaited sequentially inside afor...ofloop. For envelopes with many documents, this results in sequential network/I/O requests, significantly slowing down the ZIP generation. UsingPromise.allallows fetching all files in parallel.../../etc/passwd), it could lead to a Zip Slip vulnerability when extracted. Sanitizing the title by replacing path separators (/and\) with underscores ensures all files are safely extracted into the root of the ZIP archive.