Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
8 changes: 8 additions & 0 deletions frontend/src/components/App/index.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@
float: right
}

.turnstile {
display: inline-block;
vertical-align: middle;
margin-right: 10px;
position: relative;
z-index: 11;
}

.codeHeaderButtons > button {
margin-left: 10px
}
Expand Down
55 changes: 37 additions & 18 deletions frontend/src/components/App/index.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { useState, useContext, useRef } from 'react';
import { useState, useContext, useEffect, useRef } from 'react';
import { Col, Grid, IconButton, Loader, Panel, CustomProvider } from 'rsuite'
import PlayIcon from '@rsuite/icons/PlayOutline';

import { ExecutionResponse, runCode, trackEvent } from '../../utils'
import { createTurnstileGate, type TurnstileGate } from '../../turnstile'
import RightPanel from '../RightPanel'
import Header from '../Header'
import Editor from '../Editor'
Expand All @@ -17,36 +18,53 @@ const VITE_TURNSTILE_SITEKEY = '0x4AAAAAAA_K0T_2LZ0rgUtv';
const App: React.FunctionComponent = () => {
const { getCode, onChangeRightPanelMode, codeLanguage, onLanguageChange } = useContext(CodeContext)
const [loading, setLoading] = useState<boolean>(false)
const [running, setRunning] = useState<boolean>(false)
const [resp, setResponse] = useState<ExecutionResponse|null>(null)
const handleExecutionRef = useRef<() => Promise<void>>(undefined)
const runningRef = useRef(false)
const [darkMode] = useDarkMode()
const turnstileRef = useRef<HTMLDivElement>(null)
const gateRef = useRef<TurnstileGate | null>(null)

if (!gateRef.current) {
gateRef.current = createTurnstileGate({ sitekey: VITE_TURNSTILE_SITEKEY })
}

useEffect(() => {
return () => {
gateRef.current?.remove()
}
}, [])

const handleExecution = async (): Promise<void> => {
setLoading(true)
if (runningRef.current) {
return
}
runningRef.current = true
setRunning(true)
setResponse(null)

trackEvent()
const turnstileToken = await new Promise<string>((resolve) => {
try {
(window as any).turnstile.reset();
} catch (error) {}
(window as any).turnstile.execute(turnstileRef.current, {
sitekey: VITE_TURNSTILE_SITEKEY,
callback: (token: string) => resolve(token),
'error-callback': () => resolve(''),
});
});
// After await: do not use render-time `code` (stale vs example select).
setResponse(await runCode(getCode(), codeLanguage, turnstileToken))
setLoading(false)
onChangeRightPanelMode(false)
try {
// Keep the loader off until the widget has a token so an interactive
// challenge is not covered by the editor backdrop (z-index 10).
const turnstileToken = await gateRef.current!.getToken(turnstileRef.current)
setLoading(true)
// After await: do not use render-time `code` (stale vs example select).
setResponse(await runCode(getCode(), codeLanguage, turnstileToken))
} catch (error) {
setResponse({ error: String(error) })
} finally {
runningRef.current = false
setRunning(false)
setLoading(false)
onChangeRightPanelMode(false)
}
}
handleExecutionRef.current = handleExecution

return (
<CustomProvider theme={darkMode ? 'dark' : 'light'}>
<div ref={turnstileRef} style={{ display: 'none' }} />
<Header />
<Grid fluid className={styles.grid}>
<Col span={{ xs: 24, md: 12 }}>
Expand All @@ -58,8 +76,9 @@ const App: React.FunctionComponent = () => {
<>
Editor
<div className={styles.codeHeaderButtons}>
<div ref={turnstileRef} className={styles.turnstile} />
<CodeLanguageSelector codeLanguage={codeLanguage} onLanguageChange={onLanguageChange} />
<IconButton onClick={handleExecution} icon={<PlayIcon />}>
<IconButton onClick={handleExecution} icon={<PlayIcon />} disabled={running}>
Run
</IconButton>
</div>
Expand Down
152 changes: 152 additions & 0 deletions frontend/src/turnstile.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { test, expect } from '@playwright/experimental-ct-react'
import {
CloudflareTurnstileGate,
NoopTurnstileGate,
createTurnstileGate,
resolveTurnstileMode,
type TurnstileApi,
type TurnstileRenderOptions,
} from './turnstile'

const container = {} as HTMLElement

function mockApi(overrides: Partial<TurnstileApi> = {}): { api: TurnstileApi; options: TurnstileRenderOptions[] } {
const rendered: TurnstileRenderOptions[] = []
const api: TurnstileApi = {
ready: (callback) => callback(),
render: (_container, options) => {
rendered.push(options)
return 'widget-1'
},
execute: () => {
rendered.at(-1)?.callback?.('tok')
},
reset: () => undefined,
remove: () => undefined,
...overrides,
}
return { api, options: rendered }
}

test('defaults to cloudflare for humans and noop for automated browsers', () => {
expect(resolveTurnstileMode({ automated: false })).toBe('cloudflare')
expect(resolveTurnstileMode({ automated: true })).toBe('noop')
})

test('window override swaps the implementation at runtime', () => {
expect(resolveTurnstileMode({ automated: false, override: 'noop' })).toBe('noop')
expect(resolveTurnstileMode({ automated: true, override: 'cloudflare' })).toBe('cloudflare')
expect(createTurnstileGate({ automated: false, override: 'noop' })).toBeInstanceOf(NoopTurnstileGate)
expect(createTurnstileGate({ automated: true, override: 'cloudflare', sitekey: 'k' })).toBeInstanceOf(CloudflareTurnstileGate)
})

test('noop gate never calls Turnstile execute', async () => {
let executed = false
const gate = createTurnstileGate({
mode: 'noop',
getApi: () => ({
render: () => 'w',
execute: () => {
executed = true
},
reset: () => undefined,
}),
})
expect(await gate.getToken(container)).toBe('')
expect(executed).toBe(false)
})

test('cloudflare gate returns empty when the API is missing', async () => {
const gate = new CloudflareTurnstileGate('sitekey', { getApi: () => null })
expect(await gate.getToken(container)).toBe('')
})

test('cloudflare gate does not hang when render throws', async () => {
const gate = new CloudflareTurnstileGate('sitekey', {
getApi: () => ({
render: () => {
throw new Error('no widget')
},
execute: () => undefined,
reset: () => undefined,
}),
})
expect(await gate.getToken(container)).toBe('')
})

test('cloudflare gate renders then execute(widgetId)', async () => {
const { api, options } = mockApi()
let executedWith: unknown
let resetWith: unknown
api.execute = (target) => {
executedWith = target
options.at(-1)?.callback?.('tok')
}
api.reset = (target) => {
resetWith = target
}
const gate = new CloudflareTurnstileGate('sitekey', { getApi: () => api })
expect(await gate.getToken(container)).toBe('tok')
expect(executedWith).toBe('widget-1')
expect(resetWith).toBe('widget-1')
expect(options[0]).toMatchObject({
sitekey: 'sitekey',
execution: 'execute',
appearance: 'interaction-only',
})
})

test('overlapping getToken shares one widget callback', async () => {
const { api, options } = mockApi({
execute: () => undefined,
})
let executeCount = 0
api.execute = () => {
executeCount += 1
}
const gate = new CloudflareTurnstileGate('sitekey', { getApi: () => api })
const first = gate.getToken(container)
const second = gate.getToken(container)
await Promise.resolve()
expect(executeCount).toBe(1)
options[0]?.callback?.('tok')
expect(await Promise.all([first, second])).toEqual(['tok', 'tok'])
})

test('reuses the rendered widget on a later getToken', async () => {
let renderCount = 0
const { api, options } = mockApi({
render: (_container, renderOptions) => {
renderCount += 1
options.push(renderOptions)
return 'widget-1'
},
})
api.execute = () => {
options.at(-1)?.callback?.(`tok-${renderCount}`)
}
const gate = new CloudflareTurnstileGate('sitekey', { getApi: () => api })
await gate.getToken(container)
expect(await gate.getToken(container)).toBe('tok-1')
expect(renderCount).toBe(1)
})

test('cloudflare gate resolves empty on error-callback', async () => {
const { api, options } = mockApi()
api.execute = () => {
options.at(-1)?.['error-callback']?.()
}
const gate = new CloudflareTurnstileGate('sitekey', { getApi: () => api })
expect(await gate.getToken(container)).toBe('')
})

test('optional timeoutMs is for tests; production has no client deadline', async () => {
const { api } = mockApi({
execute: () => undefined,
})
const gate = new CloudflareTurnstileGate('sitekey', {
timeoutMs: 50,
getApi: () => api,
})
expect(await gate.getToken(container)).toBe('')
})
Loading
Loading