Skip to content

Commit 6253216

Browse files
Webhook polishing (#2987)
1 parent c5a40f6 commit 6253216

File tree

13 files changed

+149
-11
lines changed

13 files changed

+149
-11
lines changed

apps/web/components/webhook/WebhookDialogForm.tsx

+65-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { useState } from "react";
1+
import { useEffect, useState } from "react";
22
import { Controller, useForm } from "react-hook-form";
33

44
import { useLocale } from "@calcom/lib/hooks/useLocale";
55
import showToast from "@calcom/lib/notification";
6+
import { Tooltip } from "@calcom/ui";
67
import Button from "@calcom/ui/Button";
78
import { DialogFooter } from "@calcom/ui/Dialog";
89
import Switch from "@calcom/ui/Switch";
@@ -29,20 +30,38 @@ export default function WebhookDialogForm(props: {
2930
subscriberUrl: "",
3031
active: true,
3132
payloadTemplate: null,
33+
secret: null,
3234
} as Omit<TWebhook, "userId" | "createdAt" | "eventTypeId" | "appId">,
3335
} = props;
3436

3537
const [useCustomPayloadTemplate, setUseCustomPayloadTemplate] = useState(!!defaultValues.payloadTemplate);
38+
const [changeSecret, setChangeSecret] = useState(false);
39+
const [newSecret, setNewSecret] = useState("");
40+
const hasSecretKey = !!defaultValues.secret;
41+
const currentSecret = defaultValues.secret;
3642

3743
const form = useForm({
3844
defaultValues,
3945
});
46+
47+
const handleInput = (event: React.FormEvent<HTMLInputElement>) => {
48+
setNewSecret(event.currentTarget.value);
49+
};
50+
51+
useEffect(() => {
52+
if (changeSecret) {
53+
form.unregister("secret", { keepDefaultValue: false });
54+
}
55+
}, [changeSecret]);
56+
4057
return (
4158
<Form
4259
data-testid="WebhookDialogForm"
4360
form={form}
4461
handleSubmit={async (event) => {
45-
const e = { ...event, eventTypeId: props.eventTypeId };
62+
const e = changeSecret
63+
? { ...event, eventTypeId: props.eventTypeId }
64+
: { ...event, secret: currentSecret, eventTypeId: props.eventTypeId };
4665
if (!useCustomPayloadTemplate && event.payloadTemplate) {
4766
event.payloadTemplate = null;
4867
}
@@ -119,6 +138,50 @@ export default function WebhookDialogForm(props: {
119138
))}
120139
</InputGroupBox>
121140
</fieldset>
141+
<fieldset className="space-y-2">
142+
{!!hasSecretKey && !changeSecret && (
143+
<>
144+
<FieldsetLegend>{t("secret")}</FieldsetLegend>
145+
<div className="rounded-sm bg-gray-50 p-2 text-xs text-neutral-900">
146+
{t("forgotten_secret_description")}
147+
</div>
148+
<Button
149+
color="secondary"
150+
type="button"
151+
className="py-1 text-xs"
152+
onClick={() => {
153+
setChangeSecret(true);
154+
}}>
155+
{t("change_secret")}
156+
</Button>
157+
</>
158+
)}
159+
{!!hasSecretKey && changeSecret && (
160+
<>
161+
<TextField
162+
autoComplete="off"
163+
label={t("secret")}
164+
{...form.register("secret")}
165+
value={newSecret}
166+
onChange={handleInput}
167+
type="text"
168+
placeholder={t("leave_blank_to_remove_secret")}
169+
/>
170+
<Button
171+
color="secondary"
172+
type="button"
173+
className="py-1 text-xs"
174+
onClick={() => {
175+
setChangeSecret(false);
176+
}}>
177+
{t("cancel")}
178+
</Button>
179+
</>
180+
)}
181+
{!hasSecretKey && (
182+
<TextField autoComplete="off" label={t("secret")} {...form.register("secret")} type="text" />
183+
)}
184+
</fieldset>
122185
<fieldset className="space-y-2">
123186
<FieldsetLegend>{t("payload_template")}</FieldsetLegend>
124187
<div className="space-x-3 text-sm rtl:space-x-reverse">

apps/web/lib/webhooks/sendPayload.tsx

+8
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Webhook } from "@prisma/client";
2+
import { createHmac } from "crypto";
23
import { compile } from "handlebars";
34

45
import type { CalendarEvent } from "@calcom/types/Calendar";
@@ -23,12 +24,14 @@ function jsonParse(jsonString: string) {
2324
}
2425

2526
const sendPayload = async (
27+
secretKey: string | null,
2628
triggerEvent: string,
2729
createdAt: string,
2830
webhook: Pick<Webhook, "subscriberUrl" | "appId" | "payloadTemplate">,
2931
data: CalendarEvent & {
3032
metadata?: { [key: string]: string };
3133
rescheduleUid?: string;
34+
bookingId?: number;
3235
}
3336
) => {
3437
const { subscriberUrl, appId, payloadTemplate: template } = webhook;
@@ -56,10 +59,15 @@ const sendPayload = async (
5659
});
5760
}
5861

62+
const secretSignature = secretKey
63+
? createHmac("sha256", secretKey).update(`${body}`).digest("hex")
64+
: "no-secret-provided";
65+
5966
const response = await fetch(subscriberUrl, {
6067
method: "POST",
6168
headers: {
6269
"Content-Type": contentType,
70+
"X-Cal-Signature-256": secretSignature,
6371
},
6472
body,
6573
});

apps/web/lib/webhooks/subscriptions.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const getWebhooks = async (options: GetSubscriberOptions) => {
3333
subscriberUrl: true,
3434
payloadTemplate: true,
3535
appId: true,
36+
secret: true,
3637
},
3738
});
3839

apps/web/pages/api/book/event.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -795,9 +795,11 @@ async function handler(req: NextApiRequest) {
795795
...evt,
796796
metadata: reqBody.metadata,
797797
});
798+
const bookingId = booking?.id;
798799
const promises = subscribers.map((sub) =>
799-
sendPayload(eventTrigger, new Date().toISOString(), sub, {
800+
sendPayload(sub.secret, eventTrigger, new Date().toISOString(), sub, {
800801
...evt,
802+
bookingId,
801803
rescheduleUid,
802804
metadata: reqBody.metadata,
803805
}).catch((e) => {

apps/web/pages/api/book/request-reschedule.ts

+54-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
import { BookingStatus, User, Booking, Attendee, BookingReference, EventType } from "@prisma/client";
1+
import {
2+
BookingStatus,
3+
User,
4+
Booking,
5+
Attendee,
6+
BookingReference,
7+
EventType,
8+
WebhookTriggerEvents,
9+
} from "@prisma/client";
210
import dayjs from "dayjs";
311
import type { NextApiRequest, NextApiResponse } from "next";
412
import { getSession } from "next-auth/react";
@@ -10,10 +18,13 @@ import { CalendarEventBuilder } from "@calcom/core/builders/CalendarEvent/builde
1018
import { CalendarEventDirector } from "@calcom/core/builders/CalendarEvent/director";
1119
import { deleteMeeting } from "@calcom/core/videoClient";
1220
import { sendRequestRescheduleEmail } from "@calcom/emails";
21+
import { isPrismaObjOrUndefined } from "@calcom/lib";
1322
import { getTranslation } from "@calcom/lib/server/i18n";
14-
import { Person } from "@calcom/types/Calendar";
23+
import { CalendarEvent, Person } from "@calcom/types/Calendar";
1524

1625
import prisma from "@lib/prisma";
26+
import sendPayload from "@lib/webhooks/sendPayload";
27+
import getWebhooks from "@lib/webhooks/subscriptions";
1728

1829
export type RescheduleResponse = Booking & {
1930
attendees: Attendee[];
@@ -69,13 +80,15 @@ const handler = async (
6980
id: true,
7081
uid: true,
7182
title: true,
83+
description: true,
7284
startTime: true,
7385
endTime: true,
7486
eventTypeId: true,
7587
location: true,
7688
attendees: true,
7789
references: true,
7890
userId: true,
91+
customInputs: true,
7992
dynamicEventSlugRef: true,
8093
dynamicGroupSlugRef: true,
8194
destinationCalendar: true,
@@ -216,6 +229,45 @@ const handler = async (
216229
await sendRequestRescheduleEmail(builder.calendarEvent, {
217230
rescheduleLink: builder.rescheduleLink,
218231
});
232+
233+
const evt: CalendarEvent = {
234+
title: bookingToReschedule?.title,
235+
type: event && event.title ? event.title : bookingToReschedule.title,
236+
description: bookingToReschedule?.description || "",
237+
customInputs: isPrismaObjOrUndefined(bookingToReschedule.customInputs),
238+
startTime: bookingToReschedule?.startTime ? dayjs(bookingToReschedule.startTime).format() : "",
239+
endTime: bookingToReschedule?.endTime ? dayjs(bookingToReschedule.endTime).format() : "",
240+
organizer: userOwnerAsPeopleType,
241+
attendees: usersToPeopleType(
242+
// username field doesn't exists on attendee but could be in the future
243+
bookingToReschedule.attendees as unknown as PersonAttendeeCommonFields[],
244+
tAttendees
245+
),
246+
uid: bookingToReschedule?.uid,
247+
location: bookingToReschedule?.location,
248+
destinationCalendar:
249+
bookingToReschedule?.destinationCalendar || bookingToReschedule?.destinationCalendar,
250+
cancellationReason: `Please reschedule. ${cancellationReason}`, // TODO::Add i18-next for this
251+
};
252+
253+
// Send webhook
254+
const eventTrigger: WebhookTriggerEvents = "BOOKING_CANCELLED";
255+
// Send Webhook call if hooked to BOOKING.CANCELLED
256+
const subscriberOptions = {
257+
userId: bookingToReschedule.userId,
258+
eventTypeId: (bookingToReschedule.eventTypeId as number) || 0,
259+
triggerEvent: eventTrigger,
260+
};
261+
const webhooks = await getWebhooks(subscriberOptions);
262+
const promises = webhooks.map((webhook) =>
263+
sendPayload(webhook.secret, eventTrigger, new Date().toISOString(), webhook, evt).catch((e) => {
264+
console.error(
265+
`Error executing webhook for event: ${eventTrigger}, URL: ${webhook.subscriberUrl}`,
266+
e
267+
);
268+
})
269+
);
270+
await Promise.all(promises);
219271
}
220272

221273
return res.status(200).json(bookingToReschedule);

apps/web/pages/api/cancel.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
139139
};
140140
const webhooks = await getWebhooks(subscriberOptions);
141141
const promises = webhooks.map((webhook) =>
142-
sendPayload(eventTrigger, new Date().toISOString(), webhook, evt).catch((e) => {
142+
sendPayload(webhook.secret, eventTrigger, new Date().toISOString(), webhook, evt).catch((e) => {
143143
console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${webhook.subscriberUrl}`, e);
144144
})
145145
);

apps/web/playwright/integrations.test.ts

+3
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ test.describe("Integrations", () => {
3333

3434
await page.fill('[name="subscriberUrl"]', webhookReceiver.url);
3535

36+
await page.fill('[name="secret"]', "secret");
37+
3638
await page.click("[type=submit]");
3739

3840
// dialog is closed
@@ -71,6 +73,7 @@ test.describe("Integrations", () => {
7173
body.payload.organizer.timeZone = dynamic;
7274
body.payload.organizer.language = dynamic;
7375
body.payload.uid = dynamic;
76+
body.payload.bookingId = dynamic;
7477
body.payload.additionalInformation = dynamic;
7578

7679
// if we change the shape of our webhooks, we can simply update this by clicking `u`
Original file line numberDiff line numberDiff line change
@@ -1 +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]"}}
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]","bookingId":"[redacted/dynamic]","metadata":{},"additionalInformation":"[redacted/dynamic]"}}

apps/web/public/static/locales/en/common.json

+5
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,9 @@
184184
"getting_started": "Getting Started",
185185
"15min_meeting": "15 Min Meeting",
186186
"30min_meeting": "30 Min Meeting",
187+
"secret":"Secret",
188+
"leave_blank_to_remove_secret":"Leave blank to remove secret",
189+
"webhook_secret_key_description":"Ensure your server is only receiving the expected Cal.com requests for security reasons",
187190
"secret_meeting": "Secret Meeting",
188191
"login_instead": "Login instead",
189192
"already_have_an_account": "Already have an account?",
@@ -393,7 +396,9 @@
393396
"your_old_password": "Your old password",
394397
"current_password": "Current Password",
395398
"change_password": "Change Password",
399+
"change_secret": "Change Secret",
396400
"new_password_matches_old_password": "New password matches your old password. Please choose a different password.",
401+
"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",
397402
"current_incorrect_password": "Current password is incorrect",
398403
"incorrect_password": "Password is incorrect.",
399404
"1_on_1": "1-on-1",

apps/web/server/routers/viewer/webhook.tsx

+4-4
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ export const webhookRouter = createProtectedRouter()
9797
payloadTemplate: z.string().nullable(),
9898
eventTypeId: z.number().optional(),
9999
appId: z.string().optional().nullable(),
100+
secret: z.string().optional().nullable(),
100101
}),
101102
async resolve({ ctx, input }) {
102103
if (input.eventTypeId) {
@@ -125,6 +126,7 @@ export const webhookRouter = createProtectedRouter()
125126
payloadTemplate: z.string().nullable(),
126127
eventTypeId: z.number().optional(),
127128
appId: z.string().optional().nullable(),
129+
secret: z.string().optional().nullable(),
128130
}),
129131
async resolve({ ctx, input }) {
130132
const { id, ...data } = input;
@@ -161,7 +163,6 @@ export const webhookRouter = createProtectedRouter()
161163
}),
162164
async resolve({ ctx, input }) {
163165
const { id } = input;
164-
165166
input.eventTypeId
166167
? await ctx.prisma.eventType.update({
167168
where: {
@@ -207,7 +208,6 @@ export const webhookRouter = createProtectedRouter()
207208
};
208209

209210
const data = {
210-
triggerEvent: "PING",
211211
type: "Test",
212212
title: "Test trigger event",
213213
description: "",
@@ -230,8 +230,8 @@ export const webhookRouter = createProtectedRouter()
230230
};
231231

232232
try {
233-
const webhook = { subscriberUrl: url, payloadTemplate, appId: null };
234-
return await sendPayload(type, new Date().toISOString(), webhook, data);
233+
const webhook = { subscriberUrl: url, payloadTemplate, appId: null, secret: null };
234+
return await sendPayload(null, type, new Date().toISOString(), webhook, data);
235235
} catch (_err) {
236236
const error = getErrorFromUnknown(_err);
237237
return {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "Webhook" ADD COLUMN "secret" TEXT;

packages/prisma/schema.prisma

+1
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,7 @@ model Webhook {
397397
eventType EventType? @relation(fields: [eventTypeId], references: [id], onDelete: Cascade)
398398
app App? @relation(fields: [appId], references: [slug], onDelete: Cascade)
399399
appId String?
400+
secret String?
400401
}
401402

402403
model Impersonations {

packages/prisma/zod/webhook.ts

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export const _WebhookModel = z.object({
1313
active: z.boolean(),
1414
eventTriggers: z.nativeEnum(WebhookTriggerEvents).array(),
1515
appId: z.string().nullish(),
16+
secret: z.string().nullish(),
1617
})
1718

1819
export interface CompleteWebhook extends z.infer<typeof _WebhookModel> {

0 commit comments

Comments
 (0)