mirror of
https://github.com/documenso/documenso.git
synced 2026-08-23 23:02:22 +10:00
Recipients can download a PDF that contains the inserted fields while the envelope is in the PENDING state. The token file route accepts the `pending` version. The PDF contains only the fields from recipients who signed and the fields of the recipient who makes the request. This is the same set of fields that the signing page shows to the recipient. The signing page and the completion page show the download dialog for pending envelopes. The dialog does not show the "Partial" button for legacy envelopes.
328 lines
9.0 KiB
TypeScript
328 lines
9.0 KiB
TypeScript
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
|
import { generatePartialSignedPdf } from '@documenso/lib/server-only/pdf/generate-partial-signed-pdf';
|
|
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 { prisma } from '@documenso/prisma';
|
|
import {
|
|
type DocumentDataType,
|
|
DocumentStatus,
|
|
type EnvelopeType,
|
|
EnvelopeType as EnvelopeTypeEnum,
|
|
SigningStatus,
|
|
type TemplateType,
|
|
TemplateType as TemplateTypeEnum,
|
|
} from '@prisma/client';
|
|
import contentDisposition from 'content-disposition';
|
|
import type { Context } from 'hono';
|
|
import { match } from 'ts-pattern';
|
|
|
|
import type { HonoEnv } from '../../router';
|
|
|
|
type DocumentDataInput = {
|
|
type: DocumentDataType;
|
|
data: string;
|
|
initialData: string;
|
|
};
|
|
|
|
export const resolveFileUploadUserId = async (c: Context<HonoEnv>): Promise<number | null> => {
|
|
const session = await getOptionalSession(c);
|
|
|
|
if (session.user?.id) {
|
|
return session.user.id;
|
|
}
|
|
|
|
const authorizationHeader = c.req.header('authorization');
|
|
|
|
const [bearerToken] = (authorizationHeader || '').split('Bearer ').filter((part) => part.length > 0);
|
|
|
|
const queryToken = c.req.query('token');
|
|
const presignToken = bearerToken || queryToken;
|
|
|
|
if (!presignToken) {
|
|
return null;
|
|
}
|
|
|
|
const verifiedToken = await verifyEmbeddingPresignToken({ token: presignToken }).catch(() => undefined);
|
|
|
|
return verifiedToken?.userId ?? null;
|
|
};
|
|
|
|
type EnvelopeForPendingDownload = {
|
|
id: string;
|
|
status: DocumentStatus;
|
|
internalVersion: number;
|
|
};
|
|
|
|
/**
|
|
* Options shape varies by `version`:
|
|
* - `signed` / `original`: serves stored bytes; only needs envelope `status` for cache headers.
|
|
* - `pending`: generates a fresh PDF with currently-inserted fields burned in; needs the
|
|
* envelope (id, status, internalVersion) plus envelopeItemId to query fields.
|
|
*/
|
|
type HandleEnvelopeItemFileRequestOptions = {
|
|
title: string;
|
|
documentData: DocumentDataInput;
|
|
isDownload: boolean;
|
|
context: Context<HonoEnv>;
|
|
} & (
|
|
| {
|
|
version: 'signed' | 'original';
|
|
status: DocumentStatus;
|
|
}
|
|
| {
|
|
version: 'pending';
|
|
envelopeItemId: string;
|
|
envelope: EnvelopeForPendingDownload;
|
|
|
|
/**
|
|
* When set, only fields from recipients who have signed, plus the fields of
|
|
* the recipient owning this token, are burned in. Keeps recipient downloads
|
|
* in parity with what the signing page shows them.
|
|
*/
|
|
recipientToken?: string;
|
|
}
|
|
);
|
|
|
|
/**
|
|
* Single entry point for envelope item file requests (view and download).
|
|
*
|
|
* Dispatches on `version`:
|
|
* - `signed` / `original`: returns the stored PDF bytes as-is.
|
|
* - `pending`: generates an on-demand PDF with all currently-inserted fields burned in.
|
|
*/
|
|
export const handleEnvelopeItemFileRequest = async (options: HandleEnvelopeItemFileRequestOptions) => {
|
|
if (options.version === 'pending') {
|
|
return handlePendingFileRequest(options);
|
|
}
|
|
|
|
return handleStaticFileRequest(options);
|
|
};
|
|
|
|
type StaticFileRequestOptions = Extract<HandleEnvelopeItemFileRequestOptions, { version: 'signed' | 'original' }>;
|
|
|
|
const handleStaticFileRequest = async ({
|
|
title,
|
|
status,
|
|
documentData,
|
|
version,
|
|
isDownload,
|
|
context: c,
|
|
}: StaticFileRequestOptions) => {
|
|
const documentDataToUse = version === 'signed' ? documentData.data : documentData.initialData;
|
|
|
|
const etag = Buffer.from(sha256(documentDataToUse)).toString('hex');
|
|
|
|
if (c.req.header('If-None-Match') === etag && !isDownload) {
|
|
return c.body(null, 304);
|
|
}
|
|
|
|
const file = await getFileServerSide({
|
|
type: documentData.type,
|
|
data: documentDataToUse,
|
|
}).catch((error) => {
|
|
console.error(error);
|
|
|
|
return null;
|
|
});
|
|
|
|
if (!file) {
|
|
return c.json({ error: 'File not found' }, 404);
|
|
}
|
|
|
|
c.header('Content-Type', 'application/pdf');
|
|
c.header('ETag', etag);
|
|
|
|
if (!isDownload) {
|
|
if (status === DocumentStatus.COMPLETED) {
|
|
c.header('Cache-Control', 'public, max-age=31536000, immutable');
|
|
} else {
|
|
c.header('Cache-Control', 'public, max-age=0, must-revalidate');
|
|
}
|
|
}
|
|
|
|
if (isDownload) {
|
|
// Generate filename following the pattern from envelope-download-dialog.tsx
|
|
const baseTitle = title.replace(/\.pdf$/, '');
|
|
const suffix = version === 'signed' ? '_signed.pdf' : '.pdf';
|
|
const filename = `${baseTitle}${suffix}`;
|
|
|
|
c.header('Content-Disposition', contentDisposition(filename));
|
|
|
|
// For downloads, prevent caching to ensure fresh data
|
|
c.header('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
c.header('Pragma', 'no-cache');
|
|
c.header('Expires', '0');
|
|
}
|
|
|
|
return c.body(file);
|
|
};
|
|
|
|
type PendingFileRequestOptions = Extract<HandleEnvelopeItemFileRequestOptions, { version: 'pending' }>;
|
|
|
|
const handlePendingFileRequest = async ({
|
|
title,
|
|
envelopeItemId,
|
|
envelope,
|
|
documentData,
|
|
recipientToken,
|
|
context: c,
|
|
}: PendingFileRequestOptions) => {
|
|
if (envelope.status !== DocumentStatus.PENDING) {
|
|
const errorCode = match(envelope.status)
|
|
.with(DocumentStatus.DRAFT, () => AppErrorCode.ENVELOPE_DRAFT)
|
|
.with(DocumentStatus.COMPLETED, () => AppErrorCode.ENVELOPE_COMPLETED)
|
|
.with(DocumentStatus.REJECTED, () => AppErrorCode.ENVELOPE_REJECTED)
|
|
.otherwise(() => AppErrorCode.INVALID_REQUEST);
|
|
|
|
throw new AppError(errorCode, {
|
|
message: `Envelope ${envelope.id} must be pending to download a partially signed PDF`,
|
|
statusCode: 400,
|
|
});
|
|
}
|
|
|
|
if (envelope.internalVersion !== 2) {
|
|
throw new AppError(AppErrorCode.ENVELOPE_LEGACY, {
|
|
message: `Envelope ${envelope.id} is a legacy envelope and does not support partially signed PDF downloads`,
|
|
statusCode: 400,
|
|
});
|
|
}
|
|
|
|
const fields = await prisma.field.findMany({
|
|
where: {
|
|
envelopeItemId,
|
|
inserted: true,
|
|
...(recipientToken
|
|
? {
|
|
recipient: {
|
|
OR: [{ signingStatus: SigningStatus.SIGNED }, { token: recipientToken }],
|
|
},
|
|
}
|
|
: {}),
|
|
},
|
|
include: {
|
|
signature: true,
|
|
},
|
|
orderBy: {
|
|
id: 'asc',
|
|
},
|
|
});
|
|
|
|
const etag = Buffer.from(
|
|
sha256(
|
|
JSON.stringify({
|
|
envelopeStatus: envelope.status,
|
|
fields: fields.map((field) => ({
|
|
id: field.id,
|
|
customText: field.customText,
|
|
signatureId: field.signature?.id ?? null,
|
|
signatureCreated: field.signature?.created ?? null,
|
|
})),
|
|
}),
|
|
),
|
|
).toString('hex');
|
|
|
|
if (c.req.header('If-None-Match') === etag) {
|
|
c.header('ETag', etag);
|
|
c.header('Cache-Control', 'no-store, private');
|
|
|
|
return c.body(null, 304);
|
|
}
|
|
|
|
const file = await getFileServerSide({
|
|
type: documentData.type,
|
|
data: documentData.initialData,
|
|
}).catch((error) => {
|
|
console.error(error);
|
|
|
|
return null;
|
|
});
|
|
|
|
if (!file) {
|
|
return c.json({ error: 'File not found' }, 404);
|
|
}
|
|
|
|
const pdf = await generatePartialSignedPdf({
|
|
pdfData: file,
|
|
fields,
|
|
});
|
|
|
|
c.get('logger').info({
|
|
source: 'pendingPdfDownload',
|
|
envelopeId: envelope.id,
|
|
envelopeItemId,
|
|
insertedFieldCount: fields.length,
|
|
etag,
|
|
});
|
|
|
|
c.header('Content-Type', 'application/pdf');
|
|
c.header('Cache-Control', 'no-store, private');
|
|
c.header('ETag', etag);
|
|
|
|
const baseTitle = title.replace(/\.pdf$/i, '');
|
|
const filename = `${baseTitle}_pending.pdf`;
|
|
|
|
c.header('Content-Disposition', contentDisposition(filename));
|
|
|
|
return c.body(pdf);
|
|
};
|
|
|
|
type CheckEnvelopeFileAccessOptions = {
|
|
userId: number;
|
|
teamId: number;
|
|
envelopeType: EnvelopeType;
|
|
templateType: TemplateType;
|
|
};
|
|
|
|
/**
|
|
* Check whether a user has access to an envelope's file.
|
|
*
|
|
* First checks team membership. If that fails and the envelope is an
|
|
* ORGANISATION template (not a document), falls back to checking whether
|
|
* the user belongs to any team in the same organisation.
|
|
*/
|
|
export const checkEnvelopeFileAccess = async ({
|
|
userId,
|
|
teamId,
|
|
envelopeType,
|
|
templateType,
|
|
}: CheckEnvelopeFileAccessOptions): Promise<boolean> => {
|
|
const team = await getTeamById({ userId, teamId }).catch(() => null);
|
|
|
|
if (team) {
|
|
return true;
|
|
}
|
|
|
|
if (envelopeType === EnvelopeTypeEnum.TEMPLATE && templateType === TemplateTypeEnum.ORGANISATION) {
|
|
const orgAccess = await prisma.team.findFirst({
|
|
where: {
|
|
id: teamId,
|
|
organisation: {
|
|
teams: {
|
|
some: {
|
|
teamGroups: {
|
|
some: {
|
|
organisationGroup: {
|
|
organisationGroupMembers: {
|
|
some: {
|
|
organisationMember: { userId },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
select: { id: true },
|
|
});
|
|
|
|
return orgAccess !== null;
|
|
}
|
|
|
|
return false;
|
|
};
|