diff --git a/apps/web/components/webhook/WebhookDialogForm.tsx b/apps/web/components/webhook/WebhookDialogForm.tsx index b00266269c..9fa9487952 100644 --- a/apps/web/components/webhook/WebhookDialogForm.tsx +++ b/apps/web/components/webhook/WebhookDialogForm.tsx @@ -1,8 +1,9 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import showToast from "@calcom/lib/notification"; +import { Tooltip } from "@calcom/ui"; import Button from "@calcom/ui/Button"; import { DialogFooter } from "@calcom/ui/Dialog"; import Switch from "@calcom/ui/Switch"; @@ -29,20 +30,38 @@ export default function WebhookDialogForm(props: { subscriberUrl: "", active: true, payloadTemplate: null, + secret: null, } as Omit, } = props; const [useCustomPayloadTemplate, setUseCustomPayloadTemplate] = useState(!!defaultValues.payloadTemplate); + const [changeSecret, setChangeSecret] = useState(false); + const [newSecret, setNewSecret] = useState(""); + const hasSecretKey = !!defaultValues.secret; + const currentSecret = defaultValues.secret; const form = useForm({ defaultValues, }); + + const handleInput = (event: React.FormEvent) => { + setNewSecret(event.currentTarget.value); + }; + + useEffect(() => { + if (changeSecret) { + form.unregister("secret", { keepDefaultValue: false }); + } + }, [changeSecret]); + return (
{ - const e = { ...event, eventTypeId: props.eventTypeId }; + const e = changeSecret + ? { ...event, eventTypeId: props.eventTypeId } + : { ...event, secret: currentSecret, eventTypeId: props.eventTypeId }; if (!useCustomPayloadTemplate && event.payloadTemplate) { event.payloadTemplate = null; } @@ -119,6 +138,50 @@ export default function WebhookDialogForm(props: { ))} +
+ {!!hasSecretKey && !changeSecret && ( + <> + {t("secret")} +
+ {t("forgotten_secret_description")} +
+ + + )} + {!!hasSecretKey && changeSecret && ( + <> + + + + )} + {!hasSecretKey && ( + + )} +
{t("payload_template")}
diff --git a/apps/web/lib/webhooks/sendPayload.tsx b/apps/web/lib/webhooks/sendPayload.tsx index e6a81cb016..4bfa6b440c 100644 --- a/apps/web/lib/webhooks/sendPayload.tsx +++ b/apps/web/lib/webhooks/sendPayload.tsx @@ -1,4 +1,5 @@ import { Webhook } from "@prisma/client"; +import { createHmac } from "crypto"; import { compile } from "handlebars"; import type { CalendarEvent } from "@calcom/types/Calendar"; @@ -23,12 +24,14 @@ function jsonParse(jsonString: string) { } const sendPayload = async ( + secretKey: string | null, triggerEvent: string, createdAt: string, webhook: Pick, data: CalendarEvent & { metadata?: { [key: string]: string }; rescheduleUid?: string; + bookingId?: number; } ) => { const { subscriberUrl, appId, payloadTemplate: template } = webhook; @@ -56,10 +59,15 @@ const sendPayload = async ( }); } + const secretSignature = secretKey + ? createHmac("sha256", secretKey).update(`${body}`).digest("hex") + : "no-secret-provided"; + const response = await fetch(subscriberUrl, { method: "POST", headers: { "Content-Type": contentType, + "X-Cal-Signature-256": secretSignature, }, body, }); diff --git a/apps/web/lib/webhooks/subscriptions.tsx b/apps/web/lib/webhooks/subscriptions.tsx index 25db083a39..a6a5a5ae8b 100644 --- a/apps/web/lib/webhooks/subscriptions.tsx +++ b/apps/web/lib/webhooks/subscriptions.tsx @@ -33,6 +33,7 @@ const getWebhooks = async (options: GetSubscriberOptions) => { subscriberUrl: true, payloadTemplate: true, appId: true, + secret: true, }, }); diff --git a/apps/web/pages/api/book/event.ts b/apps/web/pages/api/book/event.ts index ec22a0e0db..1399d40962 100644 --- a/apps/web/pages/api/book/event.ts +++ b/apps/web/pages/api/book/event.ts @@ -795,9 +795,11 @@ async function handler(req: NextApiRequest) { ...evt, metadata: reqBody.metadata, }); + const bookingId = booking?.id; const promises = subscribers.map((sub) => - sendPayload(eventTrigger, new Date().toISOString(), sub, { + sendPayload(sub.secret, eventTrigger, new Date().toISOString(), sub, { ...evt, + bookingId, rescheduleUid, metadata: reqBody.metadata, }).catch((e) => { diff --git a/apps/web/pages/api/book/request-reschedule.ts b/apps/web/pages/api/book/request-reschedule.ts index a3f08adcf0..a30effc986 100644 --- a/apps/web/pages/api/book/request-reschedule.ts +++ b/apps/web/pages/api/book/request-reschedule.ts @@ -1,4 +1,12 @@ -import { BookingStatus, User, Booking, Attendee, BookingReference, EventType } from "@prisma/client"; +import { + BookingStatus, + User, + Booking, + Attendee, + BookingReference, + EventType, + WebhookTriggerEvents, +} from "@prisma/client"; import dayjs from "dayjs"; import type { NextApiRequest, NextApiResponse } from "next"; import { getSession } from "next-auth/react"; @@ -10,10 +18,13 @@ import { CalendarEventBuilder } from "@calcom/core/builders/CalendarEvent/builde import { CalendarEventDirector } from "@calcom/core/builders/CalendarEvent/director"; import { deleteMeeting } from "@calcom/core/videoClient"; import { sendRequestRescheduleEmail } from "@calcom/emails"; +import { isPrismaObjOrUndefined } from "@calcom/lib"; import { getTranslation } from "@calcom/lib/server/i18n"; -import { Person } from "@calcom/types/Calendar"; +import { CalendarEvent, Person } from "@calcom/types/Calendar"; import prisma from "@lib/prisma"; +import sendPayload from "@lib/webhooks/sendPayload"; +import getWebhooks from "@lib/webhooks/subscriptions"; export type RescheduleResponse = Booking & { attendees: Attendee[]; @@ -69,6 +80,7 @@ const handler = async ( id: true, uid: true, title: true, + description: true, startTime: true, endTime: true, eventTypeId: true, @@ -76,6 +88,7 @@ const handler = async ( attendees: true, references: true, userId: true, + customInputs: true, dynamicEventSlugRef: true, dynamicGroupSlugRef: true, destinationCalendar: true, @@ -216,6 +229,45 @@ const handler = async ( await sendRequestRescheduleEmail(builder.calendarEvent, { rescheduleLink: builder.rescheduleLink, }); + + const evt: CalendarEvent = { + title: bookingToReschedule?.title, + type: event && event.title ? event.title : bookingToReschedule.title, + description: bookingToReschedule?.description || "", + customInputs: isPrismaObjOrUndefined(bookingToReschedule.customInputs), + startTime: bookingToReschedule?.startTime ? dayjs(bookingToReschedule.startTime).format() : "", + endTime: bookingToReschedule?.endTime ? dayjs(bookingToReschedule.endTime).format() : "", + organizer: userOwnerAsPeopleType, + attendees: usersToPeopleType( + // username field doesn't exists on attendee but could be in the future + bookingToReschedule.attendees as unknown as PersonAttendeeCommonFields[], + tAttendees + ), + uid: bookingToReschedule?.uid, + location: bookingToReschedule?.location, + destinationCalendar: + bookingToReschedule?.destinationCalendar || bookingToReschedule?.destinationCalendar, + cancellationReason: `Please reschedule. ${cancellationReason}`, // TODO::Add i18-next for this + }; + + // Send webhook + const eventTrigger: WebhookTriggerEvents = "BOOKING_CANCELLED"; + // Send Webhook call if hooked to BOOKING.CANCELLED + const subscriberOptions = { + userId: bookingToReschedule.userId, + eventTypeId: (bookingToReschedule.eventTypeId as number) || 0, + triggerEvent: eventTrigger, + }; + const webhooks = await getWebhooks(subscriberOptions); + const promises = webhooks.map((webhook) => + sendPayload(webhook.secret, eventTrigger, new Date().toISOString(), webhook, evt).catch((e) => { + console.error( + `Error executing webhook for event: ${eventTrigger}, URL: ${webhook.subscriberUrl}`, + e + ); + }) + ); + await Promise.all(promises); } return res.status(200).json(bookingToReschedule); diff --git a/apps/web/pages/api/cancel.ts b/apps/web/pages/api/cancel.ts index 4106b993a6..75476a6aad 100644 --- a/apps/web/pages/api/cancel.ts +++ b/apps/web/pages/api/cancel.ts @@ -139,7 +139,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }; const webhooks = await getWebhooks(subscriberOptions); const promises = webhooks.map((webhook) => - sendPayload(eventTrigger, new Date().toISOString(), webhook, evt).catch((e) => { + sendPayload(webhook.secret, eventTrigger, new Date().toISOString(), webhook, evt).catch((e) => { console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${webhook.subscriberUrl}`, e); }) ); diff --git a/apps/web/playwright/integrations.test.ts b/apps/web/playwright/integrations.test.ts index d034779e4a..2d918fcb84 100644 --- a/apps/web/playwright/integrations.test.ts +++ b/apps/web/playwright/integrations.test.ts @@ -33,6 +33,8 @@ test.describe("Integrations", () => { await page.fill('[name="subscriberUrl"]', webhookReceiver.url); + await page.fill('[name="secret"]', "secret"); + await page.click("[type=submit]"); // dialog is closed @@ -71,6 +73,7 @@ test.describe("Integrations", () => { body.payload.organizer.timeZone = dynamic; body.payload.organizer.language = dynamic; body.payload.uid = dynamic; + body.payload.bookingId = dynamic; body.payload.additionalInformation = dynamic; // if we change the shape of our webhooks, we can simply update this by clicking `u` diff --git a/apps/web/playwright/integrations.test.ts-snapshots/webhookResponse-chromium.txt b/apps/web/playwright/integrations.test.ts-snapshots/webhookResponse-chromium.txt index d17c29af6e..b71943b371 100644 --- a/apps/web/playwright/integrations.test.ts-snapshots/webhookResponse-chromium.txt +++ b/apps/web/playwright/integrations.test.ts-snapshots/webhookResponse-chromium.txt @@ -1 +1 @@ -{"triggerEvent":"BOOKING_CREATED","createdAt":"[redacted/dynamic]","payload":{"type":"30 min","title":"30 min between PRO and Test Testson","description":"","additionalNotes":"","customInputs":{},"startTime":"[redacted/dynamic]","endTime":"[redacted/dynamic]","organizer":{"name":"PRO","email":"[redacted/dynamic]","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"},"attendees":[{"email":"test@example.com","name":"Test Testson","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"}],"location":"[redacted/dynamic]","destinationCalendar":null,"hideCalendarNotes":false,"uid":"[redacted/dynamic]","metadata":{},"additionalInformation":"[redacted/dynamic]"}} \ No newline at end of file +{"triggerEvent":"BOOKING_CREATED","createdAt":"[redacted/dynamic]","payload":{"type":"30 min","title":"30 min between PRO and Test Testson","description":"","additionalNotes":"","customInputs":{},"startTime":"[redacted/dynamic]","endTime":"[redacted/dynamic]","organizer":{"name":"PRO","email":"[redacted/dynamic]","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"},"attendees":[{"email":"test@example.com","name":"Test Testson","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"}],"location":"[redacted/dynamic]","destinationCalendar":null,"hideCalendarNotes":false,"uid":"[redacted/dynamic]","bookingId":"[redacted/dynamic]","metadata":{},"additionalInformation":"[redacted/dynamic]"}} \ No newline at end of file diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 1be3cbc985..d99058450e 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -184,6 +184,9 @@ "getting_started": "Getting Started", "15min_meeting": "15 Min Meeting", "30min_meeting": "30 Min Meeting", + "secret":"Secret", + "leave_blank_to_remove_secret":"Leave blank to remove secret", + "webhook_secret_key_description":"Ensure your server is only receiving the expected Cal.com requests for security reasons", "secret_meeting": "Secret Meeting", "login_instead": "Login instead", "already_have_an_account": "Already have an account?", @@ -393,7 +396,9 @@ "your_old_password": "Your old password", "current_password": "Current Password", "change_password": "Change Password", + "change_secret": "Change Secret", "new_password_matches_old_password": "New password matches your old password. Please choose a different password.", + "forgotten_secret_description":"If you have lost or forgotten this secret, you can change it, but be aware that all integrations using this secret will need to be updated", "current_incorrect_password": "Current password is incorrect", "incorrect_password": "Password is incorrect.", "1_on_1": "1-on-1", diff --git a/apps/web/server/routers/viewer/webhook.tsx b/apps/web/server/routers/viewer/webhook.tsx index 2b0991a25f..1456260bd7 100644 --- a/apps/web/server/routers/viewer/webhook.tsx +++ b/apps/web/server/routers/viewer/webhook.tsx @@ -97,6 +97,7 @@ export const webhookRouter = createProtectedRouter() payloadTemplate: z.string().nullable(), eventTypeId: z.number().optional(), appId: z.string().optional().nullable(), + secret: z.string().optional().nullable(), }), async resolve({ ctx, input }) { if (input.eventTypeId) { @@ -125,6 +126,7 @@ export const webhookRouter = createProtectedRouter() payloadTemplate: z.string().nullable(), eventTypeId: z.number().optional(), appId: z.string().optional().nullable(), + secret: z.string().optional().nullable(), }), async resolve({ ctx, input }) { const { id, ...data } = input; @@ -161,7 +163,6 @@ export const webhookRouter = createProtectedRouter() }), async resolve({ ctx, input }) { const { id } = input; - input.eventTypeId ? await ctx.prisma.eventType.update({ where: { @@ -207,7 +208,6 @@ export const webhookRouter = createProtectedRouter() }; const data = { - triggerEvent: "PING", type: "Test", title: "Test trigger event", description: "", @@ -230,8 +230,8 @@ export const webhookRouter = createProtectedRouter() }; try { - const webhook = { subscriberUrl: url, payloadTemplate, appId: null }; - return await sendPayload(type, new Date().toISOString(), webhook, data); + const webhook = { subscriberUrl: url, payloadTemplate, appId: null, secret: null }; + return await sendPayload(null, type, new Date().toISOString(), webhook, data); } catch (_err) { const error = getErrorFromUnknown(_err); return { diff --git a/packages/prisma/migrations/20220614090326_add_webhook_secret/migration.sql b/packages/prisma/migrations/20220614090326_add_webhook_secret/migration.sql new file mode 100644 index 0000000000..0e19058037 --- /dev/null +++ b/packages/prisma/migrations/20220614090326_add_webhook_secret/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Webhook" ADD COLUMN "secret" TEXT; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 9d41a2d201..a669ce467c 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -397,6 +397,7 @@ model Webhook { eventType EventType? @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) app App? @relation(fields: [appId], references: [slug], onDelete: Cascade) appId String? + secret String? } model Impersonations { diff --git a/packages/prisma/zod/webhook.ts b/packages/prisma/zod/webhook.ts index ec61dcd5e1..82ad655492 100755 --- a/packages/prisma/zod/webhook.ts +++ b/packages/prisma/zod/webhook.ts @@ -13,6 +13,7 @@ export const _WebhookModel = z.object({ active: z.boolean(), eventTriggers: z.nativeEnum(WebhookTriggerEvents).array(), appId: z.string().nullish(), + secret: z.string().nullish(), }) export interface CompleteWebhook extends z.infer {