Merge branch 'main' into feat/add-pdf-image-renderer

This commit is contained in:
David Nguyen
2026-02-24 18:51:04 +11:00
179 changed files with 14753 additions and 3181 deletions
@@ -29,6 +29,7 @@ import {
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapSecondaryIdToDocumentId, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
import { assertRecipientNotExpired } from '../../utils/recipients';
import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { isRecipientAuthorized } from './is-recipient-authorized';
@@ -94,6 +95,8 @@ export const completeDocumentWithToken = async ({
const [recipient] = envelope.recipients;
assertRecipientNotExpired(recipient);
if (recipient.signingStatus === SigningStatus.SIGNED) {
throw new Error(`Recipient ${recipient.id} has already signed`);
}
@@ -1,4 +1,4 @@
import { EnvelopeType, SigningStatus } from '@prisma/client';
import { DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
import { jobs } from '@documenso/lib/jobs/client';
import { prisma } from '@documenso/prisma';
@@ -9,6 +9,7 @@ import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapSecondaryIdToDocumentId, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
import { assertRecipientNotExpired } from '../../utils/recipients';
export type RejectDocumentWithTokenOptions = {
token: string;
@@ -42,6 +43,14 @@ export async function rejectDocumentWithToken({
});
}
if (envelope.status !== DocumentStatus.PENDING) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Document ${envelope.id} must be pending to reject`,
});
}
assertRecipientNotExpired(recipient);
// Update the recipient status to rejected
const [updatedRecipient] = await prisma.$transaction([
prisma.recipient.update({
@@ -11,6 +11,7 @@ import {
import { mailer } from '@documenso/email/mailer';
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
import { resolveExpiresAt } from '@documenso/lib/constants/envelope-expiration';
import {
RECIPIENT_ROLES_DESCRIPTION,
RECIPIENT_ROLE_TO_EMAIL_TYPE,
@@ -94,11 +95,31 @@ export const resendDocument = async ({
throw new Error('Can not send completed document');
}
// Refresh the expiresAt on each resent recipient.
const expiresAt = resolveExpiresAt(envelope.documentMeta?.envelopeExpirationPeriod ?? null);
const recipientsToRemind = envelope.recipients.filter(
(recipient) =>
recipients.includes(recipient.id) && recipient.signingStatus === SigningStatus.NOT_SIGNED,
recipients.includes(recipient.id) &&
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
recipient.role !== RecipientRole.CC,
);
// Extend the expiration deadline for recipients being resent.
if (expiresAt && recipientsToRemind.length > 0) {
await prisma.recipient.updateMany({
where: {
id: {
in: recipientsToRemind.map((r) => r.id),
},
},
data: {
expiresAt,
expirationNotifiedAt: null,
},
});
}
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
envelope.documentMeta,
).recipientSigningRequest;
@@ -10,6 +10,7 @@ import {
WebhookTriggerEvents,
} from '@prisma/client';
import { resolveExpiresAt } from '@documenso/lib/constants/envelope-expiration';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
@@ -257,6 +258,28 @@ export const sendDocument = async ({
});
}
const expiresAt = resolveExpiresAt(envelope.documentMeta?.envelopeExpirationPeriod ?? null);
// Set expiresAt on each recipient that hasn't already signed/rejected.
// Exclude CC recipients since they don't sign and shouldn't be subject to expiry.
if (expiresAt) {
await tx.recipient.updateMany({
where: {
envelopeId: envelope.id,
signingStatus: {
notIn: [SigningStatus.SIGNED, SigningStatus.REJECTED],
},
role: {
not: RecipientRole.CC,
},
},
data: {
expiresAt,
expirationNotifiedAt: null,
},
});
}
return await tx.envelope.update({
where: {
id: envelope.id,
@@ -163,6 +163,7 @@ export const getEnvelopeForDirectTemplateSigning = async ({
isRecipientsTurn: true,
isCompleted: false,
isRejected: false,
isExpired: false,
sender,
settings: {
includeSenderDetails: settings.includeSenderDetails,
@@ -13,6 +13,7 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAuthMethods } from '../../types/document-auth';
import { ZEnvelopeFieldSchema, ZFieldSchema } from '../../types/field';
import { ZRecipientLiteSchema } from '../../types/recipient';
import { isRecipientExpired } from '../../utils/recipients';
import { isRecipientAuthorized } from '../document/is-recipient-authorized';
import { getTeamSettings } from '../team/get-team-settings';
@@ -56,7 +57,9 @@ export const ZEnvelopeForSigningResponse = z.object({
email: true,
name: true,
documentDeletedAt: true,
expired: true,
expired: true, //!: deprecated Not in use. To be removed in a future migration.
expiresAt: true,
expirationNotifiedAt: true,
signedAt: true,
authOptions: true,
signingOrder: true,
@@ -102,7 +105,8 @@ export const ZEnvelopeForSigningResponse = z.object({
email: true,
name: true,
documentDeletedAt: true,
expired: true,
expiresAt: true,
expirationNotifiedAt: true,
signedAt: true,
authOptions: true,
token: true,
@@ -126,6 +130,7 @@ export const ZEnvelopeForSigningResponse = z.object({
isCompleted: z.boolean(),
isRejected: z.boolean(),
isExpired: z.boolean(),
isRecipientsTurn: z.boolean(),
sender: z.object({
@@ -291,6 +296,7 @@ export const getEnvelopeForRecipientSigning = async ({
isRejected:
recipient.signingStatus === SigningStatus.REJECTED ||
envelope.status === DocumentStatus.REJECTED,
isExpired: isRecipientExpired(recipient),
sender,
settings: {
includeSenderDetails: settings.includeSenderDetails,
@@ -3,6 +3,7 @@ import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { assertRecipientNotExpired } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma';
export type RemovedSignedFieldWithTokenOptions = {
@@ -56,6 +57,8 @@ export const removeSignedFieldWithToken = async ({
throw new Error(`Document ${envelope.id} must be pending`);
}
assertRecipientNotExpired(recipient);
if (
recipient?.signingStatus === SigningStatus.SIGNED ||
field.recipient.signingStatus === SigningStatus.SIGNED
@@ -25,6 +25,7 @@ import {
} from '../../types/field-meta';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { assertRecipientNotExpired } from '../../utils/recipients';
import { validateFieldAuth } from '../document/validate-field-auth';
export type SignFieldWithTokenOptions = {
@@ -108,6 +109,8 @@ export const signFieldWithToken = async ({
throw new Error(`Document ${envelope.id} must be pending for signing`);
}
assertRecipientNotExpired(recipient);
if (
recipient.signingStatus === SigningStatus.SIGNED ||
field.recipient.signingStatus === SigningStatus.SIGNED
@@ -1,7 +1,7 @@
import { PDF, rgb } from '@libpdf/core';
import type { FieldType, Recipient } from '@prisma/client';
import { type TFieldAndMeta, ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { type TFieldAndMeta, ZEnvelopeFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { parseFieldMetaFromPlaceholder, parseFieldTypeFromPlaceholder } from './helpers';
@@ -117,7 +117,7 @@ export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<Placehold
const parsedFieldMeta = parseFieldMetaFromPlaceholder(rawFieldMeta, fieldType);
const fieldAndMeta: TFieldAndMeta = ZFieldAndMetaSchema.parse({
const fieldAndMeta: TFieldAndMeta = ZEnvelopeFieldAndMetaSchema.parse({
type: fieldType,
fieldMeta: parsedFieldMeta,
});
@@ -1,6 +1,7 @@
import { PDF } from '@libpdf/core';
import { i18n } from '@lingui/core';
import { type TDocumentAuditLog } from '@documenso/lib/types/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { ZSupportedLanguageCodeSchema } from '../../constants/i18n';
@@ -12,15 +13,24 @@ import { renderAuditLogs } from './render-audit-logs';
type GenerateAuditLogPdfOptions = GenerateCertificatePdfOptions & {
envelopeItems: string[];
additionalAuditLogs?: TDocumentAuditLog[];
};
export const generateAuditLogPdf = async (options: GenerateAuditLogPdfOptions) => {
const { envelope, envelopeOwner, envelopeItems, recipients, language, pageWidth, pageHeight } =
options;
const {
envelope,
envelopeOwner,
envelopeItems,
recipients,
language,
pageWidth,
pageHeight,
additionalAuditLogs = [],
} = options;
const documentLanguage = ZSupportedLanguageCodeSchema.parse(language);
const [organisationClaim, auditLogs, messages] = await Promise.all([
const [organisationClaim, partialAuditLogs, messages] = await Promise.all([
getOrganisationClaimByTeamId({ teamId: envelope.teamId }),
getAuditLogs(envelope.id),
getTranslations(documentLanguage),
@@ -31,6 +41,8 @@ export const generateAuditLogPdf = async (options: GenerateAuditLogPdfOptions) =
messages,
});
const auditLogs: TDocumentAuditLog[] = [...additionalAuditLogs, ...partialAuditLogs];
const auditLogPages = await renderAuditLogs({
envelope,
envelopeOwner,
@@ -16,7 +16,14 @@ import { getOrganisationClaimByTeamId } from '../organisation/get-organisation-c
import { renderCertificate } from './render-certificate';
export type GenerateCertificatePdfOptions = {
envelope: Envelope & {
/**
* Note: completedAt is not included since it's not real at this point in time.
*
* If we actually need it here in the future, we will need to preserve the
* completedAt value and pass it to the final `envelope.update` function when
* the document is initially sealed.
*/
envelope: Omit<Envelope, 'completedAt'> & {
documentMeta: DocumentMeta;
};
envelopeOwner: {
@@ -1,4 +1,10 @@
import type { PDFPage } from '@cantoo/pdf-lib';
import type { PDF } from '@libpdf/core';
import { PDF_SIZE_A4_72PPI } from '../../constants/pdf';
const MIN_CERT_PAGE_WIDTH = 300;
const MIN_CERT_PAGE_HEIGHT = 300;
/**
* Gets the effective page size for PDF operations.
@@ -16,3 +22,20 @@ export const getPageSize = (page: PDFPage) => {
return cropBox;
};
export const getLastPageDimensions = (pdfDoc: PDF): { width: number; height: number } => {
const lastPage = pdfDoc.getPage(pdfDoc.getPageCount() - 1);
if (!lastPage) {
return PDF_SIZE_A4_72PPI;
}
const width = Math.round(lastPage.width);
const height = Math.round(lastPage.height);
if (width < MIN_CERT_PAGE_WIDTH || height < MIN_CERT_PAGE_HEIGHT) {
return PDF_SIZE_A4_72PPI;
}
return { width, height };
};
@@ -30,7 +30,7 @@ export type AuditLogRecipient = {
};
type GenerateAuditLogsOptions = {
envelope: Envelope & {
envelope: Omit<Envelope, 'completedAt'> & {
documentMeta: DocumentMeta;
};
envelopeItems: string[];
@@ -168,7 +168,7 @@ const renderVerticalLabelAndText = (options: RenderVerticalLabelAndTextOptions)
};
type RenderOverviewCardOptions = {
envelope: Envelope & {
envelope: Omit<Envelope, 'completedAt'> & {
documentMeta: DocumentMeta;
};
envelopeItems: string[];
@@ -78,6 +78,7 @@ const getDevice = (userAgent?: string | null): string => {
const textMutedForegroundLight = '#929DAE';
const textForeground = '#000';
const textMutedForeground = '#64748B';
const textRejectedRed = '#dc2626';
const textBase = 10;
const textSm = 9;
const textXs = 8;
@@ -97,6 +98,8 @@ type RenderLabelAndTextOptions = {
text: string;
width: number;
y?: number;
labelFill?: string;
valueFill?: string;
};
const renderLabelAndText = (options: RenderLabelAndTextOptions) => {
@@ -106,13 +109,16 @@ const renderLabelAndText = (options: RenderLabelAndTextOptions) => {
y,
});
const labelFill = options.labelFill ?? textMutedForeground;
const valueFill = options.valueFill ?? textMutedForeground;
const label = new Konva.Text({
x: 0,
y: 0,
text: `${options.label}: `,
fontStyle: fontMedium,
fontFamily: 'Inter',
fill: textMutedForeground,
fill: labelFill,
fontSize: textSm,
});
@@ -124,7 +130,7 @@ const renderLabelAndText = (options: RenderLabelAndTextOptions) => {
width: width - label.width(),
fontFamily: 'Inter',
text: options.text,
fill: textMutedForeground,
fill: valueFill,
wrap: 'char',
fontSize: textSm,
});
@@ -269,6 +275,8 @@ const renderColumnTwo = (options: RenderColumnOptions) => {
const columnWidth = width - columnPadding;
const isRejected = Boolean(recipient.logs.rejected);
if (recipient.signatureField?.secondaryId) {
// Signature container with green border
const signatureContainer = new Konva.Group({ x: 0, y: 0 });
@@ -313,7 +321,10 @@ const renderColumnTwo = (options: RenderColumnOptions) => {
signatureContainer.add(typedSig);
}
column.add(signatureContainer);
// Do not add the signature container for rejected recipients.
if (!isRejected) {
column.add(signatureContainer);
}
const signatureHeight = Math.max(signatureContainer.getClientRect().height, minSignatureHeight);
@@ -342,7 +353,7 @@ const renderColumnTwo = (options: RenderColumnOptions) => {
// Signature ID
const sigIdLabel = new Konva.Text({
x: 0,
y: signatureHeight + 10,
y: isRejected ? 0 : signatureHeight + 10,
text: `${i18n._(msg`Signature ID`)}:`,
fill: textMutedForeground,
width: columnWidth,
@@ -376,9 +387,11 @@ const renderColumnTwo = (options: RenderColumnOptions) => {
column.add(naText);
}
const relevantLog = isRejected ? recipient.logs.rejected : recipient.logs.completed;
const ipLabelAndText = renderLabelAndText({
label: i18n._(msg`IP Address`),
text: recipient.logs.completed?.ipAddress ?? i18n._(msg`Unknown`),
text: relevantLog?.ipAddress ?? i18n._(msg`Unknown`),
width,
y: column.getClientRect().height + 6,
});
@@ -386,7 +399,7 @@ const renderColumnTwo = (options: RenderColumnOptions) => {
const deviceLabelAndText = renderLabelAndText({
label: i18n._(msg`Device`),
text: getDevice(recipient.logs.completed?.userAgent),
text: getDevice(relevantLog?.userAgent),
width,
y: column.getClientRect().height + 6,
});
@@ -400,7 +413,14 @@ const renderColumnThree = (options: RenderColumnOptions) => {
const column = new Konva.Group();
const itemsToRender = [
type DetailItem = {
label: string;
value: string;
labelFill?: string;
valueFill?: string;
};
const itemsToRender: DetailItem[] = [
{
label: i18n._(msg`Sent`),
value: recipient.logs.emailed
@@ -429,6 +449,8 @@ const renderColumnThree = (options: RenderColumnOptions) => {
value: DateTime.fromJSDate(recipient.logs.rejected.createdAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)'),
labelFill: textRejectedRed,
valueFill: textRejectedRed,
});
} else {
itemsToRender.push({
@@ -459,6 +481,8 @@ const renderColumnThree = (options: RenderColumnOptions) => {
text: item.value,
width,
y: column.getClientRect().height + (index === 0 ? 0 : 8),
labelFill: item.labelFill,
valueFill: item.valueFill,
});
column.add(labelAndText);
}
@@ -0,0 +1,95 @@
import type { Context } from 'hono';
import type { MiddlewareHandler } from 'hono/types';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { getIpAddress } from '../../universal/get-ip-address';
import type { RateLimitCheckResult } from './rate-limit';
import type { createRateLimit } from './rate-limit';
/**
* Set rate limit response headers on a Hono context.
*/
const setRateLimitHeaders = (c: Context, result: RateLimitCheckResult) => {
c.header('X-RateLimit-Limit', String(result.limit));
c.header('X-RateLimit-Remaining', String(result.remaining));
c.header('X-RateLimit-Reset', String(Math.ceil(result.reset.getTime() / 1000)));
};
/**
* Create a Hono middleware that applies rate limiting to a route.
*
* Uses IP address for identification. Optionally accepts an identifier
* function for per-user/per-entity limiting.
*/
export const createRateLimitMiddleware = (
limiter: ReturnType<typeof createRateLimit>,
options?: { identifierFn?: (c: Context) => string | undefined },
): MiddlewareHandler => {
return async (c, next) => {
let ip: string;
try {
ip = getIpAddress(c.req.raw);
} catch {
ip = 'unknown';
}
const identifier = options?.identifierFn?.(c);
const result = await limiter.check({ ip, identifier });
setRateLimitHeaders(c, result);
if (result.isLimited) {
c.header(
'Retry-After',
String(Math.max(1, Math.ceil((result.reset.getTime() - Date.now()) / 1000))),
);
return c.json({ error: 'Too many requests, please try again later.' }, 429);
}
await next();
};
};
/**
* Helper for inline rate limit checks in Hono auth routes.
*
* Returns a 429 Response with rate limit headers if limited, or `null` if allowed.
*/
export const rateLimitResponse = (c: Context, result: RateLimitCheckResult): Response | null => {
setRateLimitHeaders(c, result);
if (result.isLimited) {
c.header(
'Retry-After',
String(Math.max(1, Math.ceil((result.reset.getTime() - Date.now()) / 1000))),
);
return c.json({ error: 'Too many requests, please try again later.' }, 429);
}
return null;
};
/**
* Helper for inline rate limit checks in tRPC routes.
*
* Throws an AppError with TOO_MANY_REQUESTS code if limited.
*/
export const assertRateLimit = (result: RateLimitCheckResult): void => {
if (result.isLimited) {
const retryAfter = String(Math.max(1, Math.ceil((result.reset.getTime() - Date.now()) / 1000)));
throw new AppError(AppErrorCode.TOO_MANY_REQUESTS, {
message: 'Too many requests, please try again later.',
headers: {
'X-RateLimit-Limit': String(result.limit),
'X-RateLimit-Remaining': String(result.remaining),
'X-RateLimit-Reset': String(Math.ceil(result.reset.getTime() / 1000)),
'Retry-After': retryAfter,
},
});
}
};
@@ -0,0 +1,197 @@
import { prisma } from '@documenso/prisma';
import { logger } from '../../utils/logger';
type WindowUnit = 's' | 'm' | 'h' | 'd';
type WindowStr = `${number}${WindowUnit}`;
type RateLimitConfig = {
action: string;
max: number;
globalMax?: number;
window: WindowStr;
};
type CheckParams = {
ip: string;
identifier?: string;
};
export type RateLimitCheckResult = {
isLimited: boolean;
remaining: number;
limit: number;
reset: Date;
};
/**
* Parse window string (e.g., '1h', '15m', '30s') to milliseconds.
*/
export const parseWindow = (window: WindowStr): number => {
const value = parseInt(window.slice(0, -1), 10);
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const unit = window.slice(-1) as WindowUnit;
const multipliers: Record<WindowUnit, number> = {
s: 1000,
m: 60 * 1000,
h: 60 * 60 * 1000,
d: 24 * 60 * 60 * 1000,
};
return value * multipliers[unit];
};
/**
* Compute the current time bucket for the given window size.
*/
export const getBucket = (windowMs: number): Date => {
const now = Date.now();
return new Date(now - (now % windowMs));
};
/**
* Create a rate limiter with the given configuration.
*
* Uses bucketed counters in the database for distributed rate limiting
* across multiple instances. Each check atomically increments the counter
* and returns the new count.
*/
export const createRateLimit = (config: RateLimitConfig) => {
const windowMs = parseWindow(config.window);
return {
async check(params: CheckParams): Promise<RateLimitCheckResult> {
const bucket = getBucket(windowMs);
const reset = new Date(bucket.getTime() + windowMs);
const ipLimit = config.globalMax ?? config.max;
if (process.env.DANGEROUS_BYPASS_RATE_LIMITS === 'true') {
return {
isLimited: false,
remaining: ipLimit,
limit: ipLimit,
reset,
};
}
try {
// Always upsert the IP counter.
const ipResult = await prisma.rateLimit.upsert({
where: {
key_action_bucket: {
key: `ip:${params.ip}`,
action: config.action,
bucket,
},
},
create: {
key: `ip:${params.ip}`,
action: config.action,
bucket,
count: 1,
},
update: {
count: { increment: 1 },
},
});
// Check IP against globalMax if set, or against max if no identifier is provided.
let ipCheckLimit = config.globalMax;
if (!params.identifier) {
ipCheckLimit = config.max;
}
if (ipCheckLimit && ipResult.count > ipCheckLimit) {
logger.warn({
msg: 'Rate limit exceeded',
action: config.action,
keyType: 'ip',
key: params.ip,
count: ipResult.count,
limit: ipCheckLimit,
});
return {
isLimited: true,
remaining: 0,
limit: ipCheckLimit,
reset,
};
}
// Upsert the identifier counter if provided.
if (params.identifier) {
const identifierResult = await prisma.rateLimit.upsert({
where: {
key_action_bucket: {
key: `id:${params.identifier}`,
action: config.action,
bucket,
},
},
create: {
key: `id:${params.identifier}`,
action: config.action,
bucket,
count: 1,
},
update: {
count: { increment: 1 },
},
});
if (identifierResult.count > config.max) {
logger.warn({
msg: 'Rate limit exceeded',
action: config.action,
keyType: 'identifier',
key: params.identifier,
count: identifierResult.count,
limit: config.max,
});
return {
isLimited: true,
remaining: 0,
limit: config.max,
reset,
};
}
return {
isLimited: false,
remaining: Math.max(0, config.max - identifierResult.count),
limit: config.max,
reset,
};
}
return {
isLimited: false,
remaining: Math.max(0, ipLimit - ipResult.count),
limit: ipLimit,
reset,
};
} catch (error) {
// Fail-open: if the rate limit DB query fails, allow the request through.
logger.error({
msg: 'Rate limit check failed, failing open',
action: config.action,
error,
});
const limit = params.identifier ? config.max : ipLimit;
return {
isLimited: false,
remaining: limit,
limit,
reset,
};
}
},
};
};
@@ -0,0 +1,99 @@
import { createRateLimit } from './rate-limit';
// ---- Auth (Tier 1 - Critical, sends emails) ----
export const signupRateLimit = createRateLimit({
action: 'auth.signup',
max: 10,
window: '1h',
});
export const forgotPasswordRateLimit = createRateLimit({
action: 'auth.forgot-password',
max: 3,
globalMax: 20,
window: '1h',
});
export const resendVerifyEmailRateLimit = createRateLimit({
action: 'auth.resend-verify-email',
max: 3,
globalMax: 20,
window: '1h',
});
export const request2FAEmailRateLimit = createRateLimit({
action: 'auth.request-2fa-email',
max: 5,
globalMax: 20,
window: '15m',
});
// ---- Auth (Tier 2 - Unauthenticated) ----
export const loginRateLimit = createRateLimit({
action: 'auth.login',
max: 10,
globalMax: 50,
window: '15m',
});
export const resetPasswordRateLimit = createRateLimit({
action: 'auth.reset-password',
max: 5,
globalMax: 20,
window: '1h',
});
export const verifyEmailRateLimit = createRateLimit({
action: 'auth.verify-email',
max: 5,
globalMax: 20,
window: '15m',
});
export const passkeyRateLimit = createRateLimit({
action: 'auth.passkey',
max: 10,
globalMax: 50,
window: '15m',
});
export const linkOrgAccountRateLimit = createRateLimit({
action: 'auth.link-org-account',
max: 5,
globalMax: 20,
window: '1h',
});
// ---- API (Tier 4 - Standard) ----
export const apiV1RateLimit = createRateLimit({
action: 'api.v1',
max: 100,
window: '1m',
});
export const apiV2RateLimit = createRateLimit({
action: 'api.v2',
max: 100,
window: '1m',
});
export const apiTrpcRateLimit = createRateLimit({
action: 'api.trpc',
max: 100,
window: '1m',
});
export const aiRateLimit = createRateLimit({
action: 'api.ai',
max: 3,
window: '1m',
});
export const fileUploadRateLimit = createRateLimit({
action: 'api.file-upload',
max: 20,
window: '1m',
});
@@ -17,6 +17,7 @@ import { nanoid, prefixedId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
import type { TEnvelopeExpirationPeriod } from '../../constants/envelope-expiration';
import type { SupportedLanguageCodes } from '../../constants/i18n';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { ZDefaultRecipientsSchema } from '../../types/default-recipients';
@@ -119,6 +120,7 @@ export type CreateDocumentFromTemplateOptions = {
typedSignatureEnabled?: boolean;
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
envelopeExpirationPeriod?: TEnvelopeExpirationPeriod | null;
};
formValues?: TDocumentFormValues;
@@ -521,6 +523,8 @@ export const createDocumentFromTemplate = async ({
override?.drawSignatureEnabled ?? template.documentMeta?.drawSignatureEnabled,
allowDictateNextSigner:
override?.allowDictateNextSigner ?? template.documentMeta?.allowDictateNextSigner,
envelopeExpirationPeriod:
override?.envelopeExpirationPeriod ?? template.documentMeta?.envelopeExpirationPeriod,
}),
});
@@ -0,0 +1,24 @@
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
export interface GetUserByResetTokenOptions {
token: string;
}
export const getUserByResetToken = async ({ token }: GetUserByResetTokenOptions) => {
const result = await prisma.passwordResetToken.findFirst({
where: {
token,
},
include: {
user: true,
},
});
if (!result || !result.user) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
return result.user;
};
@@ -1,9 +1,27 @@
import { prisma } from '@documenso/prisma';
const LEGACY_DELETED_ACCOUNT_EMAIL = 'deleted-account@documenso.com';
export const deletedServiceAccountEmail = () => {
try {
// eslint-disable-next-line turbo/no-undeclared-env-vars
if (process.env.NEXT_PRIVATE_DELETED_SERVICE_ACCOUNT_EMAIL) {
// eslint-disable-next-line turbo/no-undeclared-env-vars
return process.env.NEXT_PRIVATE_DELETED_SERVICE_ACCOUNT_EMAIL;
}
const { hostname } = new URL(process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000');
return `deleted-account@${hostname}`;
} catch (error) {
return LEGACY_DELETED_ACCOUNT_EMAIL;
}
};
export const deletedAccountServiceAccount = async () => {
const serviceAccount = await prisma.user.findFirst({
where: {
email: 'deleted-account@documenso.com',
email: deletedServiceAccountEmail(),
},
select: {
id: true,
@@ -29,3 +47,20 @@ export const deletedAccountServiceAccount = async () => {
return serviceAccount;
};
export const migrateDeletedAccountServiceAccount = async () => {
if (deletedServiceAccountEmail() !== LEGACY_DELETED_ACCOUNT_EMAIL) {
console.log(
`Migrating deleted account service account to new email: ${deletedServiceAccountEmail()}`,
);
await prisma.user.updateMany({
where: {
email: LEGACY_DELETED_ACCOUNT_EMAIL,
},
data: {
email: deletedServiceAccountEmail(),
},
});
}
};
@@ -0,0 +1,34 @@
import { prisma } from '@documenso/prisma';
const LEGACY_SERVICE_ACCOUNT_EMAIL = 'serviceaccount@documenso.com';
export const legacyServiceAccountEmail = () => {
try {
// eslint-disable-next-line turbo/no-undeclared-env-vars
if (process.env.NEXT_PRIVATE_LEGACY_SERVICE_ACCOUNT_EMAIL) {
// eslint-disable-next-line turbo/no-undeclared-env-vars
return process.env.NEXT_PRIVATE_LEGACY_SERVICE_ACCOUNT_EMAIL;
}
const { hostname } = new URL(process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000');
return `serviceaccount@${hostname}`;
} catch (error) {
return LEGACY_SERVICE_ACCOUNT_EMAIL;
}
};
export const migrateLegacyServiceAccount = async () => {
if (legacyServiceAccountEmail() !== LEGACY_SERVICE_ACCOUNT_EMAIL) {
console.log(`Migrating legacy service account to new email: ${legacyServiceAccountEmail()}`);
await prisma.user.updateMany({
where: {
email: LEGACY_SERVICE_ACCOUNT_EMAIL,
},
data: {
email: legacyServiceAccountEmail(),
},
});
}
};
@@ -20,6 +20,12 @@ export const submitSupportTicket = async ({
organisationId,
teamId,
}: SubmitSupportTicketOptions) => {
if (!plainClient) {
throw new AppError(AppErrorCode.NOT_SETUP, {
message: 'Support ticket system is not configured',
});
}
const user = await prisma.user.findFirst({
where: {
id: userId,
@@ -52,6 +58,29 @@ export const submitSupportTicket = async ({
})
: null;
// Ensure the customer exists in Plain before creating a thread
const plainCustomer = await plainClient.upsertCustomer({
identifier: {
emailAddress: user.email,
},
onCreate: {
// If the user doesn't have a name, default to their email
fullName: user.name || user.email,
email: {
email: user.email,
isVerified: !!user.emailVerified,
},
},
// No need to update the customer if it already exists
onUpdate: {},
});
if (plainCustomer.error) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: `Failed to create customer in support system: ${plainCustomer.error.message}`,
});
}
const customMessage = `
Organisation: ${organisation.name} (${organisation.id})
Team: ${team ? `${team.name} (${team.id})` : 'No team provided'}
@@ -60,12 +89,14 @@ ${message}`;
const res = await plainClient.createThread({
title: subject,
customerIdentifier: { emailAddress: user.email },
customerIdentifier: { customerId: plainCustomer.data.customer.id },
components: [{ componentText: { text: customMessage } }],
});
if (res.error) {
throw new Error(res.error.message);
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: `Failed to create support ticket: ${res.error.message}`,
});
}
return res;
@@ -61,7 +61,8 @@ export const generateSampleWebhookPayload = (
name: 'John Doe',
token: 'SIGNING_TOKEN',
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signedAt: null,
authOptions: null,
signingOrder: 1,
@@ -81,7 +82,8 @@ export const generateSampleWebhookPayload = (
name: 'John Doe',
token: 'SIGNING_TOKEN',
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signedAt: null,
authOptions: null,
signingOrder: 1,
@@ -120,7 +122,8 @@ export const generateSampleWebhookPayload = (
role: RecipientRole.VIEWER,
sendStatus: SendStatus.SENT,
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signedAt: null,
authOptions: null,
signingOrder: 1,
@@ -139,7 +142,8 @@ export const generateSampleWebhookPayload = (
role: RecipientRole.SIGNER,
sendStatus: SendStatus.SENT,
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signedAt: null,
authOptions: null,
rejectionReason: null,
@@ -168,7 +172,8 @@ export const generateSampleWebhookPayload = (
readStatus: ReadStatus.OPENED,
sendStatus: SendStatus.SENT,
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signedAt: null,
authOptions: null,
signingOrder: 1,
@@ -185,7 +190,8 @@ export const generateSampleWebhookPayload = (
readStatus: ReadStatus.OPENED,
sendStatus: SendStatus.SENT,
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signedAt: null,
authOptions: null,
signingOrder: 1,
@@ -222,7 +228,8 @@ export const generateSampleWebhookPayload = (
signingStatus: SigningStatus.SIGNED,
sendStatus: SendStatus.SENT,
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signingOrder: 1,
rejectionReason: null,
},
@@ -243,7 +250,8 @@ export const generateSampleWebhookPayload = (
signingStatus: SigningStatus.SIGNED,
sendStatus: SendStatus.SENT,
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signingOrder: 1,
rejectionReason: null,
},
@@ -270,7 +278,8 @@ export const generateSampleWebhookPayload = (
name: 'Signer 2',
token: 'SIGNING_TOKEN',
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signedAt: now,
authOptions: {
accessAuth: null,
@@ -291,7 +300,8 @@ export const generateSampleWebhookPayload = (
name: 'Signer 1',
token: 'SIGNING_TOKEN',
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signedAt: now,
authOptions: {
accessAuth: null,
@@ -314,7 +324,8 @@ export const generateSampleWebhookPayload = (
name: 'Signer 2',
token: 'SIGNING_TOKEN',
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signedAt: now,
authOptions: {
accessAuth: null,
@@ -335,7 +346,8 @@ export const generateSampleWebhookPayload = (
name: 'Signer 1',
token: 'SIGNING_TOKEN',
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signedAt: now,
authOptions: {
accessAuth: null,
@@ -374,7 +386,8 @@ export const generateSampleWebhookPayload = (
signingStatus: SigningStatus.REJECTED,
sendStatus: SendStatus.SENT,
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signingOrder: 1,
},
],
@@ -391,7 +404,8 @@ export const generateSampleWebhookPayload = (
signingStatus: SigningStatus.REJECTED,
sendStatus: SendStatus.SENT,
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signingOrder: 1,
},
],
@@ -425,6 +439,7 @@ export const generateSampleWebhookPayload = (
recipientRemoved: true,
documentCompleted: true,
ownerDocumentCompleted: true,
ownerRecipientExpired: true,
recipientSigningRequest: true,
},
},
@@ -437,7 +452,8 @@ export const generateSampleWebhookPayload = (
name: 'Signer 1',
token: 'SIGNING_TOKEN',
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signedAt: null,
authOptions: {
accessAuth: null,
@@ -460,7 +476,8 @@ export const generateSampleWebhookPayload = (
name: 'Signer',
token: 'SIGNING_TOKEN',
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signedAt: null,
authOptions: {
accessAuth: null,
@@ -480,5 +497,53 @@ export const generateSampleWebhookPayload = (
};
}
if (event === WebhookTriggerEvents.RECIPIENT_EXPIRED) {
const expiresAt = new Date(now.getTime() - 60 * 1000); // Expired 1 minute ago
return {
event,
payload: {
...basePayload,
status: DocumentStatus.PENDING,
recipients: [
{
...basePayload.recipients[0],
email: 'signer1@documenso.com',
name: 'Signer 1',
sendStatus: SendStatus.SENT,
documentDeletedAt: null,
expiresAt,
expirationNotifiedAt: now,
signedAt: null,
authOptions: null,
signingOrder: 1,
rejectionReason: null,
readStatus: ReadStatus.OPENED,
signingStatus: SigningStatus.NOT_SIGNED,
},
],
Recipient: [
{
...basePayload.Recipient[0],
email: 'signer1@documenso.com',
name: 'Signer 1',
sendStatus: SendStatus.SENT,
documentDeletedAt: null,
expiresAt,
expirationNotifiedAt: now,
signedAt: null,
authOptions: null,
signingOrder: 1,
rejectionReason: null,
readStatus: ReadStatus.OPENED,
signingStatus: SigningStatus.NOT_SIGNED,
},
],
},
createdAt: now.toISOString(),
webhookEndpoint: webhookUrl,
};
}
throw new Error(`Unsupported event type: ${event}`);
};
@@ -67,7 +67,9 @@ export const listDocumentsHandler = async (req: Request) => {
name: recipient.name,
token: recipient.token,
documentDeletedAt: recipient.documentDeletedAt,
expired: recipient.expired,
expired: recipient.expired, // !: deprecated Not in use. To be removed in a future migration.
expiresAt: recipient.expiresAt,
expirationNotifiedAt: recipient.expirationNotifiedAt,
signedAt: recipient.signedAt,
authOptions: recipient.authOptions,
signingOrder: recipient.signingOrder,