Webhook polishing (#2987)

This commit is contained in:
Syed Ali Shahbaz 2022-06-16 21:51:48 +05:30 committed by GitHub
parent c5a40f6c37
commit 6253216484
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 149 additions and 11 deletions

View File

@ -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<TWebhook, "userId" | "createdAt" | "eventTypeId" | "appId">,
} = 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<HTMLInputElement>) => {
setNewSecret(event.currentTarget.value);
};
useEffect(() => {
if (changeSecret) {
form.unregister("secret", { keepDefaultValue: false });
}
}, [changeSecret]);
return (
<Form
data-testid="WebhookDialogForm"
form={form}
handleSubmit={async (event) => {
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: {
))}
</InputGroupBox>
</fieldset>
<fieldset className="space-y-2">
{!!hasSecretKey && !changeSecret && (
<>
<FieldsetLegend>{t("secret")}</FieldsetLegend>
<div className="rounded-sm bg-gray-50 p-2 text-xs text-neutral-900">
{t("forgotten_secret_description")}
</div>
<Button
color="secondary"
type="button"
className="py-1 text-xs"
onClick={() => {
setChangeSecret(true);
}}>
{t("change_secret")}
</Button>
</>
)}
{!!hasSecretKey && changeSecret && (
<>
<TextField
autoComplete="off"
label={t("secret")}
{...form.register("secret")}
value={newSecret}
onChange={handleInput}
type="text"
placeholder={t("leave_blank_to_remove_secret")}
/>
<Button
color="secondary"
type="button"
className="py-1 text-xs"
onClick={() => {
setChangeSecret(false);
}}>
{t("cancel")}
</Button>
</>
)}
{!hasSecretKey && (
<TextField autoComplete="off" label={t("secret")} {...form.register("secret")} type="text" />
)}
</fieldset>
<fieldset className="space-y-2">
<FieldsetLegend>{t("payload_template")}</FieldsetLegend>
<div className="space-x-3 text-sm rtl:space-x-reverse">

View File

@ -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<Webhook, "subscriberUrl" | "appId" | "payloadTemplate">,
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,
});

View File

@ -33,6 +33,7 @@ const getWebhooks = async (options: GetSubscriberOptions) => {
subscriberUrl: true,
payloadTemplate: true,
appId: true,
secret: true,
},
});

View File

@ -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) => {

View File

@ -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);

View File

@ -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);
})
);

View File

@ -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`

View File

@ -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]"}}
{"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]"}}

View File

@ -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",

View File

@ -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 {

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Webhook" ADD COLUMN "secret" TEXT;

View File

@ -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 {

View File

@ -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<typeof _WebhookModel> {