feat: "download all" zip + scrollable list for envelope downloads - #88
feat: "download all" zip + scrollable list for envelope downloads#88reeseherber wants to merge 1 commit into
Conversation
…ds (do-87) The envelope download dialog listed each document with its own download buttons but had no way to fetch them all at once, and the list had no height constraint so long lists (e.g. 12 documents) overflowed the dialog with no way to scroll to the clipped entries. - Add a dependency-free, store-mode ZIP writer (packages/lib/universal/zip.ts) used server-side to bundle an envelope's documents into a single archive. - Add authenticated and recipient-token "download-all" file routes that stream the bundled signed (or original) PDFs as a .zip named after the envelope. - Add a "Download all" action to the envelope download dialog and make the document list scrollable (max-height + overflow) so every document is reachable regardless of count. Closes #87
There was a problem hiding this comment.
Code Review
This pull request introduces a feature to download all documents in an envelope as a single ZIP archive, implementing a custom, dependency-free ZIP writer utility alongside new server-side endpoints and UI integrations. The code review feedback highlights several critical improvements: parallelizing file fetching with Promise.all to boost performance, sanitizing document titles and filenames on both the client and server to prevent path traversal (Zip Slip) vulnerabilities, and optimizing database queries by using Prisma's findUnique instead of findFirst when querying by ID.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| 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 }); | ||
| } |
There was a problem hiding this comment.
Performance & Security Improvements
- Parallel File Fetching (Performance): Currently,
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. - Path Traversal / Zip Slip Prevention (Security): Document titles are not guaranteed to be safe filenames. If a title contains path traversal sequences (e.g.,
../../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.
const filesResults = await Promise.all(
items.map(async (item) => {
if (!item.documentData) {
return null;
}
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) {
return null;
}
return { item, file };
})
);
for (const result of filesResults) {
if (!result) {
continue;
}
const { item, file } = result;
const baseTitle = item.title.replace(/[/\\]/g, '_').replace(/\.pdf$/, '');
const name = dedupeFileName(`${baseTitle}${suffix}`, usedNames);
files.push({ name, data: file });
}| const baseZipTitle = envelopeTitle.replace(/\.pdf$/, '') || 'documents'; | ||
| const zipFilename = `${baseZipTitle}.zip`; |
There was a problem hiding this comment.
Sanitize ZIP Filename:
If the envelopeTitle contains path separators (e.g., / or \), it can corrupt the Content-Disposition header or cause browsers to fail the download. Sanitizing the title by replacing these characters with underscores ensures a safe and reliable download filename.
| const baseZipTitle = envelopeTitle.replace(/\.pdf$/, '') || 'documents'; | |
| const zipFilename = `${baseZipTitle}.zip`; | |
| const baseZipTitle = envelopeTitle.replace(/[/\\]/g, '_').replace(/\.pdf$/, '') || 'documents'; | |
| const zipFilename = `${baseZipTitle}.zip`; |
| const envelope = await prisma.envelope.findFirst({ | ||
| where: { | ||
| id: envelopeId, | ||
| }, | ||
| include: { | ||
| envelopeItems: { | ||
| orderBy: { | ||
| order: 'asc', | ||
| }, | ||
| include: { | ||
| documentData: true, | ||
| }, | ||
| }, | ||
| }, | ||
| }); |
There was a problem hiding this comment.
Use findUnique instead of findFirst:
Since id is the primary key of the Envelope model, using findUnique is more idiomatic and allows Prisma to optimize the query execution plan better than findFirst.
| const envelope = await prisma.envelope.findFirst({ | |
| where: { | |
| id: envelopeId, | |
| }, | |
| include: { | |
| envelopeItems: { | |
| orderBy: { | |
| order: 'asc', | |
| }, | |
| include: { | |
| documentData: true, | |
| }, | |
| }, | |
| }, | |
| }); | |
| const envelope = await prisma.envelope.findUnique({ | |
| where: { | |
| id: envelopeId, | |
| }, | |
| include: { | |
| envelopeItems: { | |
| orderBy: { | |
| order: 'asc', | |
| }, | |
| include: { | |
| documentData: true, | |
| }, | |
| }, | |
| }, | |
| }); |
| const baseTitle = (fileName ?? 'documents').replace(/\.zip$/, ''); | ||
|
|
||
| downloadFile({ | ||
| filename: `${baseTitle}.zip`, | ||
| data: blob, | ||
| }); |
There was a problem hiding this comment.
Sanitize Client-Side Filename:
Sanitize the fileName by replacing path separators (/ and \) with underscores to prevent issues when triggering the client-side file download.
| const baseTitle = (fileName ?? 'documents').replace(/\.zip$/, ''); | |
| downloadFile({ | |
| filename: `${baseTitle}.zip`, | |
| data: blob, | |
| }); | |
| const baseTitle = (fileName ?? 'documents').replace(/[/\\]/g, '_').replace(/\.zip$/, ''); | |
| downloadFile({ | |
| filename: `${baseTitle}.zip`, | |
| data: blob, | |
| }); |
Closes #87
From Freshservice ticket FS-161220 (reporter: Dana). Downloading signed documents from a multi-document envelope had three problems:
Changes
packages/lib/universal/zip.ts): a small store-mode ZIP encoder (CRC32 + central directory). PDFs are already compressed, so store mode is ideal and avoids adding a zip dependency. Validated againstunzip -tand Python'szipfile.GET /api/files/envelope/:envelopeId/download-all/:version?(authenticated) andGET /api/files/token/:token/download-all/:version?(recipient) bundle every document in the envelope into one.zipnamed after the envelope. Reuses the existing access checks.max-h+overflow-y-auto) so every document is reachable regardless of count. The envelope title is threaded through for the archive filename.The envelope detail screen's existing Download button opens this same (now fixed) dialog, addressing point 3.
apps/remixtypecheck passes.