From f36bdf37388387009c3399269736fe5859548eab Mon Sep 17 00:00:00 2001 From: Heidi Date: Sat, 16 May 2026 09:02:39 +0100 Subject: [PATCH] update osm --- server.cjs | 91 ++++++++++++++++++++--- src/App.jsx | 207 ++++++++++++++++++++++++++++------------------------ 2 files changed, 191 insertions(+), 107 deletions(-) diff --git a/server.cjs b/server.cjs index c82e25e..1526a0c 100644 --- a/server.cjs +++ b/server.cjs @@ -536,6 +536,69 @@ function readJsonBody(req) { }) } +const TURNSTILE_SESSION_COOKIE = 'tssbot_turnstile' +const TURNSTILE_SESSION_TTL_SECONDS = 30 * 60 +const TURNSTILE_SESSION_HMAC_KEY = crypto + .createHash('sha256') + .update(`tssbot-turnstile-session|${TURNSTILE_SECRET_KEY}`) + .digest() + +function signTurnstileSession(expiresAt) { + return crypto + .createHmac('sha256', TURNSTILE_SESSION_HMAC_KEY) + .update(String(expiresAt)) + .digest('base64url') +} + +function buildTurnstileSessionCookie(req) { + const expiresAt = Math.floor(Date.now() / 1000) + TURNSTILE_SESSION_TTL_SECONDS + const signature = signTurnstileSession(expiresAt) + const value = `${expiresAt}.${signature}` + const proto = req.headers['x-forwarded-proto'] || (req.socket.encrypted ? 'https' : 'http') + const isHttps = String(proto).split(',')[0].trim() === 'https' + const parts = [ + `${TURNSTILE_SESSION_COOKIE}=${value}`, + 'Path=/', + `Max-Age=${TURNSTILE_SESSION_TTL_SECONDS}`, + 'HttpOnly', + 'SameSite=Strict', + ] + if (isHttps) parts.push('Secure') + return parts.join('; ') +} + +function parseRequestCookies(req) { + const header = req.headers.cookie || '' + const out = {} + for (const part of header.split(/;\s*/)) { + if (!part) continue + const eq = part.indexOf('=') + if (eq < 1) continue + out[part.slice(0, eq)] = part.slice(eq + 1) + } + return out +} + +function isTurnstileSessionVerified(req) { + if (!TURNSTILE_SECRET_KEY) return true + const cookies = parseRequestCookies(req) + const cookie = cookies[TURNSTILE_SESSION_COOKIE] + if (!cookie) return false + const dot = cookie.indexOf('.') + if (dot < 1) return false + const expiresAtStr = cookie.slice(0, dot) + const signature = cookie.slice(dot + 1) + const expiresAt = Number(expiresAtStr) + if (!Number.isFinite(expiresAt) || expiresAt < Math.floor(Date.now() / 1000)) return false + const expected = signTurnstileSession(expiresAt) + if (signature.length !== expected.length) return false + try { + return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)) + } catch { + return false + } +} + function callTurnstileSiteverify(token, remoteIp, idempotencyKey) { return new Promise((resolve) => { const params = new URLSearchParams() @@ -1446,16 +1509,13 @@ const server = http.createServer((req, res) => { return } + if (!isTurnstileSessionVerified(req)) { + sendJson(res, 403, { error: 'Turnstile session required', detail: 'Solve the site challenge first' }) + return + } + readJsonBody(req) - .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 - } + .then((payload) => { const result = deleteViewerData(payload) sendJson(res, 200, result) }) @@ -1477,19 +1537,28 @@ const server = http.createServer((req, res) => { readJsonBody(req) .then(async (payload) => { const verification = await verifyTurnstileToken(payload.token, clientIp(req), { - expectedAction: typeof payload.action === 'string' ? payload.action : undefined, + expectedAction: 'site-gate', expectedHostname: expectedTurnstileHostname(), }) if (!verification.success) { sendJson(res, 403, { error: 'Turnstile verification failed', detail: verification.error }) return } - sendJson(res, 200, { success: true }) + const headers = TURNSTILE_SECRET_KEY ? { 'set-cookie': buildTurnstileSessionCookie(req) } : {} + send(res, 200, JSON.stringify({ success: true, ttl: TURNSTILE_SESSION_TTL_SECONDS }), { + ...jsonHeaders, + ...headers, + }) }) .catch((error) => sendJson(res, 400, { error: error.message })) return } + if (req.method === 'GET' && req.url === '/api/turnstile/session') { + sendJson(res, 200, { verified: isTurnstileSessionVerified(req) }) + return + } + if (req.method === 'OPTIONS' && req.url.startsWith('/api/')) { sendJson(res, 403, { error: 'CORS requests are not allowed' }) return diff --git a/src/App.jsx b/src/App.jsx index aa3b630..5ca8ab3 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -411,7 +411,111 @@ function TurnstileWidget({ siteKey, theme = 'auto', size = 'normal', action, onV return
} +function SiteGate({ onVerified }) { + const [status, setStatus] = useState('idle') + const [error, setError] = useState('') + const [resetSignal, setResetSignal] = useState(0) + const submittingRef = useRef(false) + + useEffect(() => { + const previousOverflow = document.body.style.overflow + document.body.style.overflow = 'hidden' + return () => { + document.body.style.overflow = previousOverflow + } + }, []) + + async function handleVerify(token) { + if (submittingRef.current) return + submittingRef.current = true + setStatus('verifying') + setError('') + try { + const response = await fetch('/api/turnstile/verify', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ token }), + }) + if (!response.ok) { + setError('Verification failed — please try again.') + setStatus('idle') + setResetSignal((value) => value + 1) + return + } + setStatus('verified') + onVerified() + } catch { + setError('Network error — please retry.') + setStatus('idle') + setResetSignal((value) => value + 1) + } finally { + submittingRef.current = false + } + } + + return ( +
+
+

+ Verifying you are human +

+

+ This quick check protects the site from automated abuse. It usually clears itself. +

+ setError('Challenge expired — please solve it again.')} + onError={() => setError('Challenge could not load. Refresh to retry.')} + resetSignal={resetSignal} + /> + {status === 'verifying' ? ( +

Confirming with Cloudflare…

+ ) : null} + {error ?

{error}

: null} +
+
+ ) +} + function App() { + const [gateState, setGateState] = useState(turnstileSiteKey ? 'checking' : 'verified') + + useEffect(() => { + if (!turnstileSiteKey) return undefined + let cancelled = false + fetch('/api/turnstile/session', { headers: { Accept: 'application/json' } }) + .then((response) => (response.ok ? response.json() : { verified: false })) + .then((data) => { + if (cancelled) return + setGateState(data?.verified ? 'verified' : 'required') + }) + .catch(() => { + if (!cancelled) setGateState('required') + }) + return () => { + cancelled = true + } + }, []) + + if (gateState === 'checking') { + return - {deleteVerificationRequired ? ( -
-

- Confirm you are not a bot to delete your existing analytics data: -

- setTurnstileToken(token)} - onExpire={() => setTurnstileToken('')} - onError={() => setTurnstileToken('')} - resetSignal={turnstileReset} - /> -
- ) : null} -
- {turnstileSiteKey ? ( -
- { - setSearchToken(token) - setSearchError('') - }} - onExpire={() => setSearchToken('')} - onError={() => setSearchToken('')} - resetSignal={searchTurnstileReset} - /> -
- ) : null} - {searchError ? ( -

{searchError}

- ) : null}