Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion apps/remix/app/components/dialogs/envelope-download-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Trans } from '@lingui/react/macro';
import { DocumentStatus, type EnvelopeItem } from '@prisma/client';
import { DownloadIcon, FileTextIcon } from 'lucide-react';

import { downloadEnvelopeZip } from '@documenso/lib/client-only/download-envelope-zip';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
Expand All @@ -28,6 +29,11 @@ type EnvelopeDownloadDialogProps = {
envelopeStatus: DocumentStatus;
envelopeItems?: EnvelopeItemToDownload[];

/**
* The envelope title, used to name the "download all" ZIP archive.
*/
envelopeTitle?: string;

/**
* The recipient token to download the document.
*
Expand All @@ -48,6 +54,7 @@ export const EnvelopeDownloadDialog = ({
envelopeId,
envelopeStatus,
envelopeItems: initialEnvelopeItems,
envelopeTitle,
token,
canDownloadPartial = false,
trigger,
Expand All @@ -61,6 +68,8 @@ export const EnvelopeDownloadDialog = ({
[envelopeItemIdAndVersion: string]: boolean;
}>({});

const [isDownloadingAll, setIsDownloadingAll] = useState(false);

const generateDownloadKey = (envelopeItemId: string, version: DownloadVersion) =>
`${envelopeItemId}-${version}`;

Expand Down Expand Up @@ -121,6 +130,34 @@ export const EnvelopeDownloadDialog = ({
}
};

const onDownloadAll = async () => {
if (isDownloadingAll) {
return;
}

setIsDownloadingAll(true);

try {
await downloadEnvelopeZip({
envelopeId,
token,
fileName: envelopeTitle,
version: envelopeStatus === DocumentStatus.COMPLETED ? 'signed' : 'original',
});
} catch (error) {
console.error(error);

toast({
title: t`Something went wrong`,
description: t`These documents could not be downloaded at this time. Please try again.`,
variant: 'destructive',
duration: 7500,
});
} finally {
setIsDownloadingAll(false);
}
};

return (
<Dialog open={open} onOpenChange={(value) => setOpen(value)}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
Expand All @@ -135,7 +172,20 @@ export const EnvelopeDownloadDialog = ({
</DialogDescription>
</DialogHeader>

<div className="flex w-full flex-col gap-4 overflow-hidden">
{!isLoadingEnvelopeItems && envelopeItems.length > 1 && (
<div className="flex items-center justify-between gap-2">
<p className="text-muted-foreground text-xs">
{t`${envelopeItems.length} documents`}
</p>

<Button variant="default" size="sm" onClick={onDownloadAll} loading={isDownloadingAll}>
{!isDownloadingAll && <DownloadIcon className="mr-2 h-4 w-4" />}
<Trans>Download all</Trans>
</Button>
</div>
)}

<div className="-mx-1 flex max-h-[60vh] w-full flex-col gap-4 overflow-y-auto px-1">
{isLoadingEnvelopeItems ? (
<>
{Array.from({ length: 1 }).map((_, index) => (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps
envelopeId={envelope.id}
envelopeStatus={envelope.status}
envelopeItems={envelope.envelopeItems}
envelopeTitle={envelope.title}
token={recipient?.token}
trigger={
<Button className="w-full">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
envelopeStatus={envelope.status}
token={recipient?.token}
envelopeItems={envelope.envelopeItems}
envelopeTitle={envelope.title}
canDownloadPartial={canManageDocument}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
<EnvelopeDownloadDialog
envelopeId={row.envelopeId}
envelopeStatus={row.status}
envelopeTitle={row.title}
token={recipient?.token}
trigger={
<Button className="w-32">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ export const DocumentsTableActionDropdown = ({
<EnvelopeDownloadDialog
envelopeId={row.envelopeId}
envelopeStatus={row.status}
envelopeTitle={row.title}
token={recipient?.token}
canDownloadPartial={canManageDocument}
trigger={
Expand Down
1 change: 1 addition & 0 deletions apps/remix/app/components/tables/inbox-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,7 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
<EnvelopeDownloadDialog
envelopeId={row.envelopeId}
envelopeStatus={row.status}
envelopeTitle={row.title}
token={recipient?.token}
trigger={
<Button className="w-32">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
envelopeId={document.envelopeId}
envelopeStatus={document.status}
envelopeItems={document.envelopeItems}
envelopeTitle={document.title}
token={recipient?.token}
trigger={
<Button type="button" variant="outline" className="flex-1 md:flex-initial">
Expand Down
107 changes: 107 additions & 0 deletions apps/remix/server/api/files/files.helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 });
}
Comment on lines +195 to +220

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

Performance & Security Improvements

  1. Parallel File Fetching (Performance): Currently, getFileServerSide is awaited sequentially inside a for...of loop. For envelopes with many documents, this results in sequential network/I/O requests, significantly slowing down the ZIP generation. Using Promise.all allows fetching all files in parallel.
  2. 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 });
  }


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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
const baseZipTitle = envelopeTitle.replace(/\.pdf$/, '') || 'documents';
const zipFilename = `${baseZipTitle}.zip`;
const baseZipTitle = envelopeTitle.replace(/[/\\]/g, '_').replace(/\.pdf$/, '') || 'documents';
const zipFilename = `${baseZipTitle}.zip`;


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;
Expand Down
108 changes: 107 additions & 1 deletion apps/remix/server/api/files/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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,
},
},
},
});


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),
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading