diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..5f15b9d --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(npm run *)" + ] + } +} diff --git a/example.env b/example.env index 89e3197..219531c 100644 --- a/example.env +++ b/example.env @@ -18,3 +18,8 @@ WEBHOOK_PORT=3011 GITHUB_WEBHOOK_SECRET=change-me PM2_RESTART_TARGETS=tssbot-web 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= diff --git a/index.html b/index.html index 138e995..2cac58a 100644 --- a/index.html +++ b/index.html @@ -1,6 +1,13 @@ + + + diff --git a/server.cjs b/server.cjs index 96b9e45..c82e25e 100644 --- a/server.cjs +++ b/server.cjs @@ -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_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 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 MAX_TEAM_NAME_LENGTH = 80 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) { 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() @@ -1318,7 +1447,15 @@ const server = http.createServer((req, res) => { } 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) sendJson(res, 200, result) }) @@ -1326,6 +1463,33 @@ const server = http.createServer((req, res) => { 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/')) { sendJson(res, 403, { error: 'CORS requests are not allowed' }) 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(`storing uptime snapshots in ${path.join(uptimeStoragePath(), UPTIME_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() }) diff --git a/src/App.jsx b/src/App.jsx index 4c85e93..aa3b630 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -37,6 +37,8 @@ const analyticsPreferencesCookie = 'tssbot_analytics_preferences' const analyticsVisitorKey = 'tssbot.analyticsVisitor' const analyticsConsentVersion = 3 +const turnstileSiteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY || '' + const defaultAnalyticsPreferences = { chosen: 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
+} + function App() { const [route, setRoute] = useState(() => parseRoute()) 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 nextPreferences = persistAnalyticsPreferences({ ...preferences, chosen: true }) @@ -860,6 +922,7 @@ function App() { body: JSON.stringify({ visitor_id: previousVisitorId, session_id: analyticsSessionId, + turnstile_token: turnstileToken, }), keepalive: true, }).catch(() => {}) @@ -985,7 +1048,11 @@ 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)) @@ -1011,10 +1078,19 @@ function ConsentBanner({ preferences, onChoose }) { } 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) } + const willDeleteFromDraft = Boolean(visitorId) && !draft.analytics + const blockSaveDraft = willDeleteFromDraft && deleteVerificationRequired && !turnstileToken + const blockDeclineAll = Boolean(visitorId) && deleteVerificationRequired && !turnstileToken + if (!isOpen) { 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}