Skip to content

Commit

Permalink
feat: cloudflare turnstile (#7)
Browse files Browse the repository at this point in the history
* popup turnstile

* turnstile on api

* fix

* trash
  • Loading branch information
Din authored Dec 12, 2023
1 parent 5d7b813 commit b321a2f
Show file tree
Hide file tree
Showing 13 changed files with 201 additions and 46 deletions.
3 changes: 2 additions & 1 deletion @api/.dev.example.vars
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ GOOGLE_CLIENT_SECRET=
RESEND_API_KEY=
RESEND_FROM=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GITHUB_CLIENT_SECRET=
TURNSTILE_SECRET_KEY=
1 change: 1 addition & 0 deletions @api/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const envSchema = z.object({
GITHUB_CLIENT_SECRET: z.string(),
RESEND_API_KEY: z.string(),
RESEND_FROM: z.string(),
TURNSTILE_SECRET_KEY: z.string(),
})

export type Env = z.infer<typeof envSchema>
27 changes: 26 additions & 1 deletion @api/trpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,32 @@ const t = initTRPC.context<Context & { request: Request }>().create({
export const middleware = t.middleware
export const router = t.router

export const procedure = t.procedure
const turnstileMiddleware = middleware(async ({ ctx, next, type }) => {
if (type === 'mutation') {
const formData = new FormData()
formData.append('secret', ctx.env.TURNSTILE_SECRET_KEY)
formData.append('response', ctx.request.headers.get('X-Turnstile-Token'))
formData.append('remoteip', ctx.request.headers.get('CF-Connecting-IP'))

const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
body: formData,
method: 'POST',
})
const outcome = (await res.json()) as { success: boolean }
if (!outcome.success) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'You are behaving like an automated bot.',
})
}
}

return next({
ctx,
})
})

export const procedure = t.procedure.use(turnstileMiddleware)

const authMiddleware = middleware(async ({ ctx, next }) => {
const bearer = ctx.request.headers.get('Authorization')
Expand Down
3 changes: 2 additions & 1 deletion @web/.env.local
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
NEXT_PUBLIC_API_URL=http://localhost:8000
NEXT_PUBLIC_API_URL=http://localhost:8000
NEXT_PUBLIC_TURNSTILE_SITE_KEY=0x4AAAAAAAOd1-P2R_2ooD0h
1 change: 1 addition & 0 deletions @web/.env.preview
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
NEXT_PUBLIC_API_URL=https://preview.api.example.com
NEXT_PUBLIC_TURNSTILE_SITE_KEY=0x4AAAAAAAOd1-P2R_2ooD0h
1 change: 1 addition & 0 deletions @web/.env.production
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_TURNSTILE_SITE_KEY=0x4AAAAAAAOd1-P2R_2ooD0h
29 changes: 29 additions & 0 deletions @web/app/_providers/query.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { useState } from 'react'
import SuperJSON from 'superjson'
import { useToast } from '@ui/ui/use-toast'
import { store } from './jotai'
import { showTurnstileAtom, turnstileRefAtom, turnstileTokenAtom } from './turnstile'

export function QueryProvider({ children }: { children: React.ReactNode }) {
const { toast } = useToast()
Expand Down Expand Up @@ -66,6 +67,34 @@ export function QueryProvider({ children }: { children: React.ReactNode }) {

return headers
},
async fetch(input, init) {
const method = init?.method?.toUpperCase() ?? 'GET'
if (method === 'POST' && init) {
if (!store.get(turnstileTokenAtom)) {
store.set(showTurnstileAtom, true)
await new Promise((resolve) => {
store.sub(turnstileTokenAtom, () => {
const token = store.get(turnstileTokenAtom)
if (token) {
resolve(token)
}
})
})
store.set(showTurnstileAtom, false)
}

const token = store.get(turnstileTokenAtom)

init.headers = {
...init.headers,
'X-Turnstile-Token': `${token}`,
}
store.set(turnstileTokenAtom, null)
store.get(turnstileRefAtom)?.reset()
}

return await fetch(input, init)
},
}),
],
}),
Expand Down
67 changes: 67 additions & 0 deletions @web/app/_providers/turnstile.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
'use client'

import type { TurnstileInstance } from '@marsidev/react-turnstile'
import { Turnstile } from '@marsidev/react-turnstile'
import * as Portal from '@radix-ui/react-portal'
import { env } from '@web/env'
import { atom, useAtom } from 'jotai'
import { useTheme } from 'next-themes'
import { useId, useRef } from 'react'
import { match } from 'ts-pattern'
import { useIsRendered } from '@ui/hooks/use-is-rendered'
import { cn } from '@ui/lib/utils'

export const turnstileTokenAtom = atom<string | null>(null)

export const showTurnstileAtom = atom(false)

export const turnstileRefAtom = atom<TurnstileInstance | null>(null)

export default function TurnstileProvider({ children }: { children: React.ReactNode }) {
const { theme } = useTheme()
const [, setTurnstileToken] = useAtom(turnstileTokenAtom)
const [showTurnstile] = useAtom(showTurnstileAtom)
const turnstileRef = useRef<TurnstileInstance>(null)
const id = useId()
const isRendered = useIsRendered()
const [, _setTurnstileRef] = useAtom(turnstileRefAtom)

return (
<>
{children}
{isRendered && (
<Portal.Root>
<div
className={cn(
'fixed top-0 left-0 right-0 bottom-0 z-[99999] flex items-center justify-center bg-background/80',
{
'top-[100%] bottom-[-100%]': !showTurnstile,
},
)}
>
<Turnstile
ref={turnstileRef}
id={id}
siteKey={env.NEXT_PUBLIC_TURNSTILE_SITE_KEY}
options={{
theme: match(theme)
.with('dark', () => 'dark' as const)
.with('light', () => 'light' as const)
.otherwise(() => 'auto' as const),
}}
onSuccess={(token) => {
_setTurnstileRef(turnstileRef.current)
setTurnstileToken(token)
}}
onError={() => setTurnstileToken(null)}
onExpire={() => {
setTurnstileToken(null)
turnstileRef.current?.reset()
}}
/>
</div>
</Portal.Root>
)}
</>
)
}
9 changes: 6 additions & 3 deletions @web/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Toaster } from '@ui/ui/toaster'
import JotaiProvider from './_providers/jotai'
import { QueryProvider } from './_providers/query'
import { ThemeProvider } from './_providers/theme'
import TurnstileProvider from './_providers/turnstile'

const inter = Inter({ subsets: ['latin'] })

Expand All @@ -21,9 +22,11 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<JotaiProvider>
<ThemeProvider attribute="class" defaultTheme="system">
<QueryProvider>
<ScrollArea>
<div className="h-screen">{children}</div>
</ScrollArea>
<TurnstileProvider>
<ScrollArea>
<div className="h-screen">{children}</div>
</ScrollArea>
</TurnstileProvider>
<Toaster />
</QueryProvider>
</ThemeProvider>
Expand Down
3 changes: 2 additions & 1 deletion @web/components/profile-dropdown-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ function WorkspaceListItem(props: {
organization: props.organization,
})
}}
disabled={props.disabled}
disabled={mutation.isLoading || props.disabled}
>
{mutation.isLoading ? (
<div className="h-9 w-9 rounded-md bg-accent flex items-center justify-center mr-2">
Expand Down Expand Up @@ -216,6 +216,7 @@ function WorkspaceListItem(props: {

function LogoutDropdownMenuItem() {
const [, setAuth] = useAtom(authAtom)
// TODO: call logout api
return <DropdownMenuItem onClick={() => setAuth(RESET)}>Log out</DropdownMenuItem>
}

Expand Down
2 changes: 2 additions & 0 deletions @web/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const env = createEnv({
*/
client: {
NEXT_PUBLIC_API_URL: z.string().url(),
NEXT_PUBLIC_TURNSTILE_SITE_KEY: z.string(),
},
/*
* Due to how Next.js bundles environment variables on Edge and Client,
Expand All @@ -23,5 +24,6 @@ export const env = createEnv({
*/
runtimeEnv: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
NEXT_PUBLIC_TURNSTILE_SITE_KEY: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY,
},
})
2 changes: 2 additions & 0 deletions @web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
},
"dependencies": {
"@dinstack/ui": "workspace:^",
"@marsidev/react-turnstile": "^0.4.0",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-portal": "^1.0.4",
"@t3-oss/env-nextjs": "^0.7.1",
"@tanstack/react-query": "^4.36.1",
"@trpc/client": "^10.44.1",
Expand Down
Loading

0 comments on commit b321a2f

Please sign in to comment.