mirror of
https://github.com/documenso/documenso.git
synced 2026-08-24 15:22:26 +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.
376 lines
11 KiB
TypeScript
376 lines
11 KiB
TypeScript
import { PDF_SIZE_A4_72PPI } from '@documenso/lib/constants/pdf';
|
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
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 { 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.
|
|
* Requires API key authentication via Authorization header.
|
|
*/
|
|
.get(
|
|
'/envelope/item/:envelopeItemId/download',
|
|
sValidator('param', ZDownloadEnvelopeItemRequestParamsSchema),
|
|
sValidator('query', ZDownloadEnvelopeItemRequestQuerySchema),
|
|
async (c) => {
|
|
const logger = c.get('logger');
|
|
|
|
try {
|
|
const { envelopeItemId } = c.req.valid('param');
|
|
const { version } = c.req.valid('query');
|
|
|
|
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,
|
|
envelopeItemId,
|
|
version,
|
|
});
|
|
|
|
const envelopeItem = await prisma.envelopeItem.findFirst({
|
|
where: {
|
|
id: envelopeItemId,
|
|
envelope: {
|
|
team: buildTeamWhereQuery({ teamId: apiToken.teamId, userId: apiToken.user.id }),
|
|
},
|
|
},
|
|
include: {
|
|
envelope: true,
|
|
documentData: true,
|
|
},
|
|
});
|
|
|
|
if (!envelopeItem) {
|
|
return c.json({ error: 'Envelope item not found' }, 404);
|
|
}
|
|
|
|
if (!envelopeItem.documentData) {
|
|
return c.json({ error: 'Document data not found' }, 404);
|
|
}
|
|
|
|
const baseOptions = {
|
|
title: envelopeItem.title,
|
|
documentData: envelopeItem.documentData,
|
|
isDownload: true,
|
|
context: c,
|
|
} as const;
|
|
|
|
if (version === 'pending') {
|
|
return await handleEnvelopeItemFileRequest({
|
|
...baseOptions,
|
|
version,
|
|
envelopeItemId: envelopeItem.id,
|
|
envelope: envelopeItem.envelope,
|
|
});
|
|
}
|
|
|
|
return await handleEnvelopeItemFileRequest({
|
|
...baseOptions,
|
|
version,
|
|
status: envelopeItem.envelope.status,
|
|
});
|
|
} catch (error) {
|
|
logger.error(error);
|
|
|
|
if (error instanceof AppError) {
|
|
const { status, body } = AppError.toRestAPIError(error);
|
|
|
|
// Preserve the existing `{ error }` shape for backwards compatibility;
|
|
// `code` is added as a new field for callers that want to branch on it.
|
|
return c.json({ error: body.message, code: error.code }, status);
|
|
}
|
|
|
|
return c.json({ error: 'Internal server error' }, 500);
|
|
}
|
|
},
|
|
)
|
|
/**
|
|
* Download the audit log for a document as a PDF.
|
|
* Requires API key authentication via Authorization header.
|
|
*/
|
|
.get(
|
|
'/envelope/:envelopeId/audit-log/download',
|
|
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/download',
|
|
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.
|
|
*/
|
|
.get('/document/:documentId/download', sValidator('param', ZDownloadDocumentRequestParamsSchema), async (c) => {
|
|
const logger = c.get('logger');
|
|
|
|
try {
|
|
const { documentId, version } = 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,
|
|
documentId,
|
|
version,
|
|
});
|
|
|
|
const envelope = await getEnvelopeById({
|
|
id: {
|
|
type: 'documentId',
|
|
id: documentId,
|
|
},
|
|
type: EnvelopeType.DOCUMENT,
|
|
userId: apiToken.user.id,
|
|
teamId: apiToken.teamId,
|
|
}).catch(() => null);
|
|
|
|
if (!envelope) {
|
|
return c.json({ error: 'Document not found' }, 404);
|
|
}
|
|
|
|
// Get the first envelope item (documents have exactly one)
|
|
const [envelopeItem] = envelope.envelopeItems;
|
|
|
|
if (!envelopeItem) {
|
|
return c.json({ error: 'Document item not found' }, 404);
|
|
}
|
|
|
|
if (!envelopeItem.documentData) {
|
|
return c.json({ error: 'Document data not found' }, 404);
|
|
}
|
|
|
|
return await handleEnvelopeItemFileRequest({
|
|
title: envelopeItem.title,
|
|
status: envelope.status,
|
|
documentData: envelopeItem.documentData,
|
|
version: version || 'signed',
|
|
isDownload: true,
|
|
context: c,
|
|
});
|
|
} 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);
|
|
}
|
|
});
|