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:
co-authored by
sean-brydon
sean-brydon
Benny Joo
parent
235382213f
commit
3eb5490df9
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user