update osm
This commit is contained in:
+80
-11
@@ -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
|
||||
|
||||
+111
-96
@@ -411,7 +411,111 @@ function TurnstileWidget({ siteKey, theme = 'auto', size = 'normal', action, onV
|
||||
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() {
|
||||
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 [leaderboard, setLeaderboard] = 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 nextPreferences = persistAnalyticsPreferences({ ...preferences, chosen: true })
|
||||
|
||||
@@ -922,7 +1026,6 @@ function App() {
|
||||
body: JSON.stringify({
|
||||
visitor_id: previousVisitorId,
|
||||
session_id: analyticsSessionId,
|
||||
turnstile_token: turnstileToken,
|
||||
}),
|
||||
keepalive: true,
|
||||
}).catch(() => {})
|
||||
@@ -1048,11 +1151,7 @@ function ConsentBanner({ preferences, onChoose }) {
|
||||
const [isOpen, setIsOpen] = useState(() => !preferences.chosen)
|
||||
const [isConfiguring, setIsConfiguring] = useState(() => Boolean(preferences.chosen))
|
||||
const [draft, setDraft] = useState(() => normalizeAnalyticsPreferences(preferences))
|
||||
const [turnstileToken, setTurnstileToken] = useState('')
|
||||
const [turnstileReset, setTurnstileReset] = useState(0)
|
||||
const privacySignalEnabled = browserPrivacySignalEnabled()
|
||||
const visitorId = storedVisitorId(analyticsVisitorKey)
|
||||
const deleteVerificationRequired = Boolean(visitorId) && Boolean(turnstileSiteKey)
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(normalizeAnalyticsPreferences(preferences))
|
||||
@@ -1078,19 +1177,10 @@ function ConsentBanner({ preferences, onChoose }) {
|
||||
}
|
||||
|
||||
function savePreferences(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)
|
||||
onChoose(normalizeAnalyticsPreferences(nextPreferences))
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const willDeleteFromDraft = Boolean(visitorId) && !draft.analytics
|
||||
const blockSaveDraft = willDeleteFromDraft && deleteVerificationRequired && !turnstileToken
|
||||
const blockDeclineAll = Boolean(visitorId) && deleteVerificationRequired && !turnstileToken
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<button
|
||||
@@ -1182,34 +1272,16 @@ function ConsentBanner({ preferences, onChoose }) {
|
||||
/>
|
||||
</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">
|
||||
<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"
|
||||
disabled={blockDeclineAll}
|
||||
className="rounded-md border border-border px-4 py-2 text-sm font-semibold text-text-soft transition hover:bg-surface hover:text-text"
|
||||
onClick={() => savePreferences({ ...defaultAnalyticsPreferences, chosen: true })}
|
||||
type="button"
|
||||
>
|
||||
Decline all
|
||||
</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"
|
||||
disabled={blockSaveDraft}
|
||||
className="rounded-md border border-border px-4 py-2 text-sm font-semibold text-text transition hover:bg-surface"
|
||||
onClick={() => savePreferences(draft)}
|
||||
type="button"
|
||||
>
|
||||
@@ -1375,43 +1447,6 @@ function Landing({
|
||||
teamQuery,
|
||||
}) {
|
||||
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 (
|
||||
<div className="relative left-1/2 w-screen -translate-x-1/2 bg-bg">
|
||||
@@ -1455,7 +1490,7 @@ function Landing({
|
||||
|
||||
<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]"
|
||||
onSubmit={handleSearchSubmit}
|
||||
onSubmit={onTeamSearch}
|
||||
>
|
||||
<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)]"
|
||||
@@ -1464,31 +1499,11 @@ function Landing({
|
||||
onChange={(event) => setTeamQuery(event.target.value)}
|
||||
/>
|
||||
<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"
|
||||
disabled={searchSubmitting || (Boolean(turnstileSiteKey) && !searchToken)}
|
||||
className="rounded-md bg-fury-cyan px-5 py-3 text-sm font-semibold text-text transition hover:bg-fury-aqua"
|
||||
type="submit"
|
||||
>
|
||||
{searchSubmitting ? 'Searching…' : 'Search teams'}
|
||||
Search teams
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user