mirror of
https://github.com/documenso/documenso.git
synced 2026-07-24 08:54:20 +10:00
chore: merge main, resolve biome formatting conflicts
Merge origin/main into feat/external-2fa-codes. Resolve formatting conflicts caused by biome rollout; preserve both feature streams: PR's external 2FA token + signing-session 2FA proof additions plus main's RateLimit/RecipientExpired/signingReminders/date-auto-insert. In complete-document-with-token.ts, drop the duplicate early field-fetching block introduced when main moved that logic later with date auto-insert support; keep the EXTERNAL_TWO_FACTOR_AUTH check using derivedRecipientActionAuth.
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { assertNotPrivateUrl } from './assert-webhook-url';
|
||||
|
||||
const fakeLookup = (addresses: Array<{ address: string; family: number }>) => {
|
||||
return vi.fn().mockResolvedValue(addresses);
|
||||
};
|
||||
|
||||
const fakeLookupSingle = (address: string, family: number) => {
|
||||
return vi.fn().mockResolvedValue({ address, family });
|
||||
};
|
||||
|
||||
describe('assertNotPrivateUrl', () => {
|
||||
describe('static URL checks', () => {
|
||||
it('should throw for localhost URLs', async () => {
|
||||
await expect(assertNotPrivateUrl('http://localhost:3000')).rejects.toThrow(AppError);
|
||||
});
|
||||
|
||||
it('should throw for 127.0.0.1', async () => {
|
||||
await expect(assertNotPrivateUrl('http://127.0.0.1')).rejects.toThrow(AppError);
|
||||
});
|
||||
|
||||
it('should throw for private IPs before DNS lookup', async () => {
|
||||
await expect(assertNotPrivateUrl('http://10.0.0.1')).rejects.toThrow(AppError);
|
||||
await expect(assertNotPrivateUrl('http://192.168.1.1')).rejects.toThrow(AppError);
|
||||
});
|
||||
|
||||
it('should throw with WEBHOOK_INVALID_REQUEST error code', async () => {
|
||||
try {
|
||||
await assertNotPrivateUrl('http://localhost');
|
||||
expect.unreachable('should have thrown');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(AppError);
|
||||
expect((err as AppError).code).toBe(AppErrorCode.WEBHOOK_INVALID_REQUEST);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('DNS resolution checks', () => {
|
||||
it('should throw when hostname resolves to a private IPv4 address', async () => {
|
||||
const lookup = fakeLookup([{ address: '127.0.0.1', family: 4 }]);
|
||||
|
||||
await expect(assertNotPrivateUrl('https://evil.example.com', { lookup })).rejects.toThrow(AppError);
|
||||
});
|
||||
|
||||
it('should throw when hostname resolves to a private IPv6 address', async () => {
|
||||
const lookup = fakeLookup([{ address: '::1', family: 6 }]);
|
||||
|
||||
await expect(assertNotPrivateUrl('https://evil.example.com', { lookup })).rejects.toThrow(AppError);
|
||||
});
|
||||
|
||||
it('should throw when any resolved address is private', async () => {
|
||||
const lookup = fakeLookup([
|
||||
{ address: '8.8.8.8', family: 4 },
|
||||
{ address: '127.0.0.1', family: 4 },
|
||||
]);
|
||||
|
||||
await expect(assertNotPrivateUrl('https://evil.example.com', { lookup })).rejects.toThrow(AppError);
|
||||
});
|
||||
|
||||
it('should allow hostnames that resolve to public addresses', async () => {
|
||||
const lookup = fakeLookup([{ address: '93.184.216.34', family: 4 }]);
|
||||
|
||||
await expect(assertNotPrivateUrl('https://example.com', { lookup })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle a single address result (non-array)', async () => {
|
||||
const lookup = fakeLookupSingle('10.0.0.1', 4);
|
||||
|
||||
await expect(assertNotPrivateUrl('https://evil.example.com', { lookup })).rejects.toThrow(AppError);
|
||||
});
|
||||
|
||||
it('should handle a single public address result', async () => {
|
||||
const lookup = fakeLookupSingle('93.184.216.34', 4);
|
||||
|
||||
await expect(assertNotPrivateUrl('https://example.com', { lookup })).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('IP address URLs skip DNS', () => {
|
||||
it('should not perform DNS lookup for IP address URLs', async () => {
|
||||
const lookup = vi.fn();
|
||||
|
||||
await assertNotPrivateUrl('http://8.8.8.8', { lookup });
|
||||
expect(lookup).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DNS failure handling', () => {
|
||||
it('should silently allow when DNS lookup throws', async () => {
|
||||
const lookup = vi.fn().mockRejectedValue(new Error('ENOTFOUND'));
|
||||
|
||||
await expect(assertNotPrivateUrl('https://nonexistent.example.com', { lookup })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should re-throw AppError even within the catch block', async () => {
|
||||
const lookup = fakeLookup([{ address: '192.168.0.1', family: 4 }]);
|
||||
|
||||
await expect(assertNotPrivateUrl('https://evil.example.com', { lookup })).rejects.toThrow(AppError);
|
||||
});
|
||||
|
||||
it('should silently allow when DNS lookup times out (returns null)', async () => {
|
||||
const lookup = vi.fn().mockReturnValue(new Promise(() => {}));
|
||||
|
||||
// withTimeout races the lookup against a 250ms timer and returns null
|
||||
// if the lookup doesn't settle in time, so the function returns early.
|
||||
await expect(assertNotPrivateUrl('https://slow.example.com', { lookup })).resolves.toBeUndefined();
|
||||
}, 10_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { withTimeout } from '../../utils/timeout';
|
||||
import { isPrivateUrl } from './is-private-url';
|
||||
|
||||
const ZIpSchema = z.string().ip();
|
||||
|
||||
const WEBHOOK_DNS_LOOKUP_TIMEOUT_MS = 250;
|
||||
|
||||
type TLookupAddress = {
|
||||
address: string;
|
||||
family: number;
|
||||
};
|
||||
|
||||
type TLookupFn = (
|
||||
hostname: string,
|
||||
options: {
|
||||
all: true;
|
||||
verbatim: true;
|
||||
},
|
||||
) => Promise<TLookupAddress[] | TLookupAddress>;
|
||||
|
||||
const normalizeHostname = (hostname: string) => hostname.toLowerCase().replace(/\.+$/, '');
|
||||
|
||||
const toAddressUrl = (address: string) => (address.includes(':') ? `http://[${address}]` : `http://${address}`);
|
||||
|
||||
/**
|
||||
* Parse the NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS environment variable into
|
||||
* a Set of lowercased hostnames/IPs that are allowed to resolve to private
|
||||
* addresses. The Set is built once at module load and never changes.
|
||||
*
|
||||
* Empty or unset = no bypasses (safe default).
|
||||
*/
|
||||
const webhookSSRFBypassHosts = (): Set<string> => {
|
||||
const raw = process.env['NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS'] ?? '';
|
||||
|
||||
const hosts = new Set<string>();
|
||||
|
||||
for (const entry of raw.split(',')) {
|
||||
const trimmed = entry.trim().toLowerCase();
|
||||
|
||||
if (trimmed.length > 0) {
|
||||
hosts.add(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
return hosts;
|
||||
};
|
||||
|
||||
const WEBHOOK_SSRF_BYPASS_HOSTS = webhookSSRFBypassHosts();
|
||||
|
||||
/**
|
||||
* Check whether the hostname of the given URL is present in the SSRF bypass
|
||||
* list. Matches against URL.hostname which covers both DNS names and raw IP
|
||||
* addresses uniformly.
|
||||
*/
|
||||
const isBypassedHost = (url: string): boolean => {
|
||||
if (WEBHOOK_SSRF_BYPASS_HOSTS.size === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const hostname = normalizeHostname(new URL(url).hostname);
|
||||
|
||||
return WEBHOOK_SSRF_BYPASS_HOSTS.has(hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Asserts that a webhook URL does not resolve to a private or loopback
|
||||
* address. Throws an AppError with WEBHOOK_INVALID_REQUEST if it does.
|
||||
*
|
||||
* Hosts listed in NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS skip all checks.
|
||||
*/
|
||||
export const assertNotPrivateUrl = async (
|
||||
url: string,
|
||||
options?: {
|
||||
lookup?: TLookupFn;
|
||||
},
|
||||
) => {
|
||||
if (isBypassedHost(url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPrivateUrl(url)) {
|
||||
throw new AppError(AppErrorCode.WEBHOOK_INVALID_REQUEST, {
|
||||
message: 'Webhook URL resolves to a private or loopback address',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const hostname = normalizeHostname(new URL(url).hostname);
|
||||
|
||||
if (hostname.length === 0 || ZIpSchema.safeParse(hostname).success) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolveHostname = options?.lookup ?? lookup;
|
||||
|
||||
const lookupResult = await withTimeout(
|
||||
resolveHostname(hostname, {
|
||||
all: true,
|
||||
verbatim: true,
|
||||
}),
|
||||
WEBHOOK_DNS_LOOKUP_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
if (!lookupResult) {
|
||||
return;
|
||||
}
|
||||
|
||||
const addresses = Array.isArray(lookupResult) ? lookupResult : [lookupResult];
|
||||
|
||||
if (addresses.some(({ address }) => isPrivateUrl(toAddressUrl(address)))) {
|
||||
throw new AppError(AppErrorCode.WEBHOOK_INVALID_REQUEST, {
|
||||
message: 'Webhook URL resolves to a private or loopback address',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof AppError) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { WebhookTriggerEvents } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { WebhookTriggerEvents } from '@prisma/client';
|
||||
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
import { fetchWithTimeout } from '../../utils/timeout';
|
||||
import { assertNotPrivateUrl } from './assert-webhook-url';
|
||||
|
||||
const WEBHOOK_TIMEOUT_MS = 10_000;
|
||||
|
||||
export type WebhookCallResult = {
|
||||
success: boolean;
|
||||
responseCode: number;
|
||||
responseBody: Prisma.InputJsonValue | Prisma.JsonNullValueInput;
|
||||
responseHeaders: Record<string, string>;
|
||||
};
|
||||
|
||||
const parseBody = (text: string): Prisma.InputJsonValue => {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
};
|
||||
|
||||
export const executeWebhookCall = async (options: {
|
||||
url: string;
|
||||
body: unknown;
|
||||
secret: string | null;
|
||||
}): Promise<WebhookCallResult> => {
|
||||
const { url, body, secret } = options;
|
||||
|
||||
try {
|
||||
await assertNotPrivateUrl(url);
|
||||
|
||||
const response = await fetchWithTimeout(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
redirect: 'manual',
|
||||
timeoutMs: WEBHOOK_TIMEOUT_MS,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Documenso-Secret': secret ?? '',
|
||||
},
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
return {
|
||||
success: response.ok,
|
||||
responseCode: response.status,
|
||||
responseBody: parseBody(text),
|
||||
responseHeaders: Object.fromEntries(response.headers.entries()),
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
responseCode: 0,
|
||||
responseBody: err instanceof Error ? err.message : 'Unknown error',
|
||||
responseHeaders: {},
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { WebhookTriggerEvents } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { WebhookTriggerEvents } from '@prisma/client';
|
||||
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
@@ -10,11 +9,7 @@ export type GetAllWebhooksByEventTriggerOptions = {
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const getAllWebhooksByEventTrigger = async ({
|
||||
event,
|
||||
userId,
|
||||
teamId,
|
||||
}: GetAllWebhooksByEventTriggerOptions) => {
|
||||
export const getAllWebhooksByEventTrigger = async ({ event, userId, teamId }: GetAllWebhooksByEventTriggerOptions) => {
|
||||
return prisma.webhook.findMany({
|
||||
where: {
|
||||
enabled: true,
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isPrivateUrl } from './is-private-url';
|
||||
|
||||
describe('isPrivateUrl', () => {
|
||||
describe('localhost', () => {
|
||||
it('should detect localhost', () => {
|
||||
expect(isPrivateUrl('http://localhost')).toBe(true);
|
||||
expect(isPrivateUrl('http://localhost:3000')).toBe(true);
|
||||
expect(isPrivateUrl('https://localhost/path')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect localhost with trailing dot', () => {
|
||||
expect(isPrivateUrl('http://localhost.')).toBe(true);
|
||||
});
|
||||
|
||||
it('should be case insensitive', () => {
|
||||
expect(isPrivateUrl('http://LOCALHOST')).toBe(true);
|
||||
expect(isPrivateUrl('http://Localhost:8080')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('IPv4 loopback', () => {
|
||||
it('should detect 127.0.0.1', () => {
|
||||
expect(isPrivateUrl('http://127.0.0.1')).toBe(true);
|
||||
expect(isPrivateUrl('http://127.0.0.1:8080')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect the full 127.x.x.x range', () => {
|
||||
expect(isPrivateUrl('http://127.0.0.2')).toBe(true);
|
||||
expect(isPrivateUrl('http://127.255.255.255')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('IPv4 private ranges', () => {
|
||||
it('should detect 10.x.x.x', () => {
|
||||
expect(isPrivateUrl('http://10.0.0.1')).toBe(true);
|
||||
expect(isPrivateUrl('http://10.255.255.255')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect 172.16.0.0/12', () => {
|
||||
expect(isPrivateUrl('http://172.16.0.1')).toBe(true);
|
||||
expect(isPrivateUrl('http://172.31.255.255')).toBe(true);
|
||||
});
|
||||
|
||||
it('should not flag 172.x outside the /12 range', () => {
|
||||
expect(isPrivateUrl('http://172.15.0.1')).toBe(false);
|
||||
expect(isPrivateUrl('http://172.32.0.1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect 192.168.x.x', () => {
|
||||
expect(isPrivateUrl('http://192.168.0.1')).toBe(true);
|
||||
expect(isPrivateUrl('http://192.168.255.255')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect link-local 169.254.x.x', () => {
|
||||
expect(isPrivateUrl('http://169.254.1.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect 0.0.0.0', () => {
|
||||
expect(isPrivateUrl('http://0.0.0.0')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('IPv6', () => {
|
||||
it('should detect ::1 loopback', () => {
|
||||
expect(isPrivateUrl('http://[::1]')).toBe(true);
|
||||
expect(isPrivateUrl('http://[::1]:3000')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect :: unspecified', () => {
|
||||
expect(isPrivateUrl('http://[::]')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect link-local fe80:', () => {
|
||||
expect(isPrivateUrl('http://[fe80::1]')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect unique local fc/fd', () => {
|
||||
expect(isPrivateUrl('http://[fc00::1]')).toBe(true);
|
||||
expect(isPrivateUrl('http://[fd12::1]')).toBe(true);
|
||||
});
|
||||
|
||||
it('should not catch IPv4-mapped IPv6 in URL form (URL parser normalizes to hex)', () => {
|
||||
// new URL() normalizes "::ffff:127.0.0.1" to "::ffff:7f00:1" which none
|
||||
// of the checks handle. This is fine because dns.lookup never returns
|
||||
// IPv4-mapped addresses — it returns plain IPv4 (family: 4) instead.
|
||||
expect(isPrivateUrl('http://[::ffff:127.0.0.1]')).toBe(false);
|
||||
expect(isPrivateUrl('http://[::ffff:10.0.0.1]')).toBe(false);
|
||||
expect(isPrivateUrl('http://[::ffff:8.8.8.8]')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('public URLs', () => {
|
||||
it('should allow public hostnames', () => {
|
||||
expect(isPrivateUrl('https://example.com')).toBe(false);
|
||||
expect(isPrivateUrl('https://api.documenso.com/webhook')).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow public IP addresses', () => {
|
||||
expect(isPrivateUrl('http://8.8.8.8')).toBe(false);
|
||||
expect(isPrivateUrl('http://1.1.1.1')).toBe(false);
|
||||
expect(isPrivateUrl('http://203.0.113.1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should return false for invalid URLs', () => {
|
||||
expect(isPrivateUrl('not-a-url')).toBe(false);
|
||||
expect(isPrivateUrl('')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const ZIpSchema = z.string().ip();
|
||||
|
||||
/**
|
||||
* Check whether a URL points to a known private/loopback address.
|
||||
*
|
||||
* Performs a synchronous check against known private hostnames and IP ranges.
|
||||
* Works regardless of the URL protocol.
|
||||
*/
|
||||
export const isPrivateUrl = (url: string): boolean => {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
|
||||
// Strip IPv6 brackets.
|
||||
const bare = hostname.startsWith('[') ? hostname.slice(1, -1) : hostname;
|
||||
const normalizedHost = bare.replace(/\.+$/, '');
|
||||
|
||||
if (normalizedHost === 'localhost') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const parsedIp = ZIpSchema.safeParse(normalizedHost);
|
||||
|
||||
if (!parsedIp.success) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalizedHost === '::1' || normalizedHost === '::') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalizedHost === '0.0.0.0') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalizedHost.startsWith('127.')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalizedHost.startsWith('10.')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalizedHost.startsWith('192.168.')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalizedHost.startsWith('169.254.')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalizedHost.startsWith('fe80:')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalizedHost.startsWith('fc') || normalizedHost.startsWith('fd')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 172.16.0.0/12
|
||||
if (normalizedHost.startsWith('172.')) {
|
||||
const second = parseInt(normalizedHost.split('.')[1], 10);
|
||||
|
||||
if (second >= 16 && second <= 31) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1)
|
||||
const v4Mapped = normalizedHost.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
|
||||
|
||||
if (v4Mapped) {
|
||||
return isPrivateUrl(`http://${v4Mapped[1]}`);
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -11,12 +11,7 @@ export type TriggerTestWebhookOptions = {
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const triggerTestWebhook = async ({
|
||||
id,
|
||||
event,
|
||||
userId,
|
||||
teamId,
|
||||
}: TriggerTestWebhookOptions) => {
|
||||
export const triggerTestWebhook = async ({ id, event, userId, teamId }: TriggerTestWebhookOptions) => {
|
||||
const webhook = await getWebhookById({ id, userId, teamId });
|
||||
|
||||
if (!webhook.enabled) {
|
||||
@@ -32,7 +27,7 @@ export const triggerTestWebhook = async ({
|
||||
try {
|
||||
await triggerWebhook({
|
||||
event,
|
||||
data: samplePayload,
|
||||
data: samplePayload.payload,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
@@ -13,10 +13,7 @@ import {
|
||||
|
||||
import type { WebhookPayload } from '../../../types/webhook-payload';
|
||||
|
||||
export const generateSampleWebhookPayload = (
|
||||
event: WebhookTriggerEvents,
|
||||
webhookUrl: string,
|
||||
): WebhookPayload => {
|
||||
export const generateSampleWebhookPayload = (event: WebhookTriggerEvents, webhookUrl: string): WebhookPayload => {
|
||||
const now = new Date();
|
||||
const basePayload = {
|
||||
id: 10,
|
||||
@@ -61,7 +58,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 +79,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 +119,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 +139,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 +169,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 +187,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 +225,8 @@ export const generateSampleWebhookPayload = (
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
documentDeletedAt: null,
|
||||
expired: null,
|
||||
expiresAt: null,
|
||||
expirationNotifiedAt: null,
|
||||
signingOrder: 1,
|
||||
rejectionReason: null,
|
||||
},
|
||||
@@ -243,7 +247,8 @@ export const generateSampleWebhookPayload = (
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
documentDeletedAt: null,
|
||||
expired: null,
|
||||
expiresAt: null,
|
||||
expirationNotifiedAt: null,
|
||||
signingOrder: 1,
|
||||
rejectionReason: null,
|
||||
},
|
||||
@@ -270,7 +275,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 +297,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 +321,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 +343,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 +383,8 @@ export const generateSampleWebhookPayload = (
|
||||
signingStatus: SigningStatus.REJECTED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
documentDeletedAt: null,
|
||||
expired: null,
|
||||
expiresAt: null,
|
||||
expirationNotifiedAt: null,
|
||||
signingOrder: 1,
|
||||
},
|
||||
],
|
||||
@@ -391,7 +401,8 @@ export const generateSampleWebhookPayload = (
|
||||
signingStatus: SigningStatus.REJECTED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
documentDeletedAt: null,
|
||||
expired: null,
|
||||
expiresAt: null,
|
||||
expirationNotifiedAt: null,
|
||||
signingOrder: 1,
|
||||
},
|
||||
],
|
||||
@@ -425,6 +436,8 @@ export const generateSampleWebhookPayload = (
|
||||
recipientRemoved: true,
|
||||
documentCompleted: true,
|
||||
ownerDocumentCompleted: true,
|
||||
ownerRecipientExpired: true,
|
||||
ownerDocumentCreated: true,
|
||||
recipientSigningRequest: true,
|
||||
},
|
||||
},
|
||||
@@ -437,7 +450,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 +474,8 @@ export const generateSampleWebhookPayload = (
|
||||
name: 'Signer',
|
||||
token: 'SIGNING_TOKEN',
|
||||
documentDeletedAt: null,
|
||||
expired: null,
|
||||
expiresAt: null,
|
||||
expirationNotifiedAt: null,
|
||||
signedAt: null,
|
||||
authOptions: {
|
||||
accessAuth: null,
|
||||
@@ -480,5 +495,167 @@ 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,
|
||||
};
|
||||
}
|
||||
|
||||
if (event === WebhookTriggerEvents.DOCUMENT_RECIPIENT_COMPLETED) {
|
||||
return {
|
||||
event,
|
||||
payload: {
|
||||
...basePayload,
|
||||
status: DocumentStatus.PENDING,
|
||||
recipients: [
|
||||
{
|
||||
...basePayload.recipients[0],
|
||||
readStatus: ReadStatus.OPENED,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
signedAt: now,
|
||||
},
|
||||
],
|
||||
Recipient: [
|
||||
{
|
||||
...basePayload.recipients[0],
|
||||
readStatus: ReadStatus.OPENED,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
signedAt: now,
|
||||
},
|
||||
],
|
||||
},
|
||||
createdAt: now.toISOString(),
|
||||
webhookEndpoint: webhookUrl,
|
||||
};
|
||||
}
|
||||
|
||||
if (event === WebhookTriggerEvents.DOCUMENT_REMINDER_SENT) {
|
||||
return {
|
||||
event,
|
||||
payload: {
|
||||
...basePayload,
|
||||
status: DocumentStatus.PENDING,
|
||||
recipients: [
|
||||
{
|
||||
...basePayload.recipients[0],
|
||||
sendStatus: SendStatus.SENT,
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
},
|
||||
],
|
||||
Recipient: [
|
||||
{
|
||||
...basePayload.recipients[0],
|
||||
sendStatus: SendStatus.SENT,
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
},
|
||||
],
|
||||
},
|
||||
createdAt: now.toISOString(),
|
||||
webhookEndpoint: webhookUrl,
|
||||
};
|
||||
}
|
||||
|
||||
if (event === WebhookTriggerEvents.TEMPLATE_CREATED) {
|
||||
return {
|
||||
event,
|
||||
payload: {
|
||||
...basePayload,
|
||||
title: 'My Template',
|
||||
status: DocumentStatus.DRAFT,
|
||||
templateId: 10,
|
||||
source: DocumentSource.TEMPLATE,
|
||||
},
|
||||
createdAt: now.toISOString(),
|
||||
webhookEndpoint: webhookUrl,
|
||||
};
|
||||
}
|
||||
|
||||
if (event === WebhookTriggerEvents.TEMPLATE_UPDATED) {
|
||||
return {
|
||||
event,
|
||||
payload: {
|
||||
...basePayload,
|
||||
title: 'My Updated Template',
|
||||
status: DocumentStatus.DRAFT,
|
||||
templateId: 10,
|
||||
source: DocumentSource.TEMPLATE,
|
||||
},
|
||||
createdAt: now.toISOString(),
|
||||
webhookEndpoint: webhookUrl,
|
||||
};
|
||||
}
|
||||
|
||||
if (event === WebhookTriggerEvents.TEMPLATE_DELETED) {
|
||||
return {
|
||||
event,
|
||||
payload: {
|
||||
...basePayload,
|
||||
title: 'Deleted Template',
|
||||
status: DocumentStatus.DRAFT,
|
||||
templateId: 10,
|
||||
source: DocumentSource.TEMPLATE,
|
||||
},
|
||||
createdAt: now.toISOString(),
|
||||
webhookEndpoint: webhookUrl,
|
||||
};
|
||||
}
|
||||
|
||||
if (event === WebhookTriggerEvents.TEMPLATE_USED) {
|
||||
return {
|
||||
event,
|
||||
payload: {
|
||||
...basePayload,
|
||||
title: 'Document from Template',
|
||||
status: DocumentStatus.DRAFT,
|
||||
templateId: 10,
|
||||
source: DocumentSource.TEMPLATE,
|
||||
},
|
||||
createdAt: now.toISOString(),
|
||||
webhookEndpoint: webhookUrl,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported event type: ${event}`);
|
||||
};
|
||||
|
||||
@@ -55,8 +55,5 @@ export const handlerTriggerWebhooks = async (req: Request) => {
|
||||
}),
|
||||
);
|
||||
|
||||
return Response.json(
|
||||
{ success: true, message: 'Webhooks queued for execution' },
|
||||
{ status: 200 },
|
||||
);
|
||||
return Response.json({ success: true, message: 'Webhooks queued for execution' }, { status: 200 });
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { EnvelopeType, type Webhook } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { EnvelopeType, type Webhook } from '@prisma/client';
|
||||
|
||||
import { mapSecondaryIdToDocumentId } from '../../../utils/envelope';
|
||||
import { getWebhooksByTeamId } from '../get-webhooks-by-team-id';
|
||||
@@ -67,7 +66,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,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { assertNotPrivateUrl } from '../assert-webhook-url';
|
||||
import { validateApiToken } from './validateApiToken';
|
||||
|
||||
export const subscribeHandler = async (req: Request) => {
|
||||
@@ -12,6 +14,8 @@ export const subscribeHandler = async (req: Request) => {
|
||||
|
||||
const { webhookUrl, eventTrigger } = await req.json();
|
||||
|
||||
await assertNotPrivateUrl(webhookUrl);
|
||||
|
||||
const result = await validateApiToken({ authorization });
|
||||
|
||||
const createdWebhook = await prisma.webhook.create({
|
||||
@@ -27,6 +31,10 @@ export const subscribeHandler = async (req: Request) => {
|
||||
|
||||
return Response.json(createdWebhook);
|
||||
} catch (err) {
|
||||
if (err instanceof AppError) {
|
||||
return Response.json({ message: err.message }, { status: 400 });
|
||||
}
|
||||
|
||||
console.error(err);
|
||||
|
||||
return Response.json(
|
||||
|
||||
Reference in New Issue
Block a user