feat: Use Cloudflare Turnstile in booker (#18755)

* feat: Use Cloudflare Turnstile in booker

* move files arround

* render turnstile widget and add token to store

* use token from body instead of headers

* watch cf token to re-render disabled state

* Update packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx

* Update packages/features/bookings/lib/create-booking.ts

* ensure bool

* add to recuring event

* fix recuring events

---------

Co-authored-by: sean-brydon <sean@cal.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
This commit is contained in:
Keith Williams
2025-01-21 11:27:56 +05:30
committed by GitHub
co-authored by sean-brydon sean-brydon Benny Joo
parent 235382213f
commit 3eb5490df9
14 changed files with 56 additions and 3 deletions
+1
View File
@@ -242,6 +242,7 @@ E2E_TEST_MAILHOG_ENABLED=
# Cloudflare Turnstile
NEXT_PUBLIC_CLOUDFLARE_SITEKEY=
NEXT_PUBLIC_CLOUDFLARE_USE_TURNSTILE_IN_BOOKER=
CLOUDFLARE_TURNSTILE_SECRET=
# Set the following value to true if you wish to enable Team Impersonation
+1 -1
View File
@@ -58,7 +58,7 @@ const signupSchema = apiSignupSchema.extend({
cfToken: z.string().optional(),
});
const TurnstileCaptcha = dynamic(() => import("@components/auth/Turnstile"), { ssr: false });
const TurnstileCaptcha = dynamic(() => import("@calcom/features/auth/Turnstile"), { ssr: false });
type FormValues = z.infer<typeof signupSchema>;
+8
View File
@@ -6,10 +6,18 @@ import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import getIP from "@calcom/lib/getIP";
import { defaultResponder } from "@calcom/lib/server";
import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken";
async function handler(req: NextApiRequest & { userId?: number }, res: NextApiResponse) {
const userIp = getIP(req);
if (process.env.NEXT_PUBLIC_CLOUDFLARE_USE_TURNSTILE_IN_BOOKER === "1") {
await checkCfTurnstileToken({
token: req.body["cfToken"] as string,
remoteIp: userIp,
});
}
await checkRateLimitAndThrowError({
rateLimitingType: "core",
identifier: userIp,
@@ -6,12 +6,20 @@ import type { BookingResponse } from "@calcom/features/bookings/types";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import getIP from "@calcom/lib/getIP";
import { defaultResponder } from "@calcom/lib/server";
import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken";
// @TODO: Didn't look at the contents of this function in order to not break old booking page.
async function handler(req: NextApiRequest & { userId?: number }, res: NextApiResponse) {
const userIp = getIP(req);
if (process.env.NEXT_PUBLIC_CLOUDFLARE_USE_TURNSTILE_IN_BOOKER === "1") {
await checkCfTurnstileToken({
token: req.body[0]["cfToken"] as string,
remoteIp: userIp,
});
}
await checkRateLimitAndThrowError({
rateLimitingType: "core",
identifier: userIp,
@@ -72,6 +72,7 @@ const BookerComponent = ({
areInstantMeetingParametersSet = false,
userLocale,
hasValidLicense,
renderCaptcha,
}: BookerProps & WrappedBookerProps) => {
const searchParams = useCompatSearchParams();
const isPlatformBookerEmbed = useIsPlatformBookerEmbed();
@@ -166,6 +167,7 @@ const BookerComponent = ({
return bookerState === "booking" ? (
<BookEventForm
key={key}
renderCaptcha={renderCaptcha}
onCancel={() => {
setSelectedTimeslot(null);
if (seatedEventData.bookingUid) {
@@ -1,12 +1,18 @@
import type { TFunction } from "next-i18next";
import { Trans } from "next-i18next";
import dynamic from "next/dynamic";
import Link from "next/link";
import { useMemo, useState } from "react";
import type { FieldError } from "react-hook-form";
import { useIsPlatformBookerEmbed } from "@calcom/atoms/monorepo";
import type { BookerEvent } from "@calcom/features/bookings/types";
import { WEBSITE_PRIVACY_POLICY_URL, WEBSITE_TERMS_URL } from "@calcom/lib/constants";
import {
WEBSITE_PRIVACY_POLICY_URL,
WEBSITE_TERMS_URL,
CLOUDFLARE_SITE_ID,
CLOUDFLARE_USE_TURNSTILE_IN_BOOKER,
} from "@calcom/lib/constants";
import { getPaymentAppData } from "@calcom/lib/getPaymentAppData";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Alert, Button, EmptyScreen, Form } from "@calcom/ui";
@@ -17,6 +23,8 @@ import type { IUseBookingErrors, IUseBookingLoadingStates } from "../hooks/useBo
import { BookingFields } from "./BookingFields";
import { FormSkeleton } from "./Skeleton";
const TurnstileCaptcha = dynamic(() => import("@calcom/features/auth/Turnstile"), { ssr: false });
type BookEventFormProps = {
onCancel?: () => void;
onSubmit: () => void;
@@ -29,6 +37,7 @@ type BookEventFormProps = {
extraOptions: Record<string, string | string[]>;
isPlatform?: boolean;
isVerificationCodeSending: boolean;
renderCaptcha?: boolean;
};
export const BookEventForm = ({
@@ -45,6 +54,7 @@ export const BookEventForm = ({
extraOptions,
isVerificationCodeSending,
isPlatform = false,
renderCaptcha,
}: Omit<BookEventFormProps, "event"> & {
eventQuery: {
isError: boolean;
@@ -61,6 +71,10 @@ export const BookEventForm = ({
const isInstantMeeting = useBookerStore((state) => state.isInstantMeeting);
const isPlatformBookerEmbed = useIsPlatformBookerEmbed();
// Cloudflare Turnstile Captcha
const shouldRenderCaptcha =
renderCaptcha && CLOUDFLARE_SITE_ID && CLOUDFLARE_USE_TURNSTILE_IN_BOOKER === "1";
const [responseVercelIdHeader] = useState<string | null>(null);
const { t } = useLocale();
@@ -88,6 +102,8 @@ export const BookEventForm = ({
return <Alert severity="warning" message={t("error_booking_event")} />;
}
const watchedCfToken = bookingForm.watch("cfToken");
return (
<div className="flex h-full flex-col">
<Form
@@ -120,6 +136,15 @@ export const BookEventForm = ({
/>
</div>
)}
{/* Cloudflare Turnstile Captcha */}
{shouldRenderCaptcha ? (
<TurnstileCaptcha
appearance="interaction-only"
onVerify={(token) => {
bookingForm.setValue("cfToken", token);
}}
/>
) : null}
{!isPlatform && (
<div className="text-subtle my-3 w-full text-xs">
<Trans
@@ -143,6 +168,7 @@ export const BookEventForm = ({
/>
</div>
)}
{isPlatformBookerEmbed && (
<div className="text-subtle my-3 w-full text-xs">
{t("proceeding_agreement")}{" "}
@@ -176,9 +202,11 @@ export const BookEventForm = ({
{t("back")}
</Button>
)}
<Button
type="submit"
color="primary"
disabled={!!shouldRenderCaptcha && !watchedCfToken}
loading={
loadingStates.creatingBooking ||
loadingStates.creatingRecurringBooking ||
@@ -60,6 +60,7 @@ export const useBookingForm = ({
// Key is not really part of form values, but only used to have a key
// to set generic error messages on. Needed until RHF has implemented root error keys.
globalError: undefined;
cfToken?: string;
};
const isRescheduling = !!rescheduleUid && !!bookingData;
@@ -118,6 +118,7 @@ export type WrappedBookerPropsMain = {
bookerLayout: UseBookerLayoutType;
verifyEmail: UseVerifyEmailReturnType;
customClassNames?: CustomClassNames;
renderCaptcha?: boolean;
};
export type WrappedBookerPropsForPlatform = WrappedBookerPropsMain & {
+1
View File
@@ -159,6 +159,7 @@ export const BOOKER_NUMBER_OF_DAYS_TO_LOAD = parseInt(
);
export const CLOUDFLARE_SITE_ID = process.env.NEXT_PUBLIC_CLOUDFLARE_SITEKEY;
export const CLOUDFLARE_USE_TURNSTILE_IN_BOOKER = process.env.NEXT_PUBLIC_CLOUDFLARE_USE_TURNSTILE_IN_BOOKER;
export const MINIMUM_NUMBER_OF_ORG_SEATS = 30;
export const ORG_SELF_SERVE_ENABLED = process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED === "1";
export const ORG_MINIMUM_PUBLISHED_TEAMS_SELF_SERVE = 0;
+1 -1
View File
@@ -3,7 +3,7 @@ import { HttpError } from "../http-error";
const TURNSTILE_SECRET_ID = process.env.CLOUDFLARE_TURNSTILE_SECRET;
export async function checkCfTurnstileToken({ token, remoteIp }: { token?: string; remoteIp: string }) {
// This means the instant doesnt have turnstile enabled - we skip the check and just return success.
// This means the instance doesnt have turnstile enabled - we skip the check and just return success.
// OR the instance is running in CI so we skip these checks also
if (!TURNSTILE_SECRET_ID || !!process.env.NEXT_PUBLIC_IS_E2E) {
return {
@@ -232,6 +232,7 @@ export const BookerWebWrapper = (props: BookerWebWrapperAtomProps) => {
isPlatform={false}
areInstantMeetingParametersSet={areInstantMeetingParametersSet}
userLocale={session?.user.locale}
renderCaptcha
/>
);
};
+1
View File
@@ -270,6 +270,7 @@ export const bookingCreateBodySchema = z.object({
routingFormResponseId: z.number().optional(),
skipContactOwner: z.boolean().optional(),
crmAppSlug: z.string().nullish().optional(),
cfToken: z.string().nullish().optional(),
/**
* Holds the corrected responses of the Form for a booking, provided during rerouting
+1
View File
@@ -357,6 +357,7 @@
"NEXT_PUBLIC_TEAM_IMPERSONATION",
"NEXT_PUBLIC_VERCEL_URL",
"NEXT_PUBLIC_CLOUDFLARE_SITEKEY",
"NEXT_PUBLIC_CLOUDFLARE_USE_TURNSTILE_IN_BOOKER",
"NEXT_PUBLIC_VERCEL_ENV",
"NEXT_PUBLIC_VERCEL_BRANCH_URL",
"NEXT_PUBLIC_GTM_ID",