chore: merge main, resolve biome formatting conflicts

Merge origin/main into feat/document-file-conversion. Conflicts were
format-only (Tailwind class ordering, single-line vs multi-line) plus
two semantic merges:

- files.helpers.ts: combine main's pending-PDF download path with the
  branch's original-source-file (DOCX/PNG/JPEG) download path
- download-pdf.ts: combine main's versionToFilenameSuffix helper with
  the branch's server-provided Content-Disposition filename support
This commit is contained in:
ephraimduncan
2026-05-12 11:28:47 +00:00
1495 changed files with 22068 additions and 33465 deletions
@@ -1,9 +1,6 @@
import { z } from 'zod';
import {
type TDetectFieldsRequest,
ZNormalizedFieldWithContextSchema,
} from './detect-fields.types';
import { type TDetectFieldsRequest, ZNormalizedFieldWithContextSchema } from './detect-fields.types';
export type { TDetectFieldsRequest };
+3 -4
View File
@@ -1,12 +1,11 @@
import { sValidator } from '@hono/standard-validator';
import { Hono } from 'hono';
import { streamText } from 'hono/streaming';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { IS_AI_FEATURES_CONFIGURED } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { detectFieldsFromEnvelope } from '@documenso/lib/server-only/ai/envelope/detect-fields';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { sValidator } from '@hono/standard-validator';
import { Hono } from 'hono';
import { streamText } from 'hono/streaming';
import type { HonoEnv } from '../../router';
import { ZDetectFieldsRequestSchema } from './detect-fields.types';
@@ -1,10 +1,6 @@
import { ZConfidenceLevel, ZDetectableFieldType } from '@documenso/lib/server-only/ai/envelope/detect-fields/schema';
import { z } from 'zod';
import {
ZConfidenceLevel,
ZDetectableFieldType,
} from '@documenso/lib/server-only/ai/envelope/detect-fields/schema';
export const ZDetectFieldsRequestSchema = z.object({
envelopeId: z.string().min(1).describe('The ID of the envelope to detect fields from.'),
teamId: z.number().describe('The ID of the team the envelope belongs to.'),
@@ -1,8 +1,7 @@
import { ZDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
import { z } from 'zod';
import { ZDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
import { type TDetectRecipientsRequest } from './detect-recipients.types';
import type { TDetectRecipientsRequest } from './detect-recipients.types';
export type { TDetectRecipientsRequest };
@@ -1,12 +1,11 @@
import { sValidator } from '@hono/standard-validator';
import { Hono } from 'hono';
import { streamText } from 'hono/streaming';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { IS_AI_FEATURES_CONFIGURED } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { detectRecipientsFromEnvelope } from '@documenso/lib/server-only/ai/envelope/detect-recipients';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { sValidator } from '@hono/standard-validator';
import { Hono } from 'hono';
import { streamText } from 'hono/streaming';
import type { HonoEnv } from '../../router';
import { ZDetectRecipientsRequestSchema } from './detect-recipients.types';
@@ -1,6 +1,5 @@
import { z } from 'zod';
import { ZDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
import { z } from 'zod';
export const ZDetectRecipientsRequestSchema = z.object({
envelopeId: z.string().min(1).describe('The ID of the envelope to detect recipients from.'),
+113 -93
View File
@@ -1,18 +1,18 @@
import { sValidator } from '@hono/standard-validator';
import { EnvelopeType } from '@prisma/client';
import { Hono } from 'hono';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token';
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { sValidator } from '@hono/standard-validator';
import { EnvelopeType } from '@prisma/client';
import { Hono } from 'hono';
import type { HonoEnv } from '../../router';
import { handleEnvelopeItemFileRequest } from '../files/files.helpers';
import {
ZDownloadDocumentRequestParamsSchema,
ZDownloadEnvelopeItemRequestParamsSchema,
ZDownloadEnvelopeItemRequestQuerySchema,
} from './download.types';
export const downloadRoute = new Hono<HonoEnv>()
@@ -23,11 +23,13 @@ export const downloadRoute = new Hono<HonoEnv>()
.get(
'/envelope/item/:envelopeItemId/download',
sValidator('param', ZDownloadEnvelopeItemRequestParamsSchema),
sValidator('query', ZDownloadEnvelopeItemRequestQuerySchema),
async (c) => {
const logger = c.get('logger');
try {
const { envelopeItemId, version } = c.req.valid('param');
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"
@@ -65,7 +67,16 @@ export const downloadRoute = new Hono<HonoEnv>()
},
},
include: {
envelope: true,
envelope: {
include: {
recipients: {
select: {
role: true,
signingStatus: true,
},
},
},
},
documentData: true,
},
});
@@ -78,23 +89,36 @@ export const downloadRoute = new Hono<HonoEnv>()
return c.json({ error: 'Document data not found' }, 404);
}
return await handleEnvelopeItemFileRequest({
const baseOptions = {
title: envelopeItem.title,
status: envelopeItem.envelope.status,
documentData: envelopeItem.documentData,
version: version || 'signed',
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) {
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);
// 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);
@@ -105,88 +129,84 @@ export const downloadRoute = new Hono<HonoEnv>()
* 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');
.get('/document/:documentId/download', sValidator('param', ZDownloadDocumentRequestParamsSchema), async (c) => {
const logger = c.get('logger');
try {
const { documentId, version } = c.req.valid('param');
const authorizationHeader = c.req.header('authorization');
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);
// 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',
});
}
logger.info({
auth: 'api',
source: 'apiV2',
path: c.req.path,
userId: apiToken.user.id,
apiTokenId: apiToken.id,
documentId,
version,
if (!token) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'API token was not provided',
});
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) {
if (error.code === AppErrorCode.UNAUTHORIZED) {
return c.json({ error: error.message }, 401);
}
return c.json({ error: error.message }, 400);
}
return c.json({ error: 'Internal server error' }, 500);
}
},
);
const apiToken = await getApiTokenByToken({ token });
if (apiToken.user.disabled) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'User is disabled',
});
}
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) {
if (error.code === AppErrorCode.UNAUTHORIZED) {
return c.json({ error: error.message }, 401);
}
return c.json({ error: error.message }, 400);
}
return c.json({ error: 'Internal server error' }, 500);
}
});
@@ -2,18 +2,21 @@ import { z } from 'zod';
export const ZDownloadEnvelopeItemRequestParamsSchema = z.object({
envelopeItemId: z.string().describe('The ID of the envelope item to download.'),
});
export const ZDownloadEnvelopeItemRequestQuerySchema = z.object({
version: z
.enum(['original', 'signed'])
.enum(['original', 'signed', 'pending'])
.optional()
.default('signed')
.describe(
'The version of the envelope item to download. "signed" returns the completed document with signatures, "original" returns the original uploaded document.',
'The version of the envelope item to download. "signed" returns the completed document with all signatures and the audit trail, "original" returns the original uploaded document, "pending" returns the original document with currently-inserted fields burned in (only valid while the envelope is in PENDING status; not a final executed document).',
),
});
export type TDownloadEnvelopeItemRequestParams = z.infer<
typeof ZDownloadEnvelopeItemRequestParamsSchema
>;
export type TDownloadEnvelopeItemRequestParams = z.infer<typeof ZDownloadEnvelopeItemRequestParamsSchema>;
export type TDownloadEnvelopeItemRequestQuery = z.infer<typeof ZDownloadEnvelopeItemRequestQuerySchema>;
export const ZDownloadDocumentRequestParamsSchema = z.object({
documentId: z.coerce.number().describe('The ID of the document to download.'),
+173 -32
View File
@@ -1,47 +1,92 @@
import {
type DocumentDataType,
DocumentStatus,
type EnvelopeType,
type TemplateType,
} from '@prisma/client';
import { EnvelopeType as EnvelopeTypeEnum, TemplateType as TemplateTypeEnum } from '@prisma/client';
import contentDisposition from 'content-disposition';
import { type Context } from 'hono';
import { getFileExtensionForMimeType } from '@documenso/lib/constants/upload';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
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,
type RecipientRole,
type 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 HandleEnvelopeItemFileRequestOptions = {
title: string;
type DocumentDataInput = {
type: DocumentDataType;
data: string;
initialData: string;
originalData?: string | null;
originalMimeType?: string | null;
};
type EnvelopeForPendingDownload = {
id: string;
status: DocumentStatus;
documentData: {
type: DocumentDataType;
data: string;
initialData: string;
originalData?: string | null;
originalMimeType?: string | null;
};
version: 'signed' | 'original';
isDownload: boolean;
context: Context<HonoEnv>;
internalVersion: number;
recipients: Array<{
role: RecipientRole;
signingStatus: SigningStatus;
}>;
};
/**
* Helper function to handle envelope item file requests (both view and download)
* 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
* full envelope (id, status, internalVersion, recipients) plus envelopeItemId to query fields.
*/
export const handleEnvelopeItemFileRequest = async ({
type HandleEnvelopeItemFileRequestOptions = {
title: string;
documentData: DocumentDataInput;
isDownload: boolean;
context: Context<HonoEnv>;
} & (
| {
version: 'signed' | 'original';
status: DocumentStatus;
}
| {
version: 'pending';
envelopeItemId: string;
envelope: EnvelopeForPendingDownload;
}
);
/**
* 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,
}: HandleEnvelopeItemFileRequestOptions) => {
}: StaticFileRequestOptions) => {
const shouldServeOriginalSourceFile =
version === 'original' &&
documentData.originalData &&
@@ -54,9 +99,7 @@ export const handleEnvelopeItemFileRequest = async ({
? documentData.data
: documentData.initialData;
const contentType = shouldServeOriginalSourceFile
? documentData.originalMimeType!
: 'application/pdf';
const contentType = shouldServeOriginalSourceFile ? documentData.originalMimeType! : 'application/pdf';
const etag = Buffer.from(sha256(documentDataToUse)).toString('hex');
@@ -112,6 +155,107 @@ export const handleEnvelopeItemFileRequest = async ({
return c.body(file);
};
type PendingFileRequestOptions = Extract<HandleEnvelopeItemFileRequestOptions, { version: 'pending' }>;
const handlePendingFileRequest = async ({
title,
envelopeItemId,
envelope,
documentData,
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,
},
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;
@@ -138,10 +282,7 @@ export const checkEnvelopeFileAccess = async ({
return true;
}
if (
envelopeType === EnvelopeTypeEnum.TEMPLATE &&
templateType === TemplateTypeEnum.ORGANISATION
) {
if (envelopeType === EnvelopeTypeEnum.TEMPLATE && templateType === TemplateTypeEnum.ORGANISATION) {
const orgAccess = await prisma.team.findFirst({
where: {
id: teamId,
+91 -60
View File
@@ -1,7 +1,3 @@
import { sValidator } from '@hono/standard-validator';
import type { Prisma } from '@prisma/client';
import { Hono } from 'hono';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
@@ -9,6 +5,9 @@ import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embeddin
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
import { prisma } from '@documenso/prisma';
import { sValidator } from '@hono/standard-validator';
import type { Prisma } from '@prisma/client';
import { Hono } from 'hono';
import type { HonoEnv } from '../../router';
import { checkEnvelopeFileAccess, handleEnvelopeItemFileRequest } from './files.helpers';
@@ -126,10 +125,7 @@ export const filesRoute = new Hono<HonoEnv>()
});
if (!hasAccess) {
return c.json(
{ error: 'User does not have access to the team that this envelope is associated with' },
403,
);
return c.json({ error: 'User does not have access to the team that this envelope is associated with' }, 403);
}
if (!envelopeItem.documentData) {
@@ -150,66 +146,101 @@ export const filesRoute = new Hono<HonoEnv>()
'/envelope/:envelopeId/envelopeItem/:envelopeItemId/download/:version?',
sValidator('param', ZGetEnvelopeItemFileDownloadRequestParamsSchema),
async (c) => {
const { envelopeId, envelopeItemId, version } = c.req.valid('param');
const logger = c.get('logger');
const session = await getOptionalSession(c);
try {
const { envelopeId, envelopeItemId, version } = c.req.valid('param');
if (!session.user) {
return c.json({ error: 'Unauthorized' }, 401);
}
const session = await getOptionalSession(c);
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
},
include: {
envelopeItems: {
where: {
id: envelopeItemId,
if (!session.user) {
return c.json({ error: 'Unauthorized' }, 401);
}
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
},
include: {
envelopeItems: {
where: {
id: envelopeItemId,
},
include: {
documentData: true,
},
},
include: {
documentData: true,
recipients: {
select: {
role: true,
signingStatus: true,
},
},
},
},
});
});
if (!envelope) {
return c.json({ error: 'Envelope not found' }, 404);
if (!envelope) {
return c.json({ error: 'Envelope not found' }, 404);
}
const [envelopeItem] = envelope.envelopeItems;
if (!envelopeItem) {
return c.json({ error: 'Envelope item 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,
);
}
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,
});
}
return await handleEnvelopeItemFileRequest({
...baseOptions,
version,
status: envelope.status,
});
} 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);
}
const [envelopeItem] = envelope.envelopeItems;
if (!envelopeItem) {
return c.json({ error: 'Envelope item 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,
);
}
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,
isDownload: true,
context: c,
});
},
)
.get(
+6 -15
View File
@@ -1,6 +1,5 @@
import { z } from 'zod';
import DocumentDataSchema from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema';
import { z } from 'zod';
export const ZUploadPdfRequestSchema = z.object({
file: z.instanceof(File),
@@ -32,36 +31,28 @@ export const ZGetEnvelopeItemFileRequestParamsSchema = z.object({
envelopeItemId: z.string().min(1),
});
export type TGetEnvelopeItemFileRequestParams = z.infer<
typeof ZGetEnvelopeItemFileRequestParamsSchema
>;
export type TGetEnvelopeItemFileRequestParams = z.infer<typeof ZGetEnvelopeItemFileRequestParamsSchema>;
export const ZGetEnvelopeItemFileRequestQuerySchema = z.object({
token: z.string().optional(),
});
export type TGetEnvelopeItemFileRequestQuery = z.infer<
typeof ZGetEnvelopeItemFileRequestQuerySchema
>;
export type TGetEnvelopeItemFileRequestQuery = z.infer<typeof ZGetEnvelopeItemFileRequestQuerySchema>;
export const ZGetEnvelopeItemFileTokenRequestParamsSchema = z.object({
token: z.string().min(1),
envelopeItemId: z.string().min(1),
});
export type TGetEnvelopeItemFileTokenRequestParams = z.infer<
typeof ZGetEnvelopeItemFileTokenRequestParamsSchema
>;
export type TGetEnvelopeItemFileTokenRequestParams = z.infer<typeof ZGetEnvelopeItemFileTokenRequestParamsSchema>;
export const ZGetEnvelopeItemFileDownloadRequestParamsSchema = z.object({
envelopeId: z.string().min(1),
envelopeItemId: z.string().min(1),
version: z.enum(['signed', 'original']).default('signed'),
version: z.enum(['signed', 'original', 'pending']).default('signed'),
});
export type TGetEnvelopeItemFileDownloadRequestParams = z.infer<
typeof ZGetEnvelopeItemFileDownloadRequestParamsSchema
>;
export type TGetEnvelopeItemFileDownloadRequestParams = z.infer<typeof ZGetEnvelopeItemFileDownloadRequestParamsSchema>;
export const ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema = z.object({
token: z.string().min(1),
@@ -1,10 +1,9 @@
import { prisma } from '@documenso/prisma';
import { sValidator } from '@hono/standard-validator';
import type { Prisma } from '@prisma/client';
import { Hono } from 'hono';
import { z } from 'zod';
import { prisma } from '@documenso/prisma';
import type { HonoEnv } from '../../../router';
import { handleEnvelopeItemPdfRequest } from './get-envelope-item-pdf';
@@ -1,14 +1,13 @@
import { sValidator } from '@hono/standard-validator';
import type { DocumentData, EnvelopeItem } from '@prisma/client';
import { type Context, Hono } from 'hono';
import { z } from 'zod';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import type { DocumentDataVersion } from '@documenso/lib/types/document';
import { sha256 } from '@documenso/lib/universal/crypto';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { prisma } from '@documenso/prisma';
import { sValidator } from '@hono/standard-validator';
import type { DocumentData, EnvelopeItem } from '@prisma/client';
import { type Context, Hono } from 'hono';
import { z } from 'zod';
import type { HonoEnv } from '../../../router';
import { checkEnvelopeFileAccess } from '../files.helpers';
+3 -7
View File
@@ -1,10 +1,6 @@
import type { Context, Next } from 'hono';
import { extractSessionCookieFromHeaders } from '@documenso/auth/server/lib/session/session-cookies';
import {
type RequestMetadata,
extractRequestMetadata,
} from '@documenso/lib/universal/extract-request-metadata';
import { extractRequestMetadata, type RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import type { Context, Next } from 'hono';
export type AppContext = {
requestMetadata: RequestMetadata;
@@ -64,4 +60,4 @@ const isPageRequest = (request: Request) => {
* - Urls that start with /api
* - Urls that start with _
*/
const blacklistedPathsRegex = new RegExp('^/api/|^/__');
const blacklistedPathsRegex = /^\/api\/|^\/__/;
+33
View File
@@ -0,0 +1,33 @@
import { getContext } from 'hono/context-storage';
import type { AppLoadContext } from 'react-router';
import type { HonoEnv } from './router';
import { CSP_NONCE_KEY } from './security-headers';
/**
* Augment React Router's `AppLoadContext` so loaders, actions, and
* `entry.server` can access fields by name without casts.
*/
declare module 'react-router' {
interface AppLoadContext {
/**
* Per-request CSP nonce. Populated by `securityHeadersMiddleware` and surfaced here
* so it can be threaded into `<ServerRouter nonce>` and root loader
* data, which then feeds `<Scripts>`, `<Links>`, etc.
*/
nonce: string;
}
}
/**
* Builds the React Router `AppLoadContext` for both dev (vite plugin) and
* production (`hono-react-router-adapter/node`).
*
* The Hono context isn't passed directly by the adapter, so we read it via
* `hono/context-storage`, which is enabled in `server/router.ts`.
*/
export const getLoadContext = (): AppLoadContext => {
const nonce = getContext<HonoEnv>().var[CSP_NONCE_KEY] ?? '';
return { nonce };
};
+2 -1
View File
@@ -10,6 +10,7 @@ import { serve } from '@hono/node-server';
import { serveStatic } from '@hono/node-server/serve-static';
import handle from 'hono-react-router-adapter/node';
import { getLoadContext } from './hono/server/load-context.js';
import server from './hono/server/router.js';
import * as build from './index.js';
@@ -28,7 +29,7 @@ server.use(
}),
);
const handler = handle(build, server);
const handler = handle(build, server, { getLoadContext });
const port = parseInt(process.env.PORT || '3000', 10);
+1 -2
View File
@@ -1,8 +1,7 @@
import { AppDebugger } from '@documenso/lib/utils/debugger';
import type { Context, Next } from 'hono';
import { setCookie } from 'hono/cookie';
import { AppDebugger } from '@documenso/lib/utils/debugger';
import { handleRedirects } from './redirects';
const debug = new AppDebugger('Middleware');
+22 -7
View File
@@ -1,10 +1,3 @@
import { Hono } from 'hono';
import { contextStorage } from 'hono/context-storage';
import { cors } from 'hono/cors';
import type { RequestIdVariables } from 'hono/request-id';
import { requestId } from 'hono/request-id';
import type { Logger } from 'pino';
import { tsRestHonoApp } from '@documenso/api/hono';
import { auth } from '@documenso/auth/server';
import { jobsClient } from '@documenso/lib/jobs/client';
@@ -23,19 +16,32 @@ import { migrateLegacyServiceAccount } from '@documenso/lib/server-only/user/ser
import { env } from '@documenso/lib/utils/env';
import { logger } from '@documenso/lib/utils/logger';
import { openApiDocument } from '@documenso/trpc/server/open-api';
import { Hono } from 'hono';
import { contextStorage } from 'hono/context-storage';
import { cors } from 'hono/cors';
import type { RequestIdVariables } from 'hono/request-id';
import { requestId } from 'hono/request-id';
import type { Logger } from 'pino';
import { aiRoute } from './api/ai/route';
import { downloadRoute } from './api/download/download';
import { filesRoute } from './api/files/files';
import { type AppContext, appContext } from './context';
import { appMiddleware } from './middleware';
import { securityHeadersMiddleware } from './security-headers';
import { openApiTrpcServerHandler } from './trpc/hono-trpc-open-api';
import { reactRouterTrpcServer } from './trpc/hono-trpc-remix';
// Re-export so the rollup build (entry: server/router.ts) bundles
// load-context.ts. server/main.js imports getLoadContext from the rolled-up
// output to wire it into the React Router adapter.
export { getLoadContext } from './load-context';
export interface HonoEnv {
Variables: RequestIdVariables & {
context: AppContext;
logger: Logger;
cspNonce: string;
};
}
@@ -56,6 +62,15 @@ const fileRateLimitMiddleware = createRateLimitMiddleware(fileUploadRateLimit);
app.use(contextStorage());
app.use(appContext);
/**
* Emit response security headers (CSP with per-request nonce, plus
* Referrer-Policy and X-Content-Type-Options on embed routes). Must run
* after `contextStorage()` so the nonce is readable via `getContext()` from
* `getLoadContext`, and before the React Router handler so the response
* carries the header.
*/
app.use(securityHeadersMiddleware);
/**
* RR7 app middleware.
*/
+180
View File
@@ -0,0 +1,180 @@
import { createMiddleware } from 'hono/factory';
import type { HonoEnv } from './router';
/**
* Paths that never render HTML and therefore do not need security headers.
*
* Browsers ignore CSP and friends on non-document responses, so we skip
* them to keep API/manifest/asset responses clean.
*/
const NON_PAGE_PATH_REGEX = /^(\/api\/|\/ingest\/|\/__manifest|\/assets\/|\/apple-.*|\/favicon.*)/;
/**
* Embed routes serve our white-label embed UI. Customers iframe these from
* arbitrary origins, so `frame-ancestors` must be wildcard, and customer-
* supplied CSS is injected at runtime as `<style>` elements which means
* `style-src-elem` cannot be nonce-restricted on these routes.
*/
const EMBED_PATH_REGEX = /^\/embed(\/|\.data|$)/;
/**
* Non-`/embed` page routes that customers iframe directly, plus the auth
* pages reachable from inside an embed iframe during the
* reauth-as-different-account flow.
*
* Signing routes (`/sign/:token`, `/d/:token`):
* Some customer integrations embed these URLs directly (without going
* through `EmbedSignDocument`). Without `frame-ancestors *` here, those
* integrations break with a "refused to connect" iframe error.
*
* Auth routes (`/signin`, `/forgot-password`, `/check-email`,
* `/unverified-account`):
* `apps/remix/app/components/general/document-signing/document-signing-auth-account.tsx`
* does `window.location.href = '/signin?...'` inside the iframe when the
* user needs to sign out and sign back in as a different account, and
* `<SignInForm>` links/navigates to `/forgot-password`, `/check-email`, and
* `/unverified-account` from there. Without `frame-ancestors *` on these
* routes, the customer's iframe gets blocked the moment the user clicks
* "Login" in the reauth dialog.
*
* These routes still get the strict nonced `script-src`/`style-src-elem`
* policy — only `frame-ancestors` is relaxed. The `(\/|\.data|$)` tail
* keeps `/sign` from matching `/signin`/`/signup` and `/d` from matching
* `/dashboard`.
*/
const FRAMEABLE_PATH_REGEX = /^\/(signin|forgot-password|check-email|unverified-account|sign|d)(\/|\.data|$)/;
/**
* Hono context variable name where the per-request CSP nonce is stashed.
*
* Read by `getLoadContext` (server/load-context.ts) so the nonce can be
* threaded into React Router's `<ServerRouter nonce>` and surfaced in the
* root loader for use by `<Scripts>`, `<Links>`, etc.
*/
export const CSP_NONCE_KEY = 'cspNonce' as const;
const generateNonce = () => {
const buf = new Uint8Array(16);
crypto.getRandomValues(buf);
let binary = '';
for (let i = 0; i < buf.length; i++) {
binary += String.fromCharCode(buf[i]);
}
return btoa(binary);
};
type CspPathKind = 'embed' | 'frameable' | 'default';
const buildCspHeader = ({ nonce, kind }: { nonce: string; kind: CspPathKind }) => {
// `'self'` is included alongside `'strict-dynamic'` as a fallback for
// browsers that don't understand `'strict-dynamic'`. Modern browsers
// ignore `'self'` (and other host/scheme sources) when `'strict-dynamic'`
// is present.
const directives = [
`base-uri 'self'`,
`object-src 'none'`,
`form-action 'self'`,
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
// PDF.js (apps/remix/app/components/general/pdf-viewer/pdf-viewer.tsx)
// creates a Web Worker via `new Worker(url)`. `'strict-dynamic'` does
// not reliably propagate to worker creation across browsers, and
// without `worker-src` the browser falls back to `script-src` which
// would block the worker. `blob:` covers libs that inline workers.
`worker-src 'self' blob:`,
// Inline `style=""` attributes cannot be nonced or hashed (CSP3 has no
// mechanism for it), and React inline styles, framer-motion, react-rnd,
// konva, etc. all rely on them. `'unsafe-inline'` for attributes is
// industry standard and does not weaken `style-src-elem`.
`style-src-attr 'unsafe-inline'`,
];
// Embeds inject customer-supplied CSS via runtime-created `<style>`
// elements (see apps/remix/app/utils/css-vars.ts). Nonce-stamping those
// would be brittle for white-label customers, so we accept
// `'unsafe-inline'` on the embed scope only. Frameable (auth/signing)
// pages do NOT load customer CSS and keep the strict nonced policy.
if (kind === 'embed') {
directives.push(`style-src-elem 'self' 'unsafe-inline'`);
} else {
directives.push(`style-src-elem 'self' 'nonce-${nonce}'`);
}
// Embed, signing, and auth routes are all reachable from inside a
// customer's iframe and therefore need `frame-ancestors *`. Every other
// page gets clickjacking protection.
if (kind === 'embed' || kind === 'frameable') {
directives.push(`frame-ancestors *`);
} else {
directives.push(`frame-ancestors 'self'`);
}
return directives.join('; ');
};
const classifyPath = (path: string): CspPathKind => {
if (EMBED_PATH_REGEX.test(path)) {
return 'embed';
}
if (FRAMEABLE_PATH_REGEX.test(path)) {
return 'frameable';
}
return 'default';
};
/**
* Owns response security headers for page responses:
* `Content-Security-Policy`, plus `Referrer-Policy` and
* `X-Content-Type-Options` on embed routes (preserved from the per-route
* `headers()` export this middleware replaces).
*
* Generates a per-request CSP nonce and stashes it on the Hono context so
* `getLoadContext` (server/load-context.ts) can thread it into React
* Router for `<ServerRouter nonce>` and `<Scripts nonce>` etc.
*
* Path-aware classification:
* - `embed` — wildcard `frame-ancestors`, `'unsafe-inline'`
* style-src-elem (white-label CSS injection), strict
* nonced script-src.
* - `frameable` — wildcard `frame-ancestors` only; needed because the
* embed reauth flow redirects the iframe to `/signin` etc,
* and because some customers iframe `/sign/:token` and
* `/d/:token` directly without using `EmbedSignDocument`.
* Strict nonced script-src and style-src-elem otherwise.
* - default — strict nonced script-src and style-src-elem,
* `frame-ancestors 'self'` for clickjacking protection.
*/
export const securityHeadersMiddleware = createMiddleware<HonoEnv>(async (c, next) => {
const nonce = generateNonce();
c.set(CSP_NONCE_KEY, nonce);
await next();
const path = c.req.path;
if (NON_PAGE_PATH_REGEX.test(path)) {
return;
}
const kind = classifyPath(path);
c.res.headers.set('Content-Security-Policy', buildCspHeader({ nonce, kind }));
// Preserved from the per-route `headers()` export in
// apps/remix/app/routes/embed+/_v0+/_layout.tsx, which has been removed.
if (kind === 'embed') {
if (!c.res.headers.has('Referrer-Policy')) {
c.res.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
}
if (!c.res.headers.has('X-Content-Type-Options')) {
c.res.headers.set('X-Content-Type-Options', 'nosniff');
}
}
});
+2 -6
View File
@@ -1,20 +1,16 @@
import type { Context } from 'hono';
import { API_V2_BETA_URL, API_V2_URL } from '@documenso/lib/constants/app';
import { AppError, genericErrorCodeToTrpcErrorCodeMap } from '@documenso/lib/errors/app-error';
import { createTrpcContext } from '@documenso/trpc/server/context';
import { appRouter } from '@documenso/trpc/server/router';
import { createOpenApiFetchHandler } from '@documenso/trpc/utils/openapi-fetch-handler';
import { handleTrpcRouterError } from '@documenso/trpc/utils/trpc-error-handler';
import type { Context } from 'hono';
type OpenApiTrpcServerHandlerOptions = {
isBeta: boolean;
};
export const openApiTrpcServerHandler = async (
c: Context,
{ isBeta }: OpenApiTrpcServerHandlerOptions,
) => {
export const openApiTrpcServerHandler = async (c: Context, { isBeta }: OpenApiTrpcServerHandlerOptions) => {
return createOpenApiFetchHandler<typeof appRouter>({
endpoint: isBeta ? API_V2_BETA_URL : API_V2_URL,
router: appRouter,
+1 -2
View File
@@ -1,8 +1,7 @@
import { trpcServer } from '@hono/trpc-server';
import { createTrpcContext } from '@documenso/trpc/server/context';
import { appRouter } from '@documenso/trpc/server/router';
import { handleTrpcRouterError } from '@documenso/trpc/utils/trpc-error-handler';
import { trpcServer } from '@hono/trpc-server';
/**
* Trpc server for internal routes like /api/trpc/*