update osm
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npm run *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,3 +18,8 @@ WEBHOOK_PORT=3011
|
|||||||
GITHUB_WEBHOOK_SECRET=change-me
|
GITHUB_WEBHOOK_SECRET=change-me
|
||||||
PM2_RESTART_TARGETS=tssbot-web
|
PM2_RESTART_TARGETS=tssbot-web
|
||||||
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
|
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
|
||||||
|
|
||||||
|
# Cloudflare Turnstile. VITE_TURNSTILE_SITE_KEY is the public site key baked into the client bundle by Vite.
|
||||||
|
# TURNSTILE_SECRET_KEY is the server-only secret used to call the Siteverify endpoint.
|
||||||
|
VITE_TURNSTILE_SITE_KEY=
|
||||||
|
TURNSTILE_SECRET_KEY=
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
|
<link rel="preconnect" href="https://challenges.cloudflare.com" />
|
||||||
|
<script
|
||||||
|
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
|
||||||
|
async
|
||||||
|
defer>
|
||||||
|
</script>
|
||||||
|
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#e82517" />
|
<meta name="theme-color" content="#e82517" />
|
||||||
|
|||||||
+168
-1
@@ -49,6 +49,11 @@ const ANALYTICS_ACTIVE_WINDOW_SECONDS = Number(process.env.ANALYTICS_ACTIVE_WIND
|
|||||||
const API_CACHE_TTL_MS = Number(process.env.API_CACHE_TTL_MS || 15000)
|
const API_CACHE_TTL_MS = Number(process.env.API_CACHE_TTL_MS || 15000)
|
||||||
const API_RATE_LIMIT_WINDOW_MS = Number(process.env.API_RATE_LIMIT_WINDOW_MS || 60000)
|
const API_RATE_LIMIT_WINDOW_MS = Number(process.env.API_RATE_LIMIT_WINDOW_MS || 60000)
|
||||||
const API_RATE_LIMIT_MAX = Number(process.env.API_RATE_LIMIT_MAX || 120)
|
const API_RATE_LIMIT_MAX = Number(process.env.API_RATE_LIMIT_MAX || 120)
|
||||||
|
const TURNSTILE_SECRET_KEY = process.env.TURNSTILE_SECRET_KEY || ''
|
||||||
|
const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'
|
||||||
|
const TURNSTILE_VERIFY_TIMEOUT_MS = Number(process.env.TURNSTILE_VERIFY_TIMEOUT_MS || 5000)
|
||||||
|
const TURNSTILE_MAX_TOKEN_LENGTH = 2048
|
||||||
|
const TURNSTILE_TOKEN_MAX_AGE_MS = 5 * 60 * 1000
|
||||||
const DIST_DIR = path.join(__dirname, 'dist')
|
const DIST_DIR = path.join(__dirname, 'dist')
|
||||||
const MAX_TEAM_NAME_LENGTH = 80
|
const MAX_TEAM_NAME_LENGTH = 80
|
||||||
const MAX_CACHE_ENTRIES = 200
|
const MAX_CACHE_ENTRIES = 200
|
||||||
@@ -531,6 +536,130 @@ function readJsonBody(req) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function callTurnstileSiteverify(token, remoteIp, idempotencyKey) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.set('secret', TURNSTILE_SECRET_KEY)
|
||||||
|
params.set('response', token)
|
||||||
|
if (remoteIp && remoteIp !== 'unknown') params.set('remoteip', remoteIp)
|
||||||
|
if (idempotencyKey) params.set('idempotency_key', idempotencyKey)
|
||||||
|
const payload = params.toString()
|
||||||
|
|
||||||
|
const verifyUrl = new URL(TURNSTILE_VERIFY_URL)
|
||||||
|
const request = https.request(
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
hostname: verifyUrl.hostname,
|
||||||
|
path: verifyUrl.pathname,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/x-www-form-urlencoded',
|
||||||
|
'content-length': Buffer.byteLength(payload),
|
||||||
|
},
|
||||||
|
timeout: TURNSTILE_VERIFY_TIMEOUT_MS,
|
||||||
|
},
|
||||||
|
(response) => {
|
||||||
|
const chunks = []
|
||||||
|
response.on('data', (chunk) => chunks.push(chunk))
|
||||||
|
response.on('end', () => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
||||||
|
resolve({ ok: true, data })
|
||||||
|
} catch {
|
||||||
|
resolve({ ok: false, error: 'Invalid Turnstile response' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
response.on('error', (error) => {
|
||||||
|
resolve({ ok: false, error: error.message || 'Turnstile read failed' })
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
request.on('timeout', () => {
|
||||||
|
request.destroy(new Error('Turnstile verification timed out'))
|
||||||
|
})
|
||||||
|
request.on('error', (error) => {
|
||||||
|
resolve({ ok: false, error: error.message || 'Turnstile request failed' })
|
||||||
|
})
|
||||||
|
|
||||||
|
request.write(payload)
|
||||||
|
request.end()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyTurnstileToken(token, remoteIp, options = {}) {
|
||||||
|
if (!TURNSTILE_SECRET_KEY) {
|
||||||
|
return { success: true, data: { skipped: true } }
|
||||||
|
}
|
||||||
|
if (!token || typeof token !== 'string') {
|
||||||
|
return { success: false, error: 'Missing Turnstile token', codes: ['missing-input-response'] }
|
||||||
|
}
|
||||||
|
if (token.length > TURNSTILE_MAX_TOKEN_LENGTH) {
|
||||||
|
return { success: false, error: 'Turnstile token too long', codes: ['invalid-input-response'] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const { expectedAction, expectedHostname, maxRetries = 2 } = options
|
||||||
|
const idempotencyKey = crypto.randomUUID()
|
||||||
|
|
||||||
|
let attempt = 0
|
||||||
|
let lastResult = null
|
||||||
|
while (attempt < maxRetries) {
|
||||||
|
attempt += 1
|
||||||
|
const call = await callTurnstileSiteverify(token, remoteIp, idempotencyKey)
|
||||||
|
lastResult = call
|
||||||
|
|
||||||
|
if (call.ok) {
|
||||||
|
const data = call.data || {}
|
||||||
|
if (!data.success) {
|
||||||
|
const codes = Array.isArray(data['error-codes']) ? data['error-codes'] : []
|
||||||
|
const transient = codes.includes('internal-error')
|
||||||
|
if (transient && attempt < maxRetries) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 250))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return { success: false, error: 'Turnstile verification failed', codes }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectedAction && data.action && data.action !== expectedAction) {
|
||||||
|
console.warn(`Turnstile action mismatch: expected ${expectedAction}, got ${data.action}`)
|
||||||
|
return { success: false, error: 'Action mismatch', codes: ['action-mismatch'] }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectedHostname && data.hostname && data.hostname !== expectedHostname) {
|
||||||
|
console.warn(`Turnstile hostname mismatch: expected ${expectedHostname}, got ${data.hostname}`)
|
||||||
|
return { success: false, error: 'Hostname mismatch', codes: ['hostname-mismatch'] }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.challenge_ts) {
|
||||||
|
const ageMs = Date.now() - Date.parse(data.challenge_ts)
|
||||||
|
if (Number.isFinite(ageMs) && ageMs > TURNSTILE_TOKEN_MAX_AGE_MS) {
|
||||||
|
console.warn(`Turnstile token age ${(ageMs / 1000).toFixed(1)}s exceeds limit`)
|
||||||
|
return { success: false, error: 'Token expired', codes: ['stale-token'] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, data }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < maxRetries) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 250))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Turnstile verification failed after retries:', lastResult?.error)
|
||||||
|
return { success: false, error: 'Turnstile verification unavailable', codes: ['internal-error'] }
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectedTurnstileHostname() {
|
||||||
|
if (!PUBLIC_ORIGIN) return ''
|
||||||
|
const first = PUBLIC_ORIGIN.split(',').map((origin) => origin.trim()).filter(Boolean)[0]
|
||||||
|
if (!first) return ''
|
||||||
|
try {
|
||||||
|
return new URL(first).hostname
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function purgeOldAnalytics(db) {
|
function purgeOldAnalytics(db) {
|
||||||
const eventCutoff = new Date(Date.now() - ANALYTICS_RETENTION_DAYS * 24 * 60 * 60 * 1000).toISOString()
|
const eventCutoff = new Date(Date.now() - ANALYTICS_RETENTION_DAYS * 24 * 60 * 60 * 1000).toISOString()
|
||||||
const activeCutoff = new Date(Date.now() - ANALYTICS_ACTIVE_WINDOW_SECONDS * 3 * 1000).toISOString()
|
const activeCutoff = new Date(Date.now() - ANALYTICS_ACTIVE_WINDOW_SECONDS * 3 * 1000).toISOString()
|
||||||
@@ -1318,7 +1447,15 @@ const server = http.createServer((req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
readJsonBody(req)
|
readJsonBody(req)
|
||||||
.then((payload) => {
|
.then(async (payload) => {
|
||||||
|
const verification = await verifyTurnstileToken(payload.turnstile_token, clientIp(req), {
|
||||||
|
expectedAction: 'analytics-delete',
|
||||||
|
expectedHostname: expectedTurnstileHostname(),
|
||||||
|
})
|
||||||
|
if (!verification.success) {
|
||||||
|
sendJson(res, 403, { error: 'Turnstile verification required', detail: verification.error })
|
||||||
|
return
|
||||||
|
}
|
||||||
const result = deleteViewerData(payload)
|
const result = deleteViewerData(payload)
|
||||||
sendJson(res, 200, result)
|
sendJson(res, 200, result)
|
||||||
})
|
})
|
||||||
@@ -1326,6 +1463,33 @@ const server = http.createServer((req, res) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST' && req.url === '/api/turnstile/verify') {
|
||||||
|
if (!isSameOriginRequest(req)) {
|
||||||
|
sendJson(res, 403, { error: 'Turnstile verification is restricted to this site' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRateLimited(req)) {
|
||||||
|
sendJson(res, 429, { error: 'Too many verification attempts' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
readJsonBody(req)
|
||||||
|
.then(async (payload) => {
|
||||||
|
const verification = await verifyTurnstileToken(payload.token, clientIp(req), {
|
||||||
|
expectedAction: typeof payload.action === 'string' ? payload.action : undefined,
|
||||||
|
expectedHostname: expectedTurnstileHostname(),
|
||||||
|
})
|
||||||
|
if (!verification.success) {
|
||||||
|
sendJson(res, 403, { error: 'Turnstile verification failed', detail: verification.error })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendJson(res, 200, { success: true })
|
||||||
|
})
|
||||||
|
.catch((error) => sendJson(res, 400, { error: error.message }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === 'OPTIONS' && req.url.startsWith('/api/')) {
|
if (req.method === 'OPTIONS' && req.url.startsWith('/api/')) {
|
||||||
sendJson(res, 403, { error: 'CORS requests are not allowed' })
|
sendJson(res, 403, { error: 'CORS requests are not allowed' })
|
||||||
return
|
return
|
||||||
@@ -1345,5 +1509,8 @@ server.listen(PORT, '0.0.0.0', () => {
|
|||||||
console.log(`sampling uptime every ${Math.round(UPTIME_SAMPLE_INTERVAL_MS / 60000)} minutes`)
|
console.log(`sampling uptime every ${Math.round(UPTIME_SAMPLE_INTERVAL_MS / 60000)} minutes`)
|
||||||
console.log(`storing uptime snapshots in ${path.join(uptimeStoragePath(), UPTIME_DATABASE_FILE)}`)
|
console.log(`storing uptime snapshots in ${path.join(uptimeStoragePath(), UPTIME_DATABASE_FILE)}`)
|
||||||
console.log(`storing viewer analytics in ${path.join(uptimeStoragePath(), ANALYTICS_DATABASE_FILE)}`)
|
console.log(`storing viewer analytics in ${path.join(uptimeStoragePath(), ANALYTICS_DATABASE_FILE)}`)
|
||||||
|
if (!TURNSTILE_SECRET_KEY) {
|
||||||
|
console.warn('TURNSTILE_SECRET_KEY is not set — Turnstile verification is disabled and gated endpoints will accept any request')
|
||||||
|
}
|
||||||
startUptimeSampler()
|
startUptimeSampler()
|
||||||
})
|
})
|
||||||
|
|||||||
+158
-7
@@ -37,6 +37,8 @@ const analyticsPreferencesCookie = 'tssbot_analytics_preferences'
|
|||||||
const analyticsVisitorKey = 'tssbot.analyticsVisitor'
|
const analyticsVisitorKey = 'tssbot.analyticsVisitor'
|
||||||
const analyticsConsentVersion = 3
|
const analyticsConsentVersion = 3
|
||||||
|
|
||||||
|
const turnstileSiteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY || ''
|
||||||
|
|
||||||
const defaultAnalyticsPreferences = {
|
const defaultAnalyticsPreferences = {
|
||||||
chosen: false,
|
chosen: false,
|
||||||
analytics: false,
|
analytics: false,
|
||||||
@@ -349,6 +351,66 @@ function Stat({ label, value }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TurnstileWidget({ siteKey, theme = 'auto', size = 'normal', action, onVerify, onExpire, onError, resetSignal = 0 }) {
|
||||||
|
const containerRef = useRef(null)
|
||||||
|
const widgetIdRef = useRef(null)
|
||||||
|
const callbacksRef = useRef({ onVerify, onExpire, onError })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
callbacksRef.current = { onVerify, onExpire, onError }
|
||||||
|
}, [onVerify, onExpire, onError])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!siteKey) return undefined
|
||||||
|
const container = containerRef.current
|
||||||
|
if (!container) return undefined
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
let pollTimer
|
||||||
|
|
||||||
|
const renderWidget = () => {
|
||||||
|
if (cancelled) return
|
||||||
|
if (!window.turnstile || typeof window.turnstile.render !== 'function') {
|
||||||
|
pollTimer = window.setTimeout(renderWidget, 150)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const options = {
|
||||||
|
sitekey: siteKey,
|
||||||
|
theme,
|
||||||
|
size,
|
||||||
|
callback: (token) => callbacksRef.current.onVerify?.(token),
|
||||||
|
'expired-callback': () => callbacksRef.current.onExpire?.(),
|
||||||
|
'error-callback': () => callbacksRef.current.onError?.(),
|
||||||
|
}
|
||||||
|
if (action) options.action = action
|
||||||
|
widgetIdRef.current = window.turnstile.render(container, options)
|
||||||
|
}
|
||||||
|
renderWidget()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
window.clearTimeout(pollTimer)
|
||||||
|
const widgetId = widgetIdRef.current
|
||||||
|
widgetIdRef.current = null
|
||||||
|
if (widgetId != null && window.turnstile?.remove) {
|
||||||
|
try { window.turnstile.remove(widgetId) } catch { /* widget already gone */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [siteKey, theme, size, action])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!resetSignal) return
|
||||||
|
const widgetId = widgetIdRef.current
|
||||||
|
if (widgetId != null && window.turnstile?.reset) {
|
||||||
|
try { window.turnstile.reset(widgetId) } catch { /* widget gone or already reset */ }
|
||||||
|
}
|
||||||
|
}, [resetSignal])
|
||||||
|
|
||||||
|
if (!siteKey) return null
|
||||||
|
|
||||||
|
return <div ref={containerRef} />
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [route, setRoute] = useState(() => parseRoute())
|
const [route, setRoute] = useState(() => parseRoute())
|
||||||
const [leaderboard, setLeaderboard] = useState({ status: 'idle', data: null, error: null })
|
const [leaderboard, setLeaderboard] = useState({ status: 'idle', data: null, error: null })
|
||||||
@@ -847,7 +909,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function chooseAnalyticsConsent(preferences) {
|
function chooseAnalyticsConsent(preferences, turnstileToken = '') {
|
||||||
const previousVisitorId = storedVisitorId(analyticsVisitorKey)
|
const previousVisitorId = storedVisitorId(analyticsVisitorKey)
|
||||||
const nextPreferences = persistAnalyticsPreferences({ ...preferences, chosen: true })
|
const nextPreferences = persistAnalyticsPreferences({ ...preferences, chosen: true })
|
||||||
|
|
||||||
@@ -860,6 +922,7 @@ function App() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
visitor_id: previousVisitorId,
|
visitor_id: previousVisitorId,
|
||||||
session_id: analyticsSessionId,
|
session_id: analyticsSessionId,
|
||||||
|
turnstile_token: turnstileToken,
|
||||||
}),
|
}),
|
||||||
keepalive: true,
|
keepalive: true,
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
@@ -985,7 +1048,11 @@ function ConsentBanner({ preferences, onChoose }) {
|
|||||||
const [isOpen, setIsOpen] = useState(() => !preferences.chosen)
|
const [isOpen, setIsOpen] = useState(() => !preferences.chosen)
|
||||||
const [isConfiguring, setIsConfiguring] = useState(() => Boolean(preferences.chosen))
|
const [isConfiguring, setIsConfiguring] = useState(() => Boolean(preferences.chosen))
|
||||||
const [draft, setDraft] = useState(() => normalizeAnalyticsPreferences(preferences))
|
const [draft, setDraft] = useState(() => normalizeAnalyticsPreferences(preferences))
|
||||||
|
const [turnstileToken, setTurnstileToken] = useState('')
|
||||||
|
const [turnstileReset, setTurnstileReset] = useState(0)
|
||||||
const privacySignalEnabled = browserPrivacySignalEnabled()
|
const privacySignalEnabled = browserPrivacySignalEnabled()
|
||||||
|
const visitorId = storedVisitorId(analyticsVisitorKey)
|
||||||
|
const deleteVerificationRequired = Boolean(visitorId) && Boolean(turnstileSiteKey)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDraft(normalizeAnalyticsPreferences(preferences))
|
setDraft(normalizeAnalyticsPreferences(preferences))
|
||||||
@@ -1011,10 +1078,19 @@ function ConsentBanner({ preferences, onChoose }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function savePreferences(nextPreferences) {
|
function savePreferences(nextPreferences) {
|
||||||
onChoose(normalizeAnalyticsPreferences(nextPreferences))
|
const normalized = normalizeAnalyticsPreferences(nextPreferences)
|
||||||
|
const willDelete = Boolean(visitorId) && !normalized.analytics
|
||||||
|
if (willDelete && turnstileSiteKey && !turnstileToken) return
|
||||||
|
onChoose(normalized, willDelete ? turnstileToken : '')
|
||||||
|
setTurnstileToken('')
|
||||||
|
setTurnstileReset((value) => value + 1)
|
||||||
setIsOpen(false)
|
setIsOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const willDeleteFromDraft = Boolean(visitorId) && !draft.analytics
|
||||||
|
const blockSaveDraft = willDeleteFromDraft && deleteVerificationRequired && !turnstileToken
|
||||||
|
const blockDeclineAll = Boolean(visitorId) && deleteVerificationRequired && !turnstileToken
|
||||||
|
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -1106,16 +1182,34 @@ function ConsentBanner({ preferences, onChoose }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{deleteVerificationRequired ? (
|
||||||
|
<div className="mt-5">
|
||||||
|
<p className="mb-2 text-xs font-semibold text-text-soft">
|
||||||
|
Confirm you are not a bot to delete your existing analytics data:
|
||||||
|
</p>
|
||||||
|
<TurnstileWidget
|
||||||
|
siteKey={turnstileSiteKey}
|
||||||
|
action="analytics-delete"
|
||||||
|
onVerify={(token) => setTurnstileToken(token)}
|
||||||
|
onExpire={() => setTurnstileToken('')}
|
||||||
|
onError={() => setTurnstileToken('')}
|
||||||
|
resetSignal={turnstileReset}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
<div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||||
<button
|
<button
|
||||||
className="rounded-md border border-border px-4 py-2 text-sm font-semibold text-text-soft transition hover:bg-surface hover:text-text"
|
className="rounded-md border border-border px-4 py-2 text-sm font-semibold text-text-soft transition hover:bg-surface hover:text-text disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
disabled={blockDeclineAll}
|
||||||
onClick={() => savePreferences({ ...defaultAnalyticsPreferences, chosen: true })}
|
onClick={() => savePreferences({ ...defaultAnalyticsPreferences, chosen: true })}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Decline all
|
Decline all
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="rounded-md border border-border px-4 py-2 text-sm font-semibold text-text transition hover:bg-surface"
|
className="rounded-md border border-border px-4 py-2 text-sm font-semibold text-text transition hover:bg-surface disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
disabled={blockSaveDraft}
|
||||||
onClick={() => savePreferences(draft)}
|
onClick={() => savePreferences(draft)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
@@ -1281,6 +1375,43 @@ function Landing({
|
|||||||
teamQuery,
|
teamQuery,
|
||||||
}) {
|
}) {
|
||||||
const treeRef = useRef(null)
|
const treeRef = useRef(null)
|
||||||
|
const [searchToken, setSearchToken] = useState('')
|
||||||
|
const [searchTurnstileReset, setSearchTurnstileReset] = useState(0)
|
||||||
|
const [searchError, setSearchError] = useState('')
|
||||||
|
const [searchSubmitting, setSearchSubmitting] = useState(false)
|
||||||
|
|
||||||
|
async function handleSearchSubmit(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (searchSubmitting) return
|
||||||
|
if (!teamQuery.trim()) return
|
||||||
|
|
||||||
|
setSearchError('')
|
||||||
|
setSearchSubmitting(true)
|
||||||
|
try {
|
||||||
|
if (turnstileSiteKey) {
|
||||||
|
if (!searchToken) {
|
||||||
|
setSearchError('Please complete the verification before searching.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const verifyResponse = await fetch('/api/turnstile/verify', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token: searchToken, action: 'team-search' }),
|
||||||
|
})
|
||||||
|
if (!verifyResponse.ok) {
|
||||||
|
setSearchError('Verification failed — please try again.')
|
||||||
|
setSearchToken('')
|
||||||
|
setSearchTurnstileReset((value) => value + 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await onTeamSearch(event)
|
||||||
|
} finally {
|
||||||
|
setSearchSubmitting(false)
|
||||||
|
setSearchToken('')
|
||||||
|
setSearchTurnstileReset((value) => value + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative left-1/2 w-screen -translate-x-1/2 bg-bg">
|
<div className="relative left-1/2 w-screen -translate-x-1/2 bg-bg">
|
||||||
@@ -1324,7 +1455,7 @@ function Landing({
|
|||||||
|
|
||||||
<form
|
<form
|
||||||
className="mt-5 grid gap-2 rounded-lg border border-border bg-fury-white/88 p-2 shadow-sm backdrop-blur sm:grid-cols-[1fr_auto]"
|
className="mt-5 grid gap-2 rounded-lg border border-border bg-fury-white/88 p-2 shadow-sm backdrop-blur sm:grid-cols-[1fr_auto]"
|
||||||
onSubmit={onTeamSearch}
|
onSubmit={handleSearchSubmit}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
className="min-w-0 rounded-md border border-border bg-bg px-4 py-3 text-sm outline-none transition focus:border-ring focus:shadow-[0_0_0_3px_var(--color-shadow)]"
|
className="min-w-0 rounded-md border border-border bg-bg px-4 py-3 text-sm outline-none transition focus:border-ring focus:shadow-[0_0_0_3px_var(--color-shadow)]"
|
||||||
@@ -1333,11 +1464,31 @@ function Landing({
|
|||||||
onChange={(event) => setTeamQuery(event.target.value)}
|
onChange={(event) => setTeamQuery(event.target.value)}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
className="rounded-md bg-fury-cyan px-5 py-3 text-sm font-semibold text-text transition hover:bg-fury-aqua"
|
className="rounded-md bg-fury-cyan px-5 py-3 text-sm font-semibold text-text transition hover:bg-fury-aqua disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
disabled={searchSubmitting || (Boolean(turnstileSiteKey) && !searchToken)}
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
Search teams
|
{searchSubmitting ? 'Searching…' : 'Search teams'}
|
||||||
</button>
|
</button>
|
||||||
|
{turnstileSiteKey ? (
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<TurnstileWidget
|
||||||
|
siteKey={turnstileSiteKey}
|
||||||
|
size="flexible"
|
||||||
|
action="team-search"
|
||||||
|
onVerify={(token) => {
|
||||||
|
setSearchToken(token)
|
||||||
|
setSearchError('')
|
||||||
|
}}
|
||||||
|
onExpire={() => setSearchToken('')}
|
||||||
|
onError={() => setSearchToken('')}
|
||||||
|
resetSignal={searchTurnstileReset}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{searchError ? (
|
||||||
|
<p className="text-xs font-semibold text-red-600 sm:col-span-2">{searchError}</p>
|
||||||
|
) : null}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user