feat: add API endpoints for downloading certificate and audit log PDFs (#3025)

Add V2 API routes to download an envelope's certificate and audit log
separately, and align the internal cert/audit log downloads to use
envelopeId.

Enforces document visibility via getEnvelopeWhereInput and loads field
signatures so certificates render correctly.
This commit is contained in:
Lucas Smith
2026-06-26 00:01:55 +10:00
committed by GitHub
parent 94adea149d
commit 241929bb97
14 changed files with 348 additions and 64 deletions
@@ -11,10 +11,10 @@ import { DownloadIcon } from 'lucide-react';
export type DocumentAuditLogDownloadButtonProps = {
className?: string;
documentId: number;
envelopeId: string;
};
export const DocumentAuditLogDownloadButton = ({ className, documentId }: DocumentAuditLogDownloadButtonProps) => {
export const DocumentAuditLogDownloadButton = ({ className, envelopeId }: DocumentAuditLogDownloadButtonProps) => {
const { toast } = useToast();
const { _ } = useLingui();
@@ -22,7 +22,7 @@ export const DocumentAuditLogDownloadButton = ({ className, documentId }: Docume
const onDownloadAuditLogsClick = async () => {
try {
const { data, envelopeTitle } = await downloadAuditLogs({ documentId });
const { data, envelopeTitle } = await downloadAuditLogs({ envelopeId });
const buffer = new Uint8Array(base64.decode(data));
const blob = new Blob([buffer], { type: 'application/pdf' });
@@ -13,13 +13,13 @@ import { DownloadIcon } from 'lucide-react';
export type DocumentCertificateDownloadButtonProps = {
className?: string;
documentId: number;
envelopeId: string;
documentStatus: DocumentStatus;
};
export const DocumentCertificateDownloadButton = ({
className,
documentId,
envelopeId,
documentStatus,
}: DocumentCertificateDownloadButtonProps) => {
const { toast } = useToast();
@@ -29,7 +29,7 @@ export const DocumentCertificateDownloadButton = ({
const onDownloadCertificatesClick = async () => {
try {
const { data, envelopeTitle } = await downloadCertificate({ documentId });
const { data, envelopeTitle } = await downloadCertificate({ envelopeId });
const buffer = new Uint8Array(base64.decode(data));
const blob = new Blob([buffer], { type: 'application/pdf' });
@@ -153,11 +153,11 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
<div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end">
<DocumentCertificateDownloadButton
className="mr-2"
documentId={document.id}
envelopeId={document.envelopeId}
documentStatus={document.status}
/>
<DocumentAuditLogDownloadButton documentId={document.id} />
<DocumentAuditLogDownloadButton envelopeId={document.envelopeId} />
</div>
</div>
</div>
+212 -40
View File
@@ -1,20 +1,52 @@
import { PDF_SIZE_A4_72PPI } from '@documenso/lib/constants/pdf';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { getEnvelopeById, getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { generateAuditLogPdf } from '@documenso/lib/server-only/pdf/generate-audit-log-pdf';
import { generateCertificatePdf } from '@documenso/lib/server-only/pdf/generate-certificate-pdf';
import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { sValidator } from '@hono/standard-validator';
import { EnvelopeType } from '@prisma/client';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import contentDisposition from 'content-disposition';
import { Hono } from 'hono';
import type { HonoEnv } from '../../router';
import { handleEnvelopeItemFileRequest } from '../files/files.helpers';
import {
ZDownloadDocumentRequestParamsSchema,
ZDownloadEnvelopeAuditLogPdfRequestParamsSchema,
ZDownloadEnvelopeCertificatePdfRequestParamsSchema,
ZDownloadEnvelopeItemRequestParamsSchema,
ZDownloadEnvelopeItemRequestQuerySchema,
} from './download.types';
/**
* Resolve and validate an API token from the Authorization header.
*
* Supports both "Authorization: Bearer api_xxx" and "Authorization: api_xxx".
*/
const resolveApiToken = async (authorizationHeader: string | undefined) => {
const [token] = (authorizationHeader || '').split('Bearer ').filter((s) => s.length > 0);
if (!token) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'API token was not provided',
});
}
const apiToken = await getApiTokenByToken({ token });
if (apiToken.user.disabled) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'User is disabled',
});
}
return apiToken;
};
export const downloadRoute = new Hono<HonoEnv>()
/**
* Download an envelope item by its ID.
@@ -30,24 +62,8 @@ export const downloadRoute = new Hono<HonoEnv>()
try {
const { envelopeItemId } = c.req.valid('param');
const { version } = c.req.valid('query');
const authorizationHeader = c.req.header('authorization');
// Support for both "Authorization: Bearer api_xxx" and "Authorization: api_xxx"
const [token] = (authorizationHeader || '').split('Bearer ').filter((s) => s.length > 0);
if (!token) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'API token was not provided',
});
}
const apiToken = await getApiTokenByToken({ token });
if (apiToken.user.disabled) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'User is disabled',
});
}
const apiToken = await resolveApiToken(c.req.header('authorization'));
logger.info({
auth: 'api',
@@ -125,6 +141,180 @@ export const downloadRoute = new Hono<HonoEnv>()
}
},
)
/**
* Download the audit log for a document as a PDF.
* Requires API key authentication via Authorization header.
*/
.get(
'/envelope/:envelopeId/audit-log/pdf',
sValidator('param', ZDownloadEnvelopeAuditLogPdfRequestParamsSchema),
async (c) => {
const logger = c.get('logger');
try {
const { envelopeId } = c.req.valid('param');
const apiToken = await resolveApiToken(c.req.header('authorization'));
logger.info({
auth: 'api',
source: 'apiV2',
path: c.req.path,
userId: apiToken.user.id,
apiTokenId: apiToken.id,
envelopeId,
});
const envelope = await getEnvelopeById({
id: {
type: 'envelopeId',
id: envelopeId,
},
type: EnvelopeType.DOCUMENT,
userId: apiToken.user.id,
teamId: apiToken.teamId,
}).catch(() => null);
if (!envelope) {
return c.json({ error: 'Document not found' }, 404);
}
const auditLogPdf = await generateAuditLogPdf({
envelope,
recipients: envelope.recipients,
fields: envelope.fields,
language: envelope.documentMeta.language,
envelopeOwner: {
email: envelope.user.email,
name: envelope.user.name || '',
},
envelopeItems: envelope.envelopeItems.map((item) => item.title),
pageWidth: PDF_SIZE_A4_72PPI.width,
pageHeight: PDF_SIZE_A4_72PPI.height,
});
const result = await auditLogPdf.save();
const baseTitle = envelope.title.replace(/\.pdf$/i, '');
c.header('Content-Type', 'application/pdf');
c.header('Content-Disposition', contentDisposition(`${baseTitle}_audit-log.pdf`));
c.header('Cache-Control', 'no-cache, no-store, must-revalidate');
return c.body(result);
} catch (error) {
logger.error(error);
if (error instanceof AppError) {
const { status, body } = AppError.toRestAPIError(error);
return c.json({ error: body.message, code: error.code }, status);
}
return c.json({ error: 'Internal server error' }, 500);
}
},
)
/**
* Download the signing certificate for a completed document as a PDF.
* Requires API key authentication via Authorization header.
*/
.get(
'/envelope/:envelopeId/certificate/pdf',
sValidator('param', ZDownloadEnvelopeCertificatePdfRequestParamsSchema),
async (c) => {
const logger = c.get('logger');
try {
const { envelopeId } = c.req.valid('param');
const apiToken = await resolveApiToken(c.req.header('authorization'));
logger.info({
auth: 'api',
source: 'apiV2',
path: c.req.path,
userId: apiToken.user.id,
apiTokenId: apiToken.id,
envelopeId,
});
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'envelopeId',
id: envelopeId,
},
type: EnvelopeType.DOCUMENT,
userId: apiToken.user.id,
teamId: apiToken.teamId,
});
const envelope = await prisma.envelope.findFirst({
where: envelopeWhereInput,
include: {
recipients: true,
fields: {
include: {
signature: true,
},
},
documentMeta: true,
user: {
select: {
email: true,
name: true,
},
},
},
});
if (!envelope) {
return c.json({ error: 'Document not found' }, 404);
}
// A cancelled document was never sealed/completed, so a signing certificate
// must not be generated for it.
if (!isDocumentCompleted(envelope.status) || envelope.status === DocumentStatus.CANCELLED) {
throw new AppError('DOCUMENT_NOT_COMPLETE', {
message: 'Document is not complete',
});
}
const certificatePdf = await generateCertificatePdf({
envelope,
recipients: envelope.recipients,
fields: envelope.fields,
language: envelope.documentMeta.language,
envelopeOwner: {
email: envelope.user.email,
name: envelope.user.name || '',
},
pageWidth: PDF_SIZE_A4_72PPI.width,
pageHeight: PDF_SIZE_A4_72PPI.height,
});
const result = await certificatePdf.save();
const baseTitle = envelope.title.replace(/\.pdf$/i, '');
c.header('Content-Type', 'application/pdf');
c.header('Content-Disposition', contentDisposition(`${baseTitle}_certificate.pdf`));
c.header('Cache-Control', 'no-cache, no-store, must-revalidate');
return c.body(result);
} catch (error) {
logger.error(error);
if (error instanceof AppError) {
const { status, body } = AppError.toRestAPIError(error);
return c.json({ error: body.message, code: error.code }, status);
}
return c.json({ error: 'Internal server error' }, 500);
}
},
)
/**
* Download a document by its ID.
* Requires API key authentication via Authorization header.
@@ -134,24 +324,8 @@ export const downloadRoute = new Hono<HonoEnv>()
try {
const { documentId, version } = c.req.valid('param');
const authorizationHeader = c.req.header('authorization');
// Support for both "Authorization: Bearer api_xxx" and "Authorization: api_xxx"
const [token] = (authorizationHeader || '').split('Bearer ').filter((s) => s.length > 0);
if (!token) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'API token was not provided',
});
}
const apiToken = await getApiTokenByToken({ token });
if (apiToken.user.disabled) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'User is disabled',
});
}
const apiToken = await resolveApiToken(c.req.header('authorization'));
logger.info({
auth: 'api',
@@ -200,11 +374,9 @@ export const downloadRoute = new Hono<HonoEnv>()
logger.error(error);
if (error instanceof AppError) {
if (error.code === AppErrorCode.UNAUTHORIZED) {
return c.json({ error: error.message }, 401);
}
const { status, body } = AppError.toRestAPIError(error);
return c.json({ error: error.message }, 400);
return c.json({ error: body.message, code: error.code }, status);
}
return c.json({ error: 'Internal server error' }, 500);
@@ -30,3 +30,17 @@ export const ZDownloadDocumentRequestParamsSchema = z.object({
});
export type TDownloadDocumentRequestParams = z.infer<typeof ZDownloadDocumentRequestParamsSchema>;
export const ZDownloadEnvelopeAuditLogPdfRequestParamsSchema = z.object({
envelopeId: z.string().describe('The ID of the envelope to download the audit log for.'),
});
export type TDownloadEnvelopeAuditLogPdfRequestParams = z.infer<typeof ZDownloadEnvelopeAuditLogPdfRequestParamsSchema>;
export const ZDownloadEnvelopeCertificatePdfRequestParamsSchema = z.object({
envelopeId: z.string().describe('The ID of the envelope to download the certificate for.'),
});
export type TDownloadEnvelopeCertificatePdfRequestParams = z.infer<
typeof ZDownloadEnvelopeCertificatePdfRequestParamsSchema
>;