mirror of
https://github.com/documenso/documenso.git
synced 2026-07-27 18:35:05 +10:00
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:
@@ -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
|
||||
>;
|
||||
|
||||
Reference in New Issue
Block a user