diff --git a/apps/web/src/pages/api/webhook/trigger.ts b/apps/web/src/pages/api/webhook/trigger.ts new file mode 100644 index 000000000..88abbb3b6 --- /dev/null +++ b/apps/web/src/pages/api/webhook/trigger.ts @@ -0,0 +1,12 @@ +import { handlerTriggerWebhooks } from '@documenso/lib/server-only/webhooks/trigger/handler'; + +export const config = { + maxDuration: 300, + api: { + bodyParser: { + sizeLimit: '50mb', + }, + }, +}; + +export default handlerTriggerWebhooks; diff --git a/packages/lib/server-only/crypto/sign.ts b/packages/lib/server-only/crypto/sign.ts new file mode 100644 index 000000000..18c111c7b --- /dev/null +++ b/packages/lib/server-only/crypto/sign.ts @@ -0,0 +1,12 @@ +import { hashString } from '../auth/hash'; +import { encryptSecondaryData } from './encrypt'; + +export const sign = (data: unknown) => { + const stringified = JSON.stringify(data); + + const hashed = hashString(stringified); + + const signature = encryptSecondaryData({ data: hashed }); + + return signature; +}; diff --git a/packages/lib/server-only/crypto/verify.ts b/packages/lib/server-only/crypto/verify.ts new file mode 100644 index 000000000..7658e8b5e --- /dev/null +++ b/packages/lib/server-only/crypto/verify.ts @@ -0,0 +1,12 @@ +import { hashString } from '../auth/hash'; +import { decryptSecondaryData } from './decrypt'; + +export const verify = (data: unknown, signature: string) => { + const stringified = JSON.stringify(data); + + const hashed = hashString(stringified); + + const decrypted = decryptSecondaryData(signature); + + return decrypted === hashed; +}; diff --git a/packages/lib/server-only/document/complete-document-with-token.ts b/packages/lib/server-only/document/complete-document-with-token.ts index ae729e200..5f58c5183 100644 --- a/packages/lib/server-only/document/complete-document-with-token.ts +++ b/packages/lib/server-only/document/complete-document-with-token.ts @@ -7,7 +7,7 @@ import { prisma } from '@documenso/prisma'; import { DocumentStatus, SigningStatus } from '@documenso/prisma/client'; import { WebhookTriggerEvents } from '@documenso/prisma/client'; -import { triggerWebhook } from '../../universal/trigger-webhook'; +import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; import { sealDocument } from './seal-document'; import { sendPendingEmail } from './send-pending-email'; @@ -134,7 +134,9 @@ export const completeDocumentWithToken = async ({ const updatedDocument = await getDocument({ token, documentId }); await triggerWebhook({ - eventTrigger: WebhookTriggerEvents.DOCUMENT_SIGNED, - documentData: updatedDocument, + event: WebhookTriggerEvents.DOCUMENT_SIGNED, + data: updatedDocument, + userId: updatedDocument.userId, + teamId: updatedDocument.teamId ?? undefined, }); }; diff --git a/packages/lib/server-only/document/create-document.ts b/packages/lib/server-only/document/create-document.ts index d7d8b58e4..ce1f16670 100644 --- a/packages/lib/server-only/document/create-document.ts +++ b/packages/lib/server-only/document/create-document.ts @@ -7,7 +7,7 @@ import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit- import { prisma } from '@documenso/prisma'; import { WebhookTriggerEvents } from '@documenso/prisma/client'; -import { triggerWebhook } from '../../universal/trigger-webhook'; +import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; export type CreateDocumentOptions = { title: string; @@ -67,8 +67,10 @@ export const createDocument = async ({ }); await triggerWebhook({ - eventTrigger: WebhookTriggerEvents.DOCUMENT_CREATED, - documentData: document, + event: WebhookTriggerEvents.DOCUMENT_CREATED, + data: document, + userId, + teamId, }); return document; diff --git a/packages/lib/server-only/document/seal-document.ts b/packages/lib/server-only/document/seal-document.ts index 7b27e402a..8f39e3d25 100644 --- a/packages/lib/server-only/document/seal-document.ts +++ b/packages/lib/server-only/document/seal-document.ts @@ -13,10 +13,10 @@ import { WebhookTriggerEvents } from '@documenso/prisma/client'; import { signPdf } from '@documenso/signing'; import type { RequestMetadata } from '../../universal/extract-request-metadata'; -import { triggerWebhook } from '../../universal/trigger-webhook'; import { getFile } from '../../universal/upload/get-file'; import { putFile } from '../../universal/upload/put-file'; import { insertFieldInPDF } from '../pdf/insert-field-in-pdf'; +import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; import { sendCompletedEmail } from './send-completed-email'; export type SealDocumentOptions = { @@ -139,7 +139,9 @@ export const sealDocument = async ({ } await triggerWebhook({ - eventTrigger: WebhookTriggerEvents.DOCUMENT_COMPLETED, - documentData: document, + event: WebhookTriggerEvents.DOCUMENT_COMPLETED, + data: document, + userId: document.userId, + teamId: document.teamId ?? undefined, }); }; diff --git a/packages/lib/server-only/document/send-document.tsx b/packages/lib/server-only/document/send-document.tsx index d822e4a53..7c928f9a9 100644 --- a/packages/lib/server-only/document/send-document.tsx +++ b/packages/lib/server-only/document/send-document.tsx @@ -17,7 +17,7 @@ import { RECIPIENT_ROLES_DESCRIPTION, RECIPIENT_ROLE_TO_EMAIL_TYPE, } from '../../constants/recipient-roles'; -import { triggerWebhook } from '../../universal/trigger-webhook'; +import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; export type SendDocumentOptions = { documentId: number; @@ -189,8 +189,10 @@ export const sendDocument = async ({ }); await triggerWebhook({ - eventTrigger: WebhookTriggerEvents.DOCUMENT_SENT, - documentData: updatedDocument, + event: WebhookTriggerEvents.DOCUMENT_SENT, + data: updatedDocument, + userId, + teamId, }); return updatedDocument; diff --git a/packages/lib/server-only/document/viewed-document.ts b/packages/lib/server-only/document/viewed-document.ts index 3e895882d..9722b4fbf 100644 --- a/packages/lib/server-only/document/viewed-document.ts +++ b/packages/lib/server-only/document/viewed-document.ts @@ -5,7 +5,7 @@ import { prisma } from '@documenso/prisma'; import { ReadStatus } from '@documenso/prisma/client'; import { WebhookTriggerEvents } from '@documenso/prisma/client'; -import { triggerWebhook } from '../../universal/trigger-webhook'; +import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; import { getDocumentAndRecipientByToken } from './get-document-by-token'; export type ViewedDocumentOptions = { @@ -59,7 +59,9 @@ export const viewedDocument = async ({ token, requestMetadata }: ViewedDocumentO const document = await getDocumentAndRecipientByToken({ token }); await triggerWebhook({ - eventTrigger: WebhookTriggerEvents.DOCUMENT_OPENED, - documentData: document, + event: WebhookTriggerEvents.DOCUMENT_OPENED, + data: document, + userId: document.userId, + teamId: document.teamId ?? undefined, }); }; diff --git a/packages/lib/server-only/webhooks/get-all-webhooks-by-event-trigger.ts b/packages/lib/server-only/webhooks/get-all-webhooks-by-event-trigger.ts index 9ce31477a..82882c69f 100644 --- a/packages/lib/server-only/webhooks/get-all-webhooks-by-event-trigger.ts +++ b/packages/lib/server-only/webhooks/get-all-webhooks-by-event-trigger.ts @@ -2,16 +2,35 @@ import { prisma } from '@documenso/prisma'; import type { WebhookTriggerEvents } from '@documenso/prisma/client'; export type GetAllWebhooksByEventTriggerOptions = { - eventTrigger: WebhookTriggerEvents; + event: WebhookTriggerEvents; + userId: number; + teamId?: number; }; export const getAllWebhooksByEventTrigger = async ({ - eventTrigger, + event, + userId, + teamId, }: GetAllWebhooksByEventTriggerOptions) => { return prisma.webhook.findMany({ where: { + ...(teamId + ? { + team: { + id: teamId, + members: { + some: { + userId, + }, + }, + }, + } + : { + userId, + teamId: null, + }), eventTriggers: { - has: eventTrigger, + has: event, }, enabled: true, }, diff --git a/packages/lib/server-only/webhooks/trigger/execute-webhook.ts b/packages/lib/server-only/webhooks/trigger/execute-webhook.ts new file mode 100644 index 000000000..cfc828a7f --- /dev/null +++ b/packages/lib/server-only/webhooks/trigger/execute-webhook.ts @@ -0,0 +1,58 @@ +import { prisma } from '@documenso/prisma'; +import { + Prisma, + type Webhook, + WebhookCallStatus, + type WebhookTriggerEvents, +} from '@documenso/prisma/client'; + +export type ExecuteWebhookOptions = { + event: WebhookTriggerEvents; + webhook: Webhook; + data: unknown; +}; + +export const executeWebhook = async ({ event, webhook, data }: ExecuteWebhookOptions) => { + const { webhookUrl: url, secret } = webhook; + + console.log('Executing webhook', { event, url }); + + const payload = { + event, + payload: data, + createdAt: new Date().toISOString(), + webhookEndpoint: url, + }; + + const response = await fetch(url, { + method: 'POST', + body: JSON.stringify(payload), + headers: { + 'Content-Type': 'application/json', + 'X-Documenso-Secret': secret ?? '', + }, + }); + + const body = await response.text(); + + let responseBody: Prisma.InputJsonValue | Prisma.JsonNullValueInput = Prisma.JsonNull; + + try { + responseBody = JSON.parse(body); + } catch (err) { + responseBody = body; + } + + await prisma.webhookCall.create({ + data: { + url, + event, + status: response.ok ? WebhookCallStatus.SUCCESS : WebhookCallStatus.FAILED, + requestBody: payload as Prisma.InputJsonValue, + responseCode: response.status, + responseBody, + responseHeaders: Object.fromEntries(response.headers.entries()), + webhookId: webhook.id, + }, + }); +}; diff --git a/packages/lib/server-only/webhooks/trigger/handler.ts b/packages/lib/server-only/webhooks/trigger/handler.ts new file mode 100644 index 000000000..4e705efea --- /dev/null +++ b/packages/lib/server-only/webhooks/trigger/handler.ts @@ -0,0 +1,58 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; + +import { verify } from '../../crypto/verify'; +import { getAllWebhooksByEventTrigger } from '../get-all-webhooks-by-event-trigger'; +import { executeWebhook } from './execute-webhook'; +import { ZTriggerWebhookBodySchema } from './schema'; + +export type HandlerTriggerWebhooksResponse = + | { + success: true; + message: string; + } + | { + success: false; + error: string; + }; + +export const handlerTriggerWebhooks = async ( + req: NextApiRequest, + res: NextApiResponse, +) => { + const signature = req.headers['x-webhook-signature']; + + if (typeof signature !== 'string') { + console.log('Missing signature'); + return res.status(400).json({ success: false, error: 'Missing signature' }); + } + + const valid = verify(req.body, signature); + + if (!valid) { + console.log('Invalid signature'); + return res.status(400).json({ success: false, error: 'Invalid signature' }); + } + + const result = ZTriggerWebhookBodySchema.safeParse(req.body); + + if (!result.success) { + console.log('Invalid request body'); + return res.status(400).json({ success: false, error: 'Invalid request body' }); + } + + const { event, data, userId, teamId } = result.data; + + const allWebhooks = await getAllWebhooksByEventTrigger({ event, userId, teamId }); + + await Promise.allSettled( + allWebhooks.map(async (webhook) => + executeWebhook({ + event, + webhook, + data, + }), + ), + ); + + return res.status(200).json({ success: true, message: 'Webhooks executed successfully' }); +}; diff --git a/packages/lib/server-only/webhooks/trigger/schema.ts b/packages/lib/server-only/webhooks/trigger/schema.ts new file mode 100644 index 000000000..ee6d0e48d --- /dev/null +++ b/packages/lib/server-only/webhooks/trigger/schema.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +import { WebhookTriggerEvents } from '@documenso/prisma/client'; + +export const ZTriggerWebhookBodySchema = z.object({ + event: z.nativeEnum(WebhookTriggerEvents), + data: z.unknown(), + userId: z.number(), + teamId: z.number().optional(), +}); + +export type TTriggerWebhookBodySchema = z.infer; diff --git a/packages/lib/server-only/webhooks/trigger/trigger-webhook.ts b/packages/lib/server-only/webhooks/trigger/trigger-webhook.ts new file mode 100644 index 000000000..d43d227ea --- /dev/null +++ b/packages/lib/server-only/webhooks/trigger/trigger-webhook.ts @@ -0,0 +1,40 @@ +import type { WebhookTriggerEvents } from '@documenso/prisma/client'; + +import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app'; +import { sign } from '../../crypto/sign'; + +export type TriggerWebhookOptions = { + event: WebhookTriggerEvents; + data: Record; + userId: number; + teamId?: number; +}; + +export const triggerWebhook = async ({ event, data, userId, teamId }: TriggerWebhookOptions) => { + try { + const body = { + event, + data, + userId, + teamId, + }; + + const signature = sign(body); + + await Promise.race([ + fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/api/webhook/trigger`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-webhook-signature': signature, + }, + body: JSON.stringify(body), + }), + new Promise((_, reject) => { + setTimeout(() => reject(new Error('Request timeout')), 500); + }), + ]).catch(() => null); + } catch (err) { + throw new Error(`Failed to trigger webhook`); + } +}; diff --git a/packages/lib/universal/post-webhook-payload.ts b/packages/lib/universal/post-webhook-payload.ts deleted file mode 100644 index 80ddea80d..000000000 --- a/packages/lib/universal/post-webhook-payload.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { Document, Webhook } from '@documenso/prisma/client'; - -export type PostWebhookPayloadOptions = { - webhookData: Pick; - documentData: Document; -}; - -export const postWebhookPayload = async ({ - webhookData, - documentData, -}: PostWebhookPayloadOptions) => { - const { webhookUrl, secret } = webhookData; - - const payload = { - event: webhookData.eventTriggers.toString(), - createdAt: new Date().toISOString(), - webhookEndpoint: webhookUrl, - payload: documentData, - }; - - const response = await fetch(webhookUrl, { - method: 'POST', - body: JSON.stringify(payload), - headers: { - 'Content-Type': 'application/json', - 'X-Documenso-Secret': secret ?? '', - }, - }); - - if (!response.ok) { - throw new Error(`Webhook failed with the status code ${response.status}`); - } - - return { - status: response.status, - statusText: response.statusText, - message: 'Webhook sent successfully', - }; -}; diff --git a/packages/lib/universal/trigger-webhook.ts b/packages/lib/universal/trigger-webhook.ts deleted file mode 100644 index 72484d6c3..000000000 --- a/packages/lib/universal/trigger-webhook.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { Document, WebhookTriggerEvents } from '@documenso/prisma/client'; - -import { getAllWebhooksByEventTrigger } from '../server-only/webhooks/get-all-webhooks-by-event-trigger'; -import { postWebhookPayload } from './post-webhook-payload'; - -export type TriggerWebhookOptions = { - eventTrigger: WebhookTriggerEvents; - documentData: Document; -}; - -export const triggerWebhook = async ({ eventTrigger, documentData }: TriggerWebhookOptions) => { - try { - const allWebhooks = await getAllWebhooksByEventTrigger({ eventTrigger }); - - const webhookPromises = allWebhooks.map((webhook) => { - const { webhookUrl, secret } = webhook; - - postWebhookPayload({ - webhookData: { webhookUrl, secret, eventTriggers: [eventTrigger] }, - documentData, - }).catch((_err) => { - throw new Error(`Failed to send webhook to ${webhookUrl}`); - }); - }); - - return Promise.all(webhookPromises); - } catch (err) { - throw new Error(`Failed to trigger webhook`); - } -}; diff --git a/packages/prisma/migrations/20240227031228_add_event_to_webhook_call_model/migration.sql b/packages/prisma/migrations/20240227031228_add_event_to_webhook_call_model/migration.sql new file mode 100644 index 000000000..a56b5750a --- /dev/null +++ b/packages/prisma/migrations/20240227031228_add_event_to_webhook_call_model/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Added the required column `event` to the `WebhookCall` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "WebhookCall" ADD COLUMN "event" "WebhookTriggerEvents" NOT NULL; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 76892ea59..873d5ff63 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -126,16 +126,17 @@ enum WebhookCallStatus { } model WebhookCall { - id String @id @default(cuid()) + id String @id @default(cuid()) status WebhookCallStatus url String + event WebhookTriggerEvents requestBody Json responseCode Int responseHeaders Json? responseBody Json? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) webhookId String - webhook Webhook @relation(fields: [webhookId], references: [id], onDelete: Cascade) + webhook Webhook @relation(fields: [webhookId], references: [id], onDelete: Cascade) } enum ApiTokenAlgorithm {