update osm

This commit is contained in:
Heidi
2026-05-16 09:02:39 +01:00
parent 878e2a6a47
commit f36bdf3738
2 changed files with 191 additions and 107 deletions
+80 -11
View File
@@ -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) { function callTurnstileSiteverify(token, remoteIp, idempotencyKey) {
return new Promise((resolve) => { return new Promise((resolve) => {
const params = new URLSearchParams() const params = new URLSearchParams()
@@ -1446,16 +1509,13 @@ const server = http.createServer((req, res) => {
return return
} }
if (!isTurnstileSessionVerified(req)) {
sendJson(res, 403, { error: 'Turnstile session required', detail: 'Solve the site challenge first' })
return
}
readJsonBody(req) readJsonBody(req)
.then(async (payload) => { .then((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)
}) })
@@ -1477,19 +1537,28 @@ const server = http.createServer((req, res) => {
readJsonBody(req) readJsonBody(req)
.then(async (payload) => { .then(async (payload) => {
const verification = await verifyTurnstileToken(payload.token, clientIp(req), { const verification = await verifyTurnstileToken(payload.token, clientIp(req), {
expectedAction: typeof payload.action === 'string' ? payload.action : undefined, expectedAction: 'site-gate',
expectedHostname: expectedTurnstileHostname(), expectedHostname: expectedTurnstileHostname(),
}) })
if (!verification.success) { if (!verification.success) {
sendJson(res, 403, { error: 'Turnstile verification failed', detail: verification.error }) sendJson(res, 403, { error: 'Turnstile verification failed', detail: verification.error })
return 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 })) .catch((error) => sendJson(res, 400, { error: error.message }))
return 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/')) { 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
+111 -96
View File
@@ -411,7 +411,111 @@ function TurnstileWidget({ siteKey, theme = 'auto', size = 'normal', action, onV
return <div ref={containerRef} /> return <div ref={containerRef} />
} }
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 (
<div
aria-labelledby="site-gate-title"
aria-modal="true"
className="fixed inset-0 z-[9999] grid place-items-center bg-bg/95 px-4 py-6 backdrop-blur-sm"
role="dialog"
>
<div className="flex w-full max-w-sm flex-col items-center gap-4 rounded-md border border-border bg-fury-white p-6 text-center text-text shadow-[0_24px_70px_rgba(0,0,0,0.24)]">
<h1 id="site-gate-title" className="text-lg font-semibold">
Verifying you are human
</h1>
<p className="text-sm text-text-soft">
This quick check protects the site from automated abuse. It usually clears itself.
</p>
<TurnstileWidget
siteKey={turnstileSiteKey}
action="site-gate"
onVerify={handleVerify}
onExpire={() => setError('Challenge expired — please solve it again.')}
onError={() => setError('Challenge could not load. Refresh to retry.')}
resetSignal={resetSignal}
/>
{status === 'verifying' ? (
<p className="text-xs text-text-soft">Confirming with Cloudflare</p>
) : null}
{error ? <p className="text-xs font-semibold text-red-600">{error}</p> : null}
</div>
</div>
)
}
function App() { 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 <div className="fixed inset-0 grid place-items-center bg-bg" aria-hidden="true" />
}
if (gateState === 'required') {
return <SiteGate onVerified={() => setGateState('verified')} />
}
return <AppContent />
}
function AppContent() {
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 })
const [live, setLive] = useState({ status: 'idle', data: null, error: null }) const [live, setLive] = useState({ status: 'idle', data: null, error: null })
@@ -909,7 +1013,7 @@ function App() {
} }
} }
function chooseAnalyticsConsent(preferences, turnstileToken = '') { function chooseAnalyticsConsent(preferences) {
const previousVisitorId = storedVisitorId(analyticsVisitorKey) const previousVisitorId = storedVisitorId(analyticsVisitorKey)
const nextPreferences = persistAnalyticsPreferences({ ...preferences, chosen: true }) const nextPreferences = persistAnalyticsPreferences({ ...preferences, chosen: true })
@@ -922,7 +1026,6 @@ 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(() => {})
@@ -1048,11 +1151,7 @@ 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))
@@ -1078,19 +1177,10 @@ function ConsentBanner({ preferences, onChoose }) {
} }
function savePreferences(nextPreferences) { function savePreferences(nextPreferences) {
const normalized = normalizeAnalyticsPreferences(nextPreferences) onChoose(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
@@ -1182,34 +1272,16 @@ 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 disabled:cursor-not-allowed disabled:opacity-60" 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={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 disabled:cursor-not-allowed disabled:opacity-60" className="rounded-md border border-border px-4 py-2 text-sm font-semibold text-text transition hover:bg-surface"
disabled={blockSaveDraft}
onClick={() => savePreferences(draft)} onClick={() => savePreferences(draft)}
type="button" type="button"
> >
@@ -1375,43 +1447,6 @@ 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">
@@ -1455,7 +1490,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={handleSearchSubmit} onSubmit={onTeamSearch}
> >
<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)]"
@@ -1464,31 +1499,11 @@ 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 disabled:cursor-not-allowed disabled:opacity-60" className="rounded-md bg-fury-cyan px-5 py-3 text-sm font-semibold text-text transition hover:bg-fury-aqua"
disabled={searchSubmitting || (Boolean(turnstileSiteKey) && !searchToken)}
type="submit" type="submit"
> >
{searchSubmitting ? 'Searching…' : 'Search teams'} 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>