Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: cloudflare turnstile #7

Merged
merged 4 commits into from
Dec 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading