mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
[CU-8689k2b8c] Migrates google recaptcha to cloudflare turnstile (#1353)
* Migrates google recaptcha to cloudflare turnstile * Includes example env vars * Fixes turnstile when purchasing buzz * Applies code review feedback * Removes console.log
This commit is contained in:
committed by
GitHub
parent
8ac9ddf2bb
commit
4ea8bcd34a
@@ -165,6 +165,11 @@ PADDLE_SECRET_KEY=
|
||||
PADDLE_WEBHOOK_SECRET=
|
||||
NEXT_PUBLIC_PADDLE_TOKEN=
|
||||
NEXT_PUBLIC_DEFAULT_PAYMENT_PROVIDER= # Paddle OR Stripe. Defaults to Stripe if Paddle env. variables are missing.
|
||||
|
||||
# Fingerprint
|
||||
FINGERPRINT_SECRET=61952c5f9c9f1938abcf288bff56021a927a0a829f2e839a7a9fe219c83dca0c # 32 bytes
|
||||
FINGERPRINT_IV=b5f09724c7567e53d47d0a26bfa263e4 # 16 bytes
|
||||
|
||||
# CF Turnstile
|
||||
NEXT_PUBLIC_CLOUDFLARE_TURNSTILE_SITEKEY=1x00000000000000000000BB
|
||||
CLOUDFLARE_TURNSTILE_SECRET=1x0000000000000000000000000000000AA
|
||||
|
||||
Generated
+259
-304
File diff suppressed because it is too large
Load Diff
@@ -73,6 +73,7 @@
|
||||
"@mantine/rte": "^5.10.4",
|
||||
"@mantine/spotlight": "^5.10.4",
|
||||
"@mantine/tiptap": "^5.10.4",
|
||||
"@marsidev/react-turnstile": "^1.0.1",
|
||||
"@meilisearch/instant-meilisearch": "0.13.5",
|
||||
"@microsoft/signalr": "^7.0.10",
|
||||
"@next-auth/prisma-adapter": "^1.0.7",
|
||||
|
||||
@@ -149,7 +149,6 @@ export function OnboardingBuzz() {
|
||||
Done
|
||||
</Button>
|
||||
</Group>
|
||||
<RecaptchaNotice />
|
||||
{showReferral && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
|
||||
@@ -31,14 +31,17 @@ export function OnboardingProfile() {
|
||||
const debouncer = useDebouncer(500);
|
||||
const [username, setUsername] = useState('');
|
||||
const [typing, setTyping] = useState(false);
|
||||
const { data: usernameAvailable, isRefetching: usernameAvailableLoading } =
|
||||
trpc.user.usernameAvailable.useQuery({ username }, { enabled: username.length >= 3 });
|
||||
const {
|
||||
data: usernameAvailable,
|
||||
isRefetching: refetchingUsernameAvailable,
|
||||
isInitialLoading: loadingUsarnameAvailable,
|
||||
} = trpc.user.usernameAvailable.useQuery({ username }, { enabled: username.length >= 3 });
|
||||
|
||||
const form = useForm({
|
||||
schema,
|
||||
mode: 'onChange',
|
||||
shouldUnregister: false,
|
||||
defaultValues: { ...currentUser },
|
||||
defaultValues: { email: currentUser?.email, username: currentUser?.username },
|
||||
});
|
||||
|
||||
const handleSubmit = (data: z.infer<typeof schema>) => {
|
||||
@@ -64,7 +67,8 @@ export function OnboardingProfile() {
|
||||
const buttonDisabled =
|
||||
!form.formState.isValid ||
|
||||
typing ||
|
||||
(form.formState.isDirty && (!usernameAvailable || usernameAvailableLoading));
|
||||
(form.formState.isDirty &&
|
||||
(!usernameAvailable || refetchingUsernameAvailable || loadingUsarnameAvailable));
|
||||
|
||||
return (
|
||||
<Container size="xs" px={0}>
|
||||
@@ -79,7 +83,7 @@ export function OnboardingProfile() {
|
||||
label="Username"
|
||||
clearable={false}
|
||||
rightSection={
|
||||
usernameAvailableLoading ? (
|
||||
refetchingUsernameAvailable ? (
|
||||
<Loader size="sm" mr="xs" />
|
||||
) : (
|
||||
usernameAvailable !== undefined && (
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Loader,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
TypographyStylesProvider,
|
||||
} from '@mantine/core';
|
||||
@@ -19,25 +20,40 @@ import rehypeRaw from 'rehype-raw';
|
||||
import { OnboardingSteps } from '~/server/common/enums';
|
||||
import { trpc } from '~/utils/trpc';
|
||||
import { showErrorNotification } from '~/utils/notifications';
|
||||
import { RECAPTCHA_ACTIONS } from '~/server/common/constants';
|
||||
import { useRecaptchaToken } from '~/components/Recaptcha/useReptchaToken';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
CaptchaState,
|
||||
TurnstilePrivacyNotice,
|
||||
TurnstileWidget,
|
||||
} from '~/components/TurnstileWidget/TurnstileWidget';
|
||||
|
||||
export function OnboardingTos() {
|
||||
const [captchaState, setCaptchaState] = useState<CaptchaState>({
|
||||
status: null,
|
||||
token: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { next } = useOnboardingWizardContext();
|
||||
const { mutate, isLoading } = useOnboardingStepCompleteMutation();
|
||||
|
||||
const { token: recaptchaToken, loading: isLoadingRecaptcha } = useRecaptchaToken(
|
||||
RECAPTCHA_ACTIONS.COMPLETE_ONBOARDING
|
||||
);
|
||||
|
||||
const handleStepComplete = () => {
|
||||
if (!recaptchaToken)
|
||||
if (captchaState.status !== 'success')
|
||||
return showErrorNotification({
|
||||
title: 'Cannot save',
|
||||
error: new Error('Recaptcha token is missing'),
|
||||
error: new Error(captchaState.error ?? 'Captcha token expired. Please try again.'),
|
||||
});
|
||||
|
||||
mutate({ step: OnboardingSteps.TOS, recaptchaToken }, { onSuccess: () => next() });
|
||||
if (!captchaState.token)
|
||||
return showErrorNotification({
|
||||
title: 'Cannot save',
|
||||
error: new Error('Captcha token is missing'),
|
||||
});
|
||||
|
||||
mutate(
|
||||
{ step: OnboardingSteps.TOS, recaptchaToken: captchaState.token },
|
||||
{ onSuccess: () => next() }
|
||||
);
|
||||
};
|
||||
|
||||
const { data: terms, isLoading: termsLoading } = trpc.content.get.useQuery({ slug: 'tos' });
|
||||
@@ -81,12 +97,31 @@ export function OnboardingTos() {
|
||||
size="lg"
|
||||
onClick={handleStepComplete}
|
||||
loading={isLoading}
|
||||
disabled={isLoadingRecaptcha || !recaptchaToken}
|
||||
disabled={captchaState.status !== 'success'}
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
<TurnstilePrivacyNotice />
|
||||
<TurnstileWidget
|
||||
onSuccess={(token) => setCaptchaState({ status: 'success', token, error: null })}
|
||||
onError={(error) =>
|
||||
setCaptchaState({
|
||||
status: 'error',
|
||||
token: null,
|
||||
error: `There was an error generating the captcha: ${error}`,
|
||||
})
|
||||
}
|
||||
onExpire={(token) =>
|
||||
setCaptchaState({ status: 'expired', token, error: 'Captcha token expired' })
|
||||
}
|
||||
/>
|
||||
{captchaState.status === 'error' && (
|
||||
<Text size="xs" color="red">
|
||||
{captchaState.error}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
ModalProps,
|
||||
Divider,
|
||||
Text,
|
||||
Alert,
|
||||
Group,
|
||||
Paper,
|
||||
useMantineTheme,
|
||||
@@ -17,8 +16,6 @@ import {
|
||||
import { PaymentProvider } from '@prisma/client';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { useTrackEvent } from '../TrackView/track.utils';
|
||||
import { RecaptchaNotice } from '../Recaptcha/RecaptchaWidget';
|
||||
import { AlertWithIcon } from '../AlertWithIcon/AlertWithIcon';
|
||||
import { IconAlertCircle } from '@tabler/icons-react';
|
||||
import { useDialogContext } from '~/components/Dialog/DialogProvider';
|
||||
@@ -28,6 +25,11 @@ import { usePaddle } from '~/providers/PaddleProvider';
|
||||
import { useActiveSubscription } from '~/components/Stripe/memberships.util';
|
||||
import { formatPriceForDisplay, numberWithCommas } from '~/utils/number-helpers';
|
||||
import { useMutatePaddle } from '~/components/Paddle/util';
|
||||
import {
|
||||
CaptchaState,
|
||||
TurnstilePrivacyNotice,
|
||||
TurnstileWidget,
|
||||
} from '~/components/TurnstileWidget/TurnstileWidget';
|
||||
|
||||
const Error = ({ error, onClose }: { error: string; onClose: () => void }) => (
|
||||
<Stack>
|
||||
@@ -41,7 +43,7 @@ const Error = ({ error, onClose }: { error: string; onClose: () => void }) => (
|
||||
{error}
|
||||
</AlertWithIcon>
|
||||
|
||||
<RecaptchaNotice />
|
||||
<TurnstilePrivacyNotice />
|
||||
|
||||
<Center>
|
||||
<Button onClick={onClose}>Close this window</Button>
|
||||
@@ -71,6 +73,11 @@ export const PaddleTransacionModal = ({
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState<string | null>();
|
||||
const [processingSuccess, setProcessingSuccess] = useState(false);
|
||||
const [captchaState, setCaptchaState] = useState<CaptchaState>({
|
||||
status: null,
|
||||
token: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const onCheckoutComplete = useCallback(
|
||||
(data?: CheckoutEventsData) => {
|
||||
@@ -108,10 +115,11 @@ export const PaddleTransacionModal = ({
|
||||
!paddleTransactionLoading &&
|
||||
!transactionId &&
|
||||
!subscriptionLoading &&
|
||||
(!subscription || subscriptionPaymentProvider !== PaymentProvider.Paddle)
|
||||
(!subscription || subscriptionPaymentProvider !== PaymentProvider.Paddle) &&
|
||||
captchaState.status === 'success'
|
||||
) {
|
||||
// Go ahead and automatically trigger the checkout
|
||||
getTransaction();
|
||||
getTransaction(captchaState.token);
|
||||
}
|
||||
}, [
|
||||
transactionError,
|
||||
@@ -121,6 +129,8 @@ export const PaddleTransacionModal = ({
|
||||
getTransaction,
|
||||
transactionId,
|
||||
paddleTransactionLoading,
|
||||
captchaState.status,
|
||||
captchaState.token,
|
||||
]);
|
||||
|
||||
const handlePurchaseWithSubscription = useCallback(async () => {
|
||||
@@ -154,7 +164,12 @@ export const PaddleTransacionModal = ({
|
||||
[]
|
||||
);
|
||||
|
||||
if (subscriptionLoading || paddleTransactionLoading || processingSuccess) {
|
||||
if (
|
||||
subscriptionLoading ||
|
||||
paddleTransactionLoading ||
|
||||
processingSuccess ||
|
||||
(captchaState.status !== 'error' && !captchaState.token)
|
||||
) {
|
||||
return (
|
||||
<Modal {...dialog} {...modalProps}>
|
||||
<Stack spacing="md">
|
||||
@@ -162,16 +177,32 @@ export const PaddleTransacionModal = ({
|
||||
<Loader />
|
||||
</Center>
|
||||
|
||||
<RecaptchaNotice />
|
||||
<TurnstilePrivacyNotice />
|
||||
<TurnstileWidget
|
||||
onSuccess={(token) => setCaptchaState({ status: 'success', token, error: null })}
|
||||
onError={(error) =>
|
||||
setCaptchaState({
|
||||
status: 'error',
|
||||
token: null,
|
||||
error: `There was an error generating the captcha: ${error}`,
|
||||
})
|
||||
}
|
||||
onExpire={(token) =>
|
||||
setCaptchaState({ status: 'expired', token, error: 'Captcha token expired' })
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
if (transactionError && !paddleTransactionLoading && !transactionId) {
|
||||
if (
|
||||
(transactionError && !paddleTransactionLoading && !transactionId) ||
|
||||
captchaState.status === 'error'
|
||||
) {
|
||||
return (
|
||||
<Modal {...dialog} {...modalProps}>
|
||||
<Error error={transactionError} onClose={dialog.onClose} />
|
||||
<Error error={captchaState.error ?? transactionError ?? ''} onClose={dialog.onClose} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -266,14 +297,24 @@ export const PaddleTransacionModal = ({
|
||||
<Divider size="sm" label="OR" my="sm" labelPosition="center" />
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={getTransaction}
|
||||
onClick={() =>
|
||||
captchaState.status === 'success' ? getTransaction(captchaState.token) : undefined
|
||||
}
|
||||
disabled={purchasingBuzzWithSubscription}
|
||||
radius="xl"
|
||||
>
|
||||
Use a different payment method
|
||||
</Button>
|
||||
</Stack>
|
||||
<RecaptchaNotice />
|
||||
|
||||
<TurnstilePrivacyNotice />
|
||||
<TurnstileWidget
|
||||
onSuccess={(token) => setCaptchaState({ status: 'success', token, error: null })}
|
||||
onError={(error) => setCaptchaState({ status: 'error', token: null, error })}
|
||||
onExpire={(token) =>
|
||||
setCaptchaState({ status: 'expired', token, error: 'Captcha token expired' })
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { CurrencyCode } from '@paddle/paddle-js';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { RECAPTCHA_ACTIONS } from '~/server/common/constants';
|
||||
import { trpc } from '~/utils/trpc';
|
||||
import { useRecaptchaToken } from '~/components/Recaptcha/useReptchaToken';
|
||||
import { useDebouncer } from '~/utils/debouncer';
|
||||
|
||||
export const usePaddleBuzzTransaction = ({
|
||||
@@ -16,48 +14,48 @@ export const usePaddleBuzzTransaction = ({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const createTransactionMutation = trpc.paddle.createBuzzPurchaseTransaction.useMutation();
|
||||
const { getToken, loading: isLoadingToken } = useRecaptchaToken(
|
||||
RECAPTCHA_ACTIONS.PADDLE_TRANSACTION,
|
||||
false
|
||||
|
||||
const getTransaction = useCallback(
|
||||
(captchaToken: string | null) => async () => {
|
||||
if (isLoading || createTransactionMutation.isLoading) return;
|
||||
|
||||
setTransactionId(null);
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
if (!captchaToken) {
|
||||
throw new Error('Unable to get captcha token.');
|
||||
}
|
||||
|
||||
const data = await createTransactionMutation.mutateAsync({
|
||||
unitAmount,
|
||||
currency,
|
||||
recaptchaToken: captchaToken,
|
||||
});
|
||||
|
||||
setTransactionId(data.transactionId);
|
||||
} catch (err: any) {
|
||||
setError(err?.message ?? err ?? 'An error occurred');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[unitAmount, currency, createTransactionMutation, isLoading]
|
||||
);
|
||||
|
||||
const getTransaction = useCallback(async () => {
|
||||
if (isLoading || createTransactionMutation.isLoading || isLoadingToken) return;
|
||||
|
||||
setTransactionId(null);
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const recaptchaToken = await getToken();
|
||||
|
||||
if (!recaptchaToken) {
|
||||
throw new Error('Unable to get recaptcha token.');
|
||||
}
|
||||
|
||||
const data = await createTransactionMutation.mutateAsync({
|
||||
unitAmount,
|
||||
currency,
|
||||
recaptchaToken: recaptchaToken as string,
|
||||
});
|
||||
|
||||
setTransactionId(data.transactionId);
|
||||
} catch (err: any) {
|
||||
setError(err?.message ?? err ?? 'An error occurred');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [unitAmount, currency, getToken, createTransactionMutation]);
|
||||
|
||||
const debouncer = useDebouncer(300);
|
||||
const debouncedGetTransaction = useCallback(() => {
|
||||
debouncer(getTransaction);
|
||||
}, [getTransaction, debouncer]);
|
||||
const debouncedGetTransaction = useCallback(
|
||||
(captchaToken: string | null) => {
|
||||
debouncer(getTransaction(captchaToken));
|
||||
},
|
||||
[getTransaction, debouncer]
|
||||
);
|
||||
|
||||
return {
|
||||
error,
|
||||
transactionId,
|
||||
isLoading: isLoading || isLoadingToken,
|
||||
isLoading,
|
||||
getTransaction: debouncedGetTransaction,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Anchor, Text, TextProps } from '@mantine/core';
|
||||
import { Turnstile, TurnstileProps, TurnstileInstance } from '@marsidev/react-turnstile';
|
||||
import { useRef } from 'react';
|
||||
import { env } from '~/env/client.mjs';
|
||||
import { showExpiredCaptchaTokenNotification } from '~/utils/notifications';
|
||||
|
||||
export type CaptchaState = {
|
||||
status: 'success' | 'error' | 'expired' | null;
|
||||
token: string | null;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export function TurnstileWidget(props: Props) {
|
||||
const ref = useRef<TurnstileInstance>(null);
|
||||
|
||||
const handleExpired: Props['onExpire'] = (token) => {
|
||||
const instance = ref.current;
|
||||
if (instance) showExpiredCaptchaTokenNotification({ onRetryClick: () => instance.reset() });
|
||||
|
||||
return props.onExpire?.(token);
|
||||
};
|
||||
|
||||
if (!env.NEXT_PUBLIC_CLOUDFLARE_TURNSTILE_SITEKEY) return null;
|
||||
|
||||
return (
|
||||
<Turnstile
|
||||
ref={ref}
|
||||
siteKey={env.NEXT_PUBLIC_CLOUDFLARE_TURNSTILE_SITEKEY}
|
||||
options={{ size: 'invisible' }}
|
||||
{...props}
|
||||
onExpire={handleExpired}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = Omit<TurnstileProps, 'siteKey'>;
|
||||
|
||||
export function TurnstilePrivacyNotice(props: TextProps) {
|
||||
return (
|
||||
<Text size="xs" {...props}>
|
||||
This site is protected by Cloudflare Turnstile and the Cloudflare{' '}
|
||||
<Anchor href="https://www.cloudflare.com/privacypolicy/">Privacy Policy</Anchor> applies.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
Vendored
+4
-1
@@ -7,7 +7,7 @@ import {
|
||||
stringToArray,
|
||||
} from '~/utils/zod-helpers';
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Specify your server-side environment variables schema here.
|
||||
* This way you can ensure the app isn't built with invalid env vars.
|
||||
@@ -178,6 +178,7 @@ export const serverSchema = z.object({
|
||||
STRIPE_DONATE_ID: z.string().optional(),
|
||||
PADDLE_SECRET_KEY: z.string().optional(),
|
||||
PADDLE_WEBHOOK_SECRET: z.string().optional(),
|
||||
CLOUDFLARE_TURNSTILE_SECRET: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -220,6 +221,7 @@ export const clientSchema = z.object({
|
||||
NEXT_PUBLIC_PADDLE_TOKEN: z.string().optional(),
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().optional(),
|
||||
NEXT_PUBLIC_DEFAULT_PAYMENT_PROVIDER: z.enum(['Stripe', 'Paddle']).default('Stripe'),
|
||||
NEXT_PUBLIC_CLOUDFLARE_TURNSTILE_SITEKEY: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -264,4 +266,5 @@ export const clientEnv = {
|
||||
NEXT_PUBLIC_PADDLE_TOKEN: process.env.NEXT_PUBLIC_PADDLE_TOKEN,
|
||||
// Default to Stripe in case the env var is not set
|
||||
NEXT_PUBLIC_DEFAULT_PAYMENT_PROVIDER: process.env.NEXT_PUBLIC_DEFAULT_PAYMENT_PROVIDER === 'Paddle' ? 'Paddle' : 'Stripe',
|
||||
NEXT_PUBLIC_CLOUDFLARE_TURNSTILE_SITEKEY: process.env.NEXT_PUBLIC_CLOUDFLARE_TURNSTILE_SITEKEY,
|
||||
};
|
||||
|
||||
+14
-16
@@ -160,22 +160,20 @@ function MyApp(props: CustomAppProps) {
|
||||
zIndex={9999}
|
||||
>
|
||||
<BrowserRouterProvider>
|
||||
<RecaptchaWidgetProvider>
|
||||
<GenerationProvider>
|
||||
<IntersectionObserverProvider>
|
||||
<BaseLayout>
|
||||
<ChatContextProvider>
|
||||
<CustomModalsProvider>
|
||||
{getLayout(<Component {...pageProps} />)}
|
||||
<StripeSetupSuccessProvider />
|
||||
<DialogProvider />
|
||||
<RoutedDialogProvider />
|
||||
</CustomModalsProvider>
|
||||
</ChatContextProvider>
|
||||
</BaseLayout>
|
||||
</IntersectionObserverProvider>
|
||||
</GenerationProvider>
|
||||
</RecaptchaWidgetProvider>
|
||||
<GenerationProvider>
|
||||
<IntersectionObserverProvider>
|
||||
<BaseLayout>
|
||||
<ChatContextProvider>
|
||||
<CustomModalsProvider>
|
||||
{getLayout(<Component {...pageProps} />)}
|
||||
<StripeSetupSuccessProvider />
|
||||
<DialogProvider />
|
||||
<RoutedDialogProvider />
|
||||
</CustomModalsProvider>
|
||||
</ChatContextProvider>
|
||||
</BaseLayout>
|
||||
</IntersectionObserverProvider>
|
||||
</GenerationProvider>
|
||||
</BrowserRouterProvider>
|
||||
</NotificationsProvider>
|
||||
</CivitaiLinkProvider>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createGetInitialProps } from '@mantine/next';
|
||||
import Document, { Html, Main, NextScript, Head } from 'next/document';
|
||||
import Script from 'next/script';
|
||||
|
||||
const getInitialProps = createGetInitialProps();
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import { env } from '~/env/client.mjs';
|
||||
export const getServerSideProps = createServerSideProps({
|
||||
useSession: true,
|
||||
resolver: async ({ features }) => {
|
||||
if (!features?.isGreen)
|
||||
if (!features?.canBuyBuzz)
|
||||
return {
|
||||
redirect: {
|
||||
destination: `https://${env.NEXT_PUBLIC_SERVER_DOMAIN_GREEN}/purchase/buzz?sync-account=blue`,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
throwAuthorizationError,
|
||||
throwBadRequestError,
|
||||
throwDbError,
|
||||
throwNotFoundError,
|
||||
} from '~/server/utils/errorHandling';
|
||||
import { Context } from '~/server/createContext';
|
||||
@@ -14,13 +13,11 @@ import {
|
||||
} from '~/server/services/paddle.service';
|
||||
import {
|
||||
TransactionCreateInput,
|
||||
TransactionMetadataSchema,
|
||||
TransactionWithSubscriptionCreateInput,
|
||||
UpdateSubscriptionInputSchema,
|
||||
} from '~/server/schema/paddle.schema';
|
||||
import { getTRPCErrorFromUnknown } from '@trpc/server';
|
||||
import { RECAPTCHA_ACTIONS } from '~/server/common/constants';
|
||||
import { createRecaptchaAssesment } from '~/server/recaptcha/client';
|
||||
import { verifyCaptchaToken } from '~/server/recaptcha/client';
|
||||
import { getPaddleSubscription, getTransactionById } from '~/server/paddle/client';
|
||||
import { GetByIdStringInput } from '~/server/schema/base.schema';
|
||||
import { getPlans, getUserSubscription } from '~/server/services/subscriptions.service';
|
||||
@@ -40,23 +37,10 @@ export const createBuzzPurchaseTransactionHandler = async ({
|
||||
}
|
||||
|
||||
const { recaptchaToken } = input;
|
||||
|
||||
if (!recaptchaToken) throw throwAuthorizationError('recaptchaToken required');
|
||||
|
||||
const { score, reasons } = await createRecaptchaAssesment({
|
||||
token: recaptchaToken,
|
||||
recaptchaAction: RECAPTCHA_ACTIONS.PADDLE_TRANSACTION,
|
||||
});
|
||||
|
||||
if ((score || 0) < 0.7) {
|
||||
if (reasons.length) {
|
||||
throw throwAuthorizationError(
|
||||
`Recaptcha Failed. The following reasons were detected: ${reasons.join(', ')}`
|
||||
);
|
||||
} else {
|
||||
throw throwAuthorizationError('We could not verify the authenticity of your request.');
|
||||
}
|
||||
}
|
||||
const validCaptcha = await verifyCaptchaToken({ token: recaptchaToken, ip: ctx.ip });
|
||||
if (!validCaptcha) throw throwAuthorizationError('Captcha Failed. Please try again.');
|
||||
|
||||
const user = { id: ctx.user.id, email: ctx.user.email as string };
|
||||
return await createBuzzPurchaseTransaction({ user, ...input });
|
||||
|
||||
@@ -3,7 +3,7 @@ import { TRPCError } from '@trpc/server';
|
||||
import { orderBy } from 'lodash-es';
|
||||
import { isProd } from '~/env/other';
|
||||
import { clickhouse } from '~/server/clickhouse/client';
|
||||
import { constants, RECAPTCHA_ACTIONS } from '~/server/common/constants';
|
||||
import { constants } from '~/server/common/constants';
|
||||
import {
|
||||
NotificationCategory,
|
||||
OnboardingComplete,
|
||||
@@ -110,7 +110,7 @@ import { Flags } from '~/shared/utils';
|
||||
import { isUUID } from '~/utils/string-helpers';
|
||||
import { isDefined } from '~/utils/type-guards';
|
||||
import { getUserBuzzBonusAmount } from '../common/user-helpers';
|
||||
import { createRecaptchaAssesment } from '../recaptcha/client';
|
||||
import { verifyCaptchaToken } from '../recaptcha/client';
|
||||
import { TransactionType } from '../schema/buzz.schema';
|
||||
import { createBuzzTransaction } from '../services/buzz.service';
|
||||
import { FeatureAccess, toggleableFeatures } from '../services/feature-flags.service';
|
||||
@@ -267,23 +267,11 @@ export const completeOnboardingHandler = async ({
|
||||
|
||||
switch (input.step) {
|
||||
case OnboardingSteps.TOS:
|
||||
// const { recaptchaToken } = input;
|
||||
// if (!recaptchaToken) throw throwAuthorizationError('recaptchaToken required');
|
||||
const { recaptchaToken } = input;
|
||||
if (!recaptchaToken) throw throwAuthorizationError('recaptchaToken required');
|
||||
|
||||
// const { score, reasons } = await createRecaptchaAssesment({
|
||||
// token: recaptchaToken,
|
||||
// recaptchaAction: RECAPTCHA_ACTIONS.COMPLETE_ONBOARDING,
|
||||
// });
|
||||
|
||||
// if ((score || 0) < 0.5) {
|
||||
// if (reasons.length) {
|
||||
// throw throwAuthorizationError(
|
||||
// `Recaptcha Failed. The following reasons were detected: ${reasons.join(', ')}`
|
||||
// );
|
||||
// } else {
|
||||
// throw throwAuthorizationError('We could not verify the authenticity of your request.');
|
||||
// }
|
||||
// }
|
||||
const validCaptcha = await verifyCaptchaToken({ token: recaptchaToken, ip: ctx.ip });
|
||||
if (!validCaptcha) throw throwAuthorizationError('Recaptcha Failed. Please try again.');
|
||||
|
||||
await dbWrite.user.update({ where: { id }, data: { onboarding } });
|
||||
break;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { env } from '~/env/server.mjs';
|
||||
import { isDev } from '../../env/other';
|
||||
import { throwBadRequestError } from '~/server/utils/errorHandling';
|
||||
import { isDefined } from '~/utils/type-guards';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Taken from package as they don't export it :shrug:
|
||||
// enum ClassificationReason {
|
||||
@@ -86,3 +87,33 @@ export async function createRecaptchaAssesment({
|
||||
throw throwBadRequestError('Provided token does not match performed action');
|
||||
}
|
||||
}
|
||||
|
||||
type SiteVerifyResponse = z.infer<typeof siteVerifyResponseSchema>;
|
||||
const siteVerifyResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
challenge_ts: z.coerce.date().optional(),
|
||||
hostname: z.string().optional(),
|
||||
'error-codes': z.array(z.string()),
|
||||
action: z.string().optional(),
|
||||
cdata: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function verifyCaptchaToken({ token, ip }: { token: string; ip?: string }) {
|
||||
const result = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
secret: env.CLOUDFLARE_TURNSTILE_SECRET,
|
||||
response: token,
|
||||
remoteip: ip,
|
||||
}),
|
||||
});
|
||||
if (!result.ok) throw throwBadRequestError('No response from captcha service');
|
||||
|
||||
const outcome = (await result.json()) as SiteVerifyResponse;
|
||||
if (outcome.success) {
|
||||
return true;
|
||||
} else {
|
||||
throw throwBadRequestError('Unable to verify captcha token');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Button, Group, Stack, Text, ThemeIcon } from '@mantine/core';
|
||||
import { NotificationProps, showNotification } from '@mantine/notifications';
|
||||
import { IconBolt, IconCheck, IconExclamationMark, IconX } from '@tabler/icons-react';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconBolt,
|
||||
IconCheck,
|
||||
IconExclamationMark,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
export function showErrorNotification({
|
||||
error,
|
||||
@@ -138,3 +144,23 @@ export function showConfirmNotification({
|
||||
disallowClose: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function showExpiredCaptchaTokenNotification({
|
||||
onRetryClick,
|
||||
}: {
|
||||
onRetryClick: VoidFunction;
|
||||
}) {
|
||||
showNotification({
|
||||
icon: <IconAlertTriangle size={18} />,
|
||||
color: 'yellow',
|
||||
title: 'Captcha token expired',
|
||||
message: (
|
||||
<div>
|
||||
<Text inherit>Your token expired, click the button below to reset your token</Text>
|
||||
<Button size="sm" variant="subtle" onClick={onRetryClick}>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user