mirror of
https://github.com/documenso/documenso.git
synced 2026-07-27 02:15:05 +10:00
Merge remote-tracking branch 'origin/main' into pr-2468
# Conflicts: # packages/lib/types/document-audit-logs.ts # packages/lib/utils/document-audit-logs.ts # packages/prisma/schema.prisma
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { AppError, AppErrorCode } from '../errors/app-error';
|
||||
import { SignatureLevel, type TSignatureLevel } from '../types/signature-level';
|
||||
|
||||
export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT = Number(env('NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT')) || 50;
|
||||
|
||||
@@ -33,3 +35,54 @@ export const IS_AI_FEATURES_CONFIGURED = () => !!env('GOOGLE_VERTEX_PROJECT_ID')
|
||||
export const NEXT_PRIVATE_USE_PLAYWRIGHT_PDF = () => env('NEXT_PRIVATE_USE_PLAYWRIGHT_PDF') === 'true';
|
||||
|
||||
export const NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY = () => env('NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY');
|
||||
|
||||
/**
|
||||
* Whether this Documenso instance is running in CSC (Cloud Signature Consortium) mode.
|
||||
*
|
||||
* CSC mode routes signing through a third-party Trust Service Provider for
|
||||
* Advanced and Qualified Electronic Signatures (AES/QES). It is instance-wide
|
||||
* and mutually exclusive with the other signing transports.
|
||||
*/
|
||||
export const IS_INSTANCE_CSC_MODE = (): boolean => {
|
||||
if (typeof window === 'undefined') {
|
||||
return env('NEXT_PRIVATE_SIGNING_TRANSPORT') === 'csc';
|
||||
}
|
||||
|
||||
return env('NEXT_PUBLIC_SIGNING_TRANSPORT_IS_CSC') === 'true';
|
||||
};
|
||||
|
||||
/**
|
||||
* The default signature level applied to envelopes created on a CSC-mode
|
||||
* instance when the caller doesn't specify one (or asks for `SES` and the
|
||||
* resolver is in loose-coerce mode).
|
||||
*
|
||||
* Set via `NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL`; accepts `AES` or `QES`
|
||||
* only; defaults to `AES` when unset. An explicit `AES`/`QES` request on
|
||||
* envelope create still passes through unchanged — this constant only affects
|
||||
* the coerced default.
|
||||
*
|
||||
* Throws on an invalid value rather than silently falling back. A typo here
|
||||
* (e.g. `qes`) would otherwise silently downgrade qualified-tier instances
|
||||
* to advanced-tier, which has legal consequences.
|
||||
*
|
||||
* Only consulted on CSC-mode instances. Non-CSC instances always default to
|
||||
* `SES` regardless of this var.
|
||||
*/
|
||||
export const CSC_INSTANCE_SIGNATURE_LEVEL = (): TSignatureLevel => {
|
||||
// Cast through `string | undefined` because shells can deliver
|
||||
// `NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL=` as an empty string at runtime
|
||||
// — the typed env signature narrows to `'AES' | 'QES' | undefined` only.
|
||||
const value = env('NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL');
|
||||
|
||||
if (!value) {
|
||||
return SignatureLevel.AES;
|
||||
}
|
||||
|
||||
if (value !== SignatureLevel.AES && value !== SignatureLevel.QES) {
|
||||
throw new AppError(AppErrorCode.NOT_SETUP, {
|
||||
message: `NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL must be '${SignatureLevel.AES}' or '${SignatureLevel.QES}', got '${value}'.`,
|
||||
});
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import MailChecker from 'mailchecker';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { env } from '../utils/env';
|
||||
@@ -40,6 +41,14 @@ export const IS_OIDC_SSO_ENABLED = Boolean(
|
||||
|
||||
export const OIDC_PROVIDER_LABEL = env('NEXT_PRIVATE_OIDC_PROVIDER_LABEL');
|
||||
|
||||
/**
|
||||
* Opt-out flag for the automatic OIDC redirect.
|
||||
*
|
||||
* When OIDC is the only enabled signin transport we redirect to the provider
|
||||
* automatically. Set this to "true" to keep rendering the signin page instead.
|
||||
*/
|
||||
export const IS_OIDC_AUTO_REDIRECT_DISABLED = env('NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT') === 'true';
|
||||
|
||||
export const USER_SECURITY_AUDIT_LOG_MAP: Record<string, string> = {
|
||||
ACCOUNT_SSO_LINK: 'Linked account to SSO',
|
||||
ACCOUNT_SSO_UNLINK: 'Unlinked account from SSO',
|
||||
@@ -121,6 +130,54 @@ export const isEmailDomainAllowedForSignup = (email: string): boolean => {
|
||||
return allowedDomains.includes(emailDomain);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the given email belongs to a known disposable / throwaway provider
|
||||
* (e.g. mailinator, yopmail, 10minutemail, ...).
|
||||
*
|
||||
* Backed by the `mailchecker` package which bundles a static list of 55k+
|
||||
* disposable domains. The check is offline and synchronous.
|
||||
*
|
||||
* Matching also covers subdomains (e.g. `foo.mailinator.com` resolves to
|
||||
* `mailinator.com`).
|
||||
*
|
||||
* An optional `additionalBlockedDomains` list can be supplied to layer
|
||||
* admin-configured custom domains on top of the bundled list. These are
|
||||
* matched with the same subdomain-walking behaviour and are expected to be
|
||||
* pre-normalised (trimmed + lowercased) by the caller.
|
||||
*
|
||||
* Returns `true` when the email is disposable and should be rejected.
|
||||
* Email format validation is intentionally NOT performed here — that is
|
||||
* handled by Zod upstream.
|
||||
*/
|
||||
export const isDisposableEmail = (email: string, additionalBlockedDomains: string[] = []): boolean => {
|
||||
const domain = email.toLowerCase().split('@').pop();
|
||||
|
||||
if (!domain) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const blacklist = MailChecker.blacklist();
|
||||
const blocklist = new Set(additionalBlockedDomains);
|
||||
|
||||
let currentDomain: string | undefined = domain;
|
||||
|
||||
while (currentDomain) {
|
||||
if (blacklist.has(currentDomain) || blocklist.has(currentDomain)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const nextDot = currentDomain.indexOf('.');
|
||||
|
||||
if (nextDot === -1) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentDomain = currentDomain.slice(nextDot + 1);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if signup is enabled for the given provider.
|
||||
* The master switch takes precedence over the per-provider flags.
|
||||
@@ -139,3 +196,22 @@ export const isSignupEnabledForProvider = (provider: 'email' | 'google' | 'micro
|
||||
|
||||
return env(flagMap[provider]) !== 'true';
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if signin is enabled for the given provider.
|
||||
* The master switch takes precedence over the per-provider flags.
|
||||
*/
|
||||
export const isSigninEnabledForProvider = (provider: 'email' | 'google' | 'microsoft' | 'oidc'): boolean => {
|
||||
if (env('NEXT_PUBLIC_DISABLE_SIGNIN') === 'true') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const flagMap = {
|
||||
email: 'NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN',
|
||||
google: 'NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN',
|
||||
microsoft: 'NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN',
|
||||
oidc: 'NEXT_PUBLIC_DISABLE_OIDC_SIGNIN',
|
||||
} as const;
|
||||
|
||||
return env(flagMap[provider]) !== 'true';
|
||||
};
|
||||
|
||||
@@ -23,6 +23,9 @@ export const DOCUMENT_STATUS: {
|
||||
[DocumentStatus.REJECTED]: {
|
||||
description: msg`Rejected`,
|
||||
},
|
||||
[DocumentStatus.CANCELLED]: {
|
||||
description: msg`Cancelled`,
|
||||
},
|
||||
[DocumentStatus.DRAFT]: {
|
||||
description: msg`Draft`,
|
||||
},
|
||||
|
||||
@@ -36,6 +36,12 @@ export const DEFAULT_ENVELOPE_REMINDER_SETTINGS: TEnvelopeReminderSettings = {
|
||||
*/
|
||||
export const MAX_REMINDER_WINDOW_DAYS = 30;
|
||||
|
||||
/**
|
||||
* Maximum number of automated reminders sent to a recipient before reminders
|
||||
* stop. A manual resend resets the count, re-arming reminders.
|
||||
*/
|
||||
export const MAX_REMINDERS_BEFORE_RESEND = 5;
|
||||
|
||||
const UNIT_TO_LUXON_KEY: Record<TEnvelopeReminderDurationPeriod['unit'], keyof DurationLikeObject> = {
|
||||
day: 'days',
|
||||
week: 'weeks',
|
||||
@@ -53,24 +59,29 @@ export const getEnvelopeReminderDuration = (period: TEnvelopeReminderDurationPer
|
||||
* - `{ sendAfter: { disabled: true }, ... }` means never send the first reminder.
|
||||
* - `{ repeatEvery: { disabled: true }, ... }` means don't repeat after the first reminder.
|
||||
*
|
||||
* A hard cap of `MAX_REMINDER_WINDOW_DAYS` days from `sentAt` is enforced —
|
||||
* any computed reminder beyond that point returns null so reminders stop.
|
||||
* Reminders stop (returns null) once either cap is hit: `MAX_REMINDER_WINDOW_DAYS`
|
||||
* from `sentAt`, or `MAX_REMINDERS_BEFORE_RESEND` reminders already sent.
|
||||
*
|
||||
* `sentAt` is when the signing request was sent to this specific recipient.
|
||||
*
|
||||
* Returns the next Date the reminder should be sent, or null if no reminder should be sent.
|
||||
* Returns the next Date the reminder should be sent, or null if none.
|
||||
*/
|
||||
export const resolveNextReminderAt = (options: {
|
||||
config: TEnvelopeReminderSettings | null;
|
||||
sentAt: Date;
|
||||
lastReminderSentAt: Date | null;
|
||||
reminderCount: number;
|
||||
}): Date | null => {
|
||||
const { config, sentAt, lastReminderSentAt } = options;
|
||||
const { config, sentAt, lastReminderSentAt, reminderCount } = options;
|
||||
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (reminderCount >= MAX_REMINDERS_BEFORE_RESEND) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maxReminderAt = new Date(sentAt.getTime() + Duration.fromObject({ days: MAX_REMINDER_WINDOW_DAYS }).toMillis());
|
||||
|
||||
let candidate: Date;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getSignatureFontFamily } from './pdf';
|
||||
|
||||
describe('getSignatureFontFamily', () => {
|
||||
const expectCaveat = (family: string) => expect(family).toBe('Caveat');
|
||||
const expectNotoChain = (family: string) => {
|
||||
expect(family).toContain('"Noto Sans"');
|
||||
expect(family).toContain('"Noto Sans Chinese"');
|
||||
expect(family).toContain('"Noto Sans Japanese"');
|
||||
expect(family).toContain('"Noto Sans Korean"');
|
||||
expect(family).toContain('sans-serif');
|
||||
expect(family).not.toContain('Caveat');
|
||||
};
|
||||
|
||||
it('returns Caveat for ASCII-only text', () => {
|
||||
expectCaveat(getSignatureFontFamily('John Doe'));
|
||||
expectCaveat(getSignatureFontFamily(''));
|
||||
});
|
||||
|
||||
it('returns the Noto chain for any non-ASCII character', () => {
|
||||
expectNotoChain(getSignatureFontFamily('François'));
|
||||
expectNotoChain(getSignatureFontFamily('Müller'));
|
||||
expectNotoChain(getSignatureFontFamily('Søren'));
|
||||
expectNotoChain(getSignatureFontFamily('Иванов'));
|
||||
expectNotoChain(getSignatureFontFamily('Ελληνικά'));
|
||||
expectNotoChain(getSignatureFontFamily('عربي'));
|
||||
expectNotoChain(getSignatureFontFamily('עברית'));
|
||||
expectNotoChain(getSignatureFontFamily('도큐멘소'));
|
||||
expectNotoChain(getSignatureFontFamily('中文签名'));
|
||||
expectNotoChain(getSignatureFontFamily('こんにちは'));
|
||||
});
|
||||
|
||||
it('returns the Noto chain for mixed ASCII + non-ASCII input', () => {
|
||||
expectNotoChain(getSignatureFontFamily('Hello 안녕'));
|
||||
expectNotoChain(getSignatureFontFamily('Ivan Ωmega'));
|
||||
});
|
||||
|
||||
it('returns the Noto chain for scripts not covered by a dedicated Noto file', () => {
|
||||
expectNotoChain(getSignatureFontFamily('ሰላም')); // Ethiopic
|
||||
expectNotoChain(getSignatureFontFamily('សួស្ដី')); // Khmer
|
||||
expectNotoChain(getSignatureFontFamily('ᠮᠣᠩᠭᠣᠯ')); // Mongolian
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,20 @@ export const MIN_HANDWRITING_FONT_SIZE = 20;
|
||||
|
||||
export const CAVEAT_FONT_PATH = () => `${NEXT_PUBLIC_WEBAPP_URL()}/fonts/caveat.ttf`;
|
||||
|
||||
const SIGNATURE_FONT_FAMILY_CAVEAT = 'Caveat';
|
||||
|
||||
// CN-before-JP: the JP Noto file's Han glyphs use JP shapes, so pure-CN
|
||||
// text would otherwise render with JP forms. Family names sync with
|
||||
// apps/remix/app/app.css and packages/lib/server-only/pdf/helpers.ts.
|
||||
const SIGNATURE_FONT_FAMILY_NOTO =
|
||||
'"Noto Sans", "Noto Sans Chinese", "Noto Sans Japanese", "Noto Sans Korean", sans-serif';
|
||||
|
||||
const isASCII = (str: string) => /^\p{ASCII}*$/u.test(str);
|
||||
|
||||
// Deliberately never mix handwriting + sans-serif within one signature.
|
||||
export const getSignatureFontFamily = (typedSignatureText: string): string =>
|
||||
isASCII(typedSignatureText) ? SIGNATURE_FONT_FAMILY_CAVEAT : SIGNATURE_FONT_FAMILY_NOTO;
|
||||
|
||||
export const PDF_SIZE_A4_72PPI = {
|
||||
width: 595,
|
||||
height: 842,
|
||||
|
||||
Reference in New Issue
Block a user