feat: add captcha-backed forum security hardening

This commit is contained in:
2026-03-17 16:06:28 +01:00
parent 980a15f66e
commit b3fc889452
40 changed files with 2849 additions and 108 deletions

View File

@@ -8,6 +8,8 @@ import Toggle from '../../components/ui/Toggle'
import Select from '../../components/ui/Select'
import Modal from '../../components/ui/Modal'
import { RadioGroup } from '../../components/ui/Radio'
import { buildBotFingerprint } from '../../lib/security/botFingerprint'
import TurnstileField from '../../components/security/TurnstileField'
const SETTINGS_SECTIONS = [
{ key: 'profile', label: 'Profile', icon: 'fa-solid fa-user-astronaut', description: 'Public identity and avatar.' },
@@ -57,6 +59,16 @@ function getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}
async function botHeaders(extra = {}, captcha = {}) {
const fingerprint = await buildBotFingerprint()
return {
...extra,
'X-Bot-Fingerprint': fingerprint,
...(captcha?.token ? { 'X-Captcha-Token': captcha.token } : {}),
}
}
function toIsoDate(day, month, year) {
if (!day || !month || !year) return ''
return `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
@@ -122,6 +134,8 @@ export default function ProfileEdit() {
usernameCooldownDays = 30,
usernameCooldownRemainingDays = 0,
usernameCooldownActive = false,
captcha: initialCaptcha = {},
flash = {},
} = props
const fallbackDate = toIsoDate(
@@ -194,6 +208,17 @@ export default function ProfileEdit() {
notifications: {},
security: {},
})
const [captchaState, setCaptchaState] = useState({
required: !!flash?.botCaptchaRequired,
section: '',
token: '',
message: '',
nonce: 0,
provider: initialCaptcha?.provider || '',
siteKey: initialCaptcha?.siteKey || '',
inputName: initialCaptcha?.inputName || 'cf-turnstile-response',
scriptUrl: initialCaptcha?.scriptUrl || '',
})
const [avatarUrl, setAvatarUrl] = useState(initialAvatarUrl || '')
const [avatarFile, setAvatarFile] = useState(null)
@@ -346,6 +371,92 @@ export default function ProfileEdit() {
setErrorsBySection((prev) => ({ ...prev, [section]: {} }))
}
const resetCaptchaState = () => {
setCaptchaState((prev) => ({
...prev,
required: false,
section: '',
token: '',
message: '',
nonce: prev.nonce + 1,
}))
}
const captureCaptchaRequirement = (section, payload = {}) => {
const requiresCaptcha = !!(payload?.requires_captcha || payload?.requiresCaptcha)
if (!requiresCaptcha) {
return false
}
const nextCaptcha = payload?.captcha || {}
const message = payload?.errors?.captcha?.[0] || payload?.message || 'Complete the captcha challenge to continue.'
setCaptchaState((prev) => ({
required: true,
section,
token: '',
message,
nonce: prev.nonce + 1,
provider: nextCaptcha.provider || payload?.captcha_provider || prev.provider || initialCaptcha?.provider || 'turnstile',
siteKey: nextCaptcha.siteKey || payload?.captcha_site_key || prev.siteKey || initialCaptcha?.siteKey || '',
inputName: nextCaptcha.inputName || payload?.captcha_input || prev.inputName || initialCaptcha?.inputName || 'cf-turnstile-response',
scriptUrl: nextCaptcha.scriptUrl || payload?.captcha_script_url || prev.scriptUrl || initialCaptcha?.scriptUrl || '',
}))
updateSectionErrors(section, {
_general: [message],
captcha: [message],
})
return true
}
const applyCaptchaPayload = (payload = {}) => {
if (!captchaState.required || !captchaState.inputName) {
return payload
}
return {
...payload,
[captchaState.inputName]: captchaState.token || '',
}
}
const applyCaptchaFormData = (formData) => {
if (captchaState.required && captchaState.inputName) {
formData.set(captchaState.inputName, captchaState.token || '')
}
}
const renderCaptchaChallenge = (section, placement = 'section') => {
if (!captchaState.required || !captchaState.siteKey || activeSection !== section) {
return null
}
if (section === 'account' && showEmailChangeModal && placement !== 'modal') {
return null
}
if (section === 'account' && !showEmailChangeModal && placement === 'modal') {
return null
}
return (
<div className="rounded-xl border border-amber-400/20 bg-amber-500/10 p-4">
<p className="mb-3 text-sm text-amber-100">{captchaState.message || 'Complete the captcha challenge to continue.'}</p>
<TurnstileField
key={`${section}-${placement}-${captchaState.nonce}`}
provider={captchaState.provider}
siteKey={captchaState.siteKey}
scriptUrl={captchaState.scriptUrl}
onToken={(token) => setCaptchaState((prev) => ({ ...prev, token }))}
className="rounded-lg border border-white/10 bg-black/20 p-3"
/>
</div>
)
}
const switchSection = (nextSection) => {
if (activeSection === nextSection) return
if (dirtyMap[activeSection]) {
@@ -397,19 +508,23 @@ export default function ProfileEdit() {
if (avatarFile) {
formData.append('avatar', avatarFile)
}
applyCaptchaFormData(formData)
const response = await fetch('/settings/profile/update', {
method: 'POST',
credentials: 'same-origin',
headers: {
headers: await botHeaders({
Accept: 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
},
}, captchaState),
body: formData,
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
if (captureCaptchaRequirement('profile', payload)) {
return
}
updateSectionErrors('profile', payload.errors || { _general: [payload.message || 'Unable to save profile section.'] })
return
}
@@ -421,6 +536,7 @@ export default function ProfileEdit() {
setAvatarFile(null)
setAvatarPosition('center')
setRemoveAvatar(false)
resetCaptchaState()
setSavedMessage({ section: 'profile', text: payload.message || 'Profile updated successfully.' })
} catch (error) {
updateSectionErrors('profile', { _general: ['Request failed. Please try again.'] })
@@ -446,21 +562,25 @@ export default function ProfileEdit() {
const response = await fetch('/settings/account/username', {
method: 'POST',
credentials: 'same-origin',
headers: {
headers: await botHeaders({
'Content-Type': 'application/json',
Accept: 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
},
body: JSON.stringify({ username: accountForm.username }),
}, captchaState),
body: JSON.stringify(applyCaptchaPayload({ username: accountForm.username, homepage_url: '' })),
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
if (captureCaptchaRequirement('account', payload)) {
return
}
updateSectionErrors('account', payload.errors || { _general: [payload.message || 'Unable to save account section.'] })
return
}
initialRef.current.accountForm = { ...accountForm }
resetCaptchaState()
setSavedMessage({ section: 'account', text: payload.message || 'Account updated successfully.' })
} catch (error) {
updateSectionErrors('account', { _general: ['Request failed. Please try again.'] })
@@ -478,21 +598,26 @@ export default function ProfileEdit() {
const response = await fetch('/settings/email/request', {
method: 'POST',
credentials: 'same-origin',
headers: {
headers: await botHeaders({
'Content-Type': 'application/json',
Accept: 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
},
body: JSON.stringify({ new_email: emailChangeForm.new_email }),
}, captchaState),
body: JSON.stringify(applyCaptchaPayload({ new_email: emailChangeForm.new_email, homepage_url: '' })),
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
if (captureCaptchaRequirement('account', payload)) {
setEmailChangeError(payload?.errors?.captcha?.[0] || payload?.message || 'Complete the captcha challenge to continue.')
return
}
setEmailChangeError(payload?.errors?.new_email?.[0] || payload?.message || 'Unable to request email change.')
return
}
setEmailChangeStep('verify')
resetCaptchaState()
setEmailChangeInfo(payload.message || 'Verification code sent to your new email address.')
} catch (error) {
setEmailChangeError('Request failed. Please try again.')
@@ -510,16 +635,20 @@ export default function ProfileEdit() {
const response = await fetch('/settings/email/verify', {
method: 'POST',
credentials: 'same-origin',
headers: {
headers: await botHeaders({
'Content-Type': 'application/json',
Accept: 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
},
body: JSON.stringify({ code: emailChangeForm.code }),
}, captchaState),
body: JSON.stringify(applyCaptchaPayload({ code: emailChangeForm.code, homepage_url: '' })),
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
if (captureCaptchaRequirement('account', payload)) {
setEmailChangeError(payload?.errors?.captcha?.[0] || payload?.message || 'Complete the captcha challenge to continue.')
return
}
setEmailChangeError(payload?.errors?.code?.[0] || payload?.message || 'Verification failed.')
return
}
@@ -530,6 +659,7 @@ export default function ProfileEdit() {
setShowEmailChangeModal(false)
setEmailChangeStep('request')
setEmailChangeForm({ new_email: '', code: '' })
resetCaptchaState()
setSavedMessage({ section: 'account', text: payload.message || 'Email updated successfully.' })
} catch (error) {
setEmailChangeError('Request failed. Please try again.')
@@ -547,25 +677,30 @@ export default function ProfileEdit() {
const response = await fetch('/settings/personal/update', {
method: 'POST',
credentials: 'same-origin',
headers: {
headers: await botHeaders({
'Content-Type': 'application/json',
Accept: 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
},
body: JSON.stringify({
}, captchaState),
body: JSON.stringify(applyCaptchaPayload({
birthday: toIsoDate(personalForm.day, personalForm.month, personalForm.year) || null,
gender: personalForm.gender || null,
country: personalForm.country || null,
}),
homepage_url: '',
})),
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
if (captureCaptchaRequirement('personal', payload)) {
return
}
updateSectionErrors('personal', payload.errors || { _general: [payload.message || 'Unable to save personal details.'] })
return
}
initialRef.current.personalForm = { ...personalForm }
resetCaptchaState()
setSavedMessage({ section: 'personal', text: payload.message || 'Personal details saved successfully.' })
} catch (error) {
updateSectionErrors('personal', { _general: ['Request failed. Please try again.'] })
@@ -583,21 +718,25 @@ export default function ProfileEdit() {
const response = await fetch('/settings/notifications/update', {
method: 'POST',
credentials: 'same-origin',
headers: {
headers: await botHeaders({
'Content-Type': 'application/json',
Accept: 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
},
body: JSON.stringify(notificationForm),
}, captchaState),
body: JSON.stringify(applyCaptchaPayload({ ...notificationForm, homepage_url: '' })),
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
if (captureCaptchaRequirement('notifications', payload)) {
return
}
updateSectionErrors('notifications', payload.errors || { _general: [payload.message || 'Unable to save notifications.'] })
return
}
initialRef.current.notificationForm = { ...notificationForm }
resetCaptchaState()
setSavedMessage({ section: 'notifications', text: payload.message || 'Notification settings saved successfully.' })
} catch (error) {
updateSectionErrors('notifications', { _general: ['Request failed. Please try again.'] })
@@ -615,16 +754,19 @@ export default function ProfileEdit() {
const response = await fetch('/settings/security/password', {
method: 'POST',
credentials: 'same-origin',
headers: {
headers: await botHeaders({
'Content-Type': 'application/json',
Accept: 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
},
body: JSON.stringify(securityForm),
}, captchaState),
body: JSON.stringify(applyCaptchaPayload({ ...securityForm, homepage_url: '' })),
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
if (captureCaptchaRequirement('security', payload)) {
return
}
updateSectionErrors('security', payload.errors || { _general: [payload.message || 'Unable to update password.'] })
return
}
@@ -634,6 +776,7 @@ export default function ProfileEdit() {
new_password: '',
new_password_confirmation: '',
})
resetCaptchaState()
setSavedMessage({ section: 'security', text: payload.message || 'Password updated successfully.' })
} catch (error) {
updateSectionErrors('security', { _general: ['Request failed. Please try again.'] })
@@ -857,6 +1000,8 @@ export default function ProfileEdit() {
rows={3}
error={errorsBySection.profile.description?.[0]}
/>
{renderCaptchaChallenge('profile')}
</div>
</div>
</SectionCard>
@@ -933,6 +1078,8 @@ export default function ProfileEdit() {
<p className="mt-4 rounded-lg border border-white/10 bg-white/[0.02] px-3 py-2 text-xs text-slate-300">
You can change your username once every {usernameCooldownDays} days.
</p>
{renderCaptchaChallenge('account')}
</SectionCard>
</form>
) : null}
@@ -1034,6 +1181,8 @@ export default function ProfileEdit() {
error={errorsBySection.personal.country?.[0]}
/>
)}
{renderCaptchaChallenge('personal')}
</div>
</SectionCard>
</form>
@@ -1085,6 +1234,8 @@ export default function ProfileEdit() {
/>
</div>
))}
{renderCaptchaChallenge('notifications')}
</div>
</SectionCard>
</form>
@@ -1152,6 +1303,8 @@ export default function ProfileEdit() {
<div className="rounded-lg border border-white/5 bg-white/[0.02] p-3 text-xs text-slate-400">
Future security controls: Two-factor authentication, active sessions, and login history.
</div>
{renderCaptchaChallenge('security')}
</div>
</SectionCard>
</form>
@@ -1221,6 +1374,8 @@ export default function ProfileEdit() {
</div>
) : null}
{renderCaptchaChallenge('account', 'modal')}
{emailChangeStep === 'request' ? (
<TextInput
label="Enter new email address"

View File

@@ -0,0 +1,165 @@
import React, { useEffect, useRef } from 'react'
const providerAdapters = {
turnstile: {
globalName: 'turnstile',
render(api, container, { siteKey, theme, onToken }) {
return api.render(container, {
sitekey: siteKey,
theme,
callback: (token) => onToken?.(token || ''),
'expired-callback': () => onToken?.(''),
'error-callback': () => onToken?.(''),
})
},
cleanup(api, widgetId, container, onToken) {
if (widgetId !== null && api?.remove) {
api.remove(widgetId)
}
if (container) {
container.innerHTML = ''
}
onToken?.('')
},
},
recaptcha: {
globalName: 'grecaptcha',
render(api, container, { siteKey, theme, onToken }) {
return api.render(container, {
sitekey: siteKey,
theme,
callback: (token) => onToken?.(token || ''),
'expired-callback': () => onToken?.(''),
'error-callback': () => onToken?.(''),
})
},
cleanup(api, widgetId, container, onToken) {
if (widgetId !== null && api?.reset) {
api.reset(widgetId)
}
if (container) {
container.innerHTML = ''
}
onToken?.('')
},
},
hcaptcha: {
globalName: 'hcaptcha',
render(api, container, { siteKey, theme, onToken }) {
return api.render(container, {
sitekey: siteKey,
theme,
callback: (token) => onToken?.(token || ''),
'expired-callback': () => onToken?.(''),
'error-callback': () => onToken?.(''),
})
},
cleanup(api, widgetId, container, onToken) {
if (widgetId !== null && api?.remove) {
api.remove(widgetId)
}
if (container) {
container.innerHTML = ''
}
onToken?.('')
},
},
}
function loadCaptchaScript(src) {
if (!src) {
return Promise.resolve()
}
if (!window.__skinbaseCaptchaScripts) {
window.__skinbaseCaptchaScripts = {}
}
if (!window.__skinbaseCaptchaScripts[src]) {
window.__skinbaseCaptchaScripts[src] = new Promise((resolve, reject) => {
const existing = document.querySelector(`script[src="${src}"]`)
if (existing) {
if (existing.dataset.loaded === 'true') {
resolve()
return
}
existing.addEventListener('load', () => resolve(), { once: true })
existing.addEventListener('error', () => reject(new Error(`Failed to load captcha script: ${src}`)), { once: true })
return
}
const script = document.createElement('script')
script.src = src
script.async = true
script.defer = true
script.addEventListener('load', () => {
script.dataset.loaded = 'true'
resolve()
}, { once: true })
script.addEventListener('error', () => reject(new Error(`Failed to load captcha script: ${src}`)), { once: true })
document.head.appendChild(script)
})
}
return window.__skinbaseCaptchaScripts[src]
}
export default function TurnstileField({ provider = 'turnstile', siteKey, scriptUrl = '', onToken, theme = 'dark', className = '' }) {
const containerRef = useRef(null)
const widgetIdRef = useRef(null)
useEffect(() => {
const adapter = providerAdapters[provider] || providerAdapters.turnstile
if (!siteKey || !containerRef.current) {
return undefined
}
let cancelled = false
let intervalId = null
const mountWidget = () => {
const api = window[adapter.globalName]
if (cancelled || !api?.render || widgetIdRef.current !== null) {
return
}
widgetIdRef.current = adapter.render(api, containerRef.current, {
siteKey,
theme,
onToken,
})
}
loadCaptchaScript(scriptUrl).catch(() => onToken?.('')).finally(() => {
const api = window[adapter.globalName]
if (typeof api?.ready === 'function') {
api.ready(mountWidget)
} else {
mountWidget()
}
if (widgetIdRef.current === null) {
intervalId = window.setInterval(mountWidget, 250)
}
})
return () => {
cancelled = true
if (intervalId) {
window.clearInterval(intervalId)
}
adapter.cleanup(window[adapter.globalName], widgetIdRef.current, containerRef.current, onToken)
widgetIdRef.current = null
}
}, [className, onToken, provider, scriptUrl, siteKey, theme])
if (!siteKey) {
return null
}
return <div ref={containerRef} className={className} />
}

View File

@@ -0,0 +1,65 @@
async function sha256Hex(value) {
if (!window.crypto?.subtle) {
return ''
}
const encoded = new TextEncoder().encode(value)
const digest = await window.crypto.subtle.digest('SHA-256', encoded)
return Array.from(new Uint8Array(digest))
.map((part) => part.toString(16).padStart(2, '0'))
.join('')
}
function readWebglVendor() {
try {
const canvas = document.createElement('canvas')
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl')
if (!gl) {
return 'no-webgl'
}
const extension = gl.getExtension('WEBGL_debug_renderer_info')
if (!extension) {
return 'webgl-hidden'
}
return [
gl.getParameter(extension.UNMASKED_VENDOR_WEBGL),
gl.getParameter(extension.UNMASKED_RENDERER_WEBGL),
].join(':')
} catch {
return 'webgl-error'
}
}
export async function buildBotFingerprint() {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'unknown'
const screenSize = typeof window.screen !== 'undefined'
? `${window.screen.width}x${window.screen.height}x${window.devicePixelRatio || 1}`
: 'no-screen'
const payload = [
navigator.userAgent || 'unknown-ua',
navigator.language || 'unknown-language',
navigator.platform || 'unknown-platform',
timezone,
screenSize,
readWebglVendor(),
].join('|')
return sha256Hex(payload)
}
export async function populateBotFingerprint(form) {
if (!form) {
return ''
}
const fingerprint = await buildBotFingerprint()
const field = form.querySelector('input[name="_bot_fingerprint"]')
if (field && fingerprint !== '') {
field.value = fingerprint
}
return fingerprint
}

View File

@@ -16,10 +16,23 @@
</div>
@endif
@if($errors->has('bot'))
<div class="rounded-lg bg-red-900/40 border border-red-500/40 px-4 py-3 text-sm text-red-300 mb-4">
{{ $errors->first('bot') }}
</div>
@endif
@include('auth.partials.social-login')
<form method="POST" action="{{ route('login') }}" class="space-y-5">
<form method="POST" action="{{ route('login') }}" class="space-y-5" data-bot-form>
@csrf
<input type="text" name="homepage_url" value="" tabindex="-1" autocomplete="off" class="hidden" aria-hidden="true">
<input type="hidden" name="_bot_fingerprint" value="">
@php
$captchaProvider = $captcha['provider'] ?? 'turnstile';
$captchaSiteKey = $captcha['siteKey'] ?? '';
@endphp
<div>
<label class="block text-sm mb-1 text-white/80" for="email">Email</label>
@@ -33,6 +46,17 @@
<x-input-error :messages="$errors->get('password')" class="mt-2" />
</div>
@if(($requiresCaptcha ?? false) && $captchaSiteKey !== '')
@if($captchaProvider === 'recaptcha')
<div class="g-recaptcha" data-sitekey="{{ $captchaSiteKey }}" data-theme="dark"></div>
@elseif($captchaProvider === 'hcaptcha')
<div class="h-captcha" data-sitekey="{{ $captchaSiteKey }}" data-theme="dark"></div>
@else
<div class="cf-turnstile" data-sitekey="{{ $captchaSiteKey }}" data-theme="dark"></div>
@endif
<x-input-error :messages="$errors->get('captcha')" class="mt-2" />
@endif
<div class="flex items-center justify-between text-sm text-white/60">
<label class="flex items-center gap-2">
<input type="checkbox" name="remember" class="rounded bg-slate-800 border-white/20" />
@@ -51,4 +75,8 @@
</div>
</div>
</div>
@if(($requiresCaptcha ?? false) && (($captcha['siteKey'] ?? '') !== '') && (($captcha['scriptUrl'] ?? '') !== ''))
<script src="{{ $captcha['scriptUrl'] }}" async defer></script>
@endif
@include('partials.bot-fingerprint-script')
@endsection

View File

@@ -13,9 +13,22 @@
</div>
@endif
@if($errors->has('bot'))
<div class="rounded-lg bg-red-900/40 border border-red-500/40 px-4 py-3 text-sm text-red-300 mb-4">
{{ $errors->first('bot') }}
</div>
@endif
@include('auth.partials.social-login', ['dividerLabel' => 'or register with email'])
<form method="POST" action="{{ route('register') }}" class="space-y-5">
<form method="POST" action="{{ route('register') }}" class="space-y-5" data-bot-form>
@csrf
<input type="text" name="homepage_url" value="" tabindex="-1" autocomplete="off" class="hidden" aria-hidden="true">
<input type="hidden" name="_bot_fingerprint" value="">
@php
$captchaProvider = $captcha['provider'] ?? 'turnstile';
$captchaSiteKey = $captcha['siteKey'] ?? '';
@endphp
<div>
<label class="block text-sm mb-1 text-white/80" for="email">Email</label>
@@ -23,8 +36,14 @@
<x-input-error :messages="$errors->get('email')" class="mt-2" />
</div>
@if(($requiresTurnstile ?? false) && ($turnstileSiteKey ?? '') !== '')
<div class="cf-turnstile" data-sitekey="{{ $turnstileSiteKey }}"></div>
@if((($requiresCaptcha ?? false) || session('bot_captcha_required')) && $captchaSiteKey !== '')
@if($captchaProvider === 'recaptcha')
<div class="g-recaptcha" data-sitekey="{{ $captchaSiteKey }}" data-theme="dark"></div>
@elseif($captchaProvider === 'hcaptcha')
<div class="h-captcha" data-sitekey="{{ $captchaSiteKey }}" data-theme="dark"></div>
@else
<div class="cf-turnstile" data-sitekey="{{ $captchaSiteKey }}" data-theme="dark"></div>
@endif
<x-input-error :messages="$errors->get('captcha')" class="mt-2" />
@endif
@@ -35,7 +54,8 @@
</div>
</div>
</div>
@if(($requiresTurnstile ?? false) && ($turnstileSiteKey ?? '') !== '')
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
@if((($requiresCaptcha ?? false) || session('bot_captcha_required')) && (($captcha['siteKey'] ?? '') !== '') && (($captcha['scriptUrl'] ?? '') !== ''))
<script src="{{ $captcha['scriptUrl'] }}" async defer></script>
@endif
@include('partials.bot-fingerprint-script')
@endsection

View File

@@ -0,0 +1,55 @@
<script>
(() => {
const forms = document.querySelectorAll('[data-bot-form]');
if (!forms.length || !window.crypto?.subtle) {
return;
}
const readWebglVendor = () => {
try {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
if (!gl) {
return 'no-webgl';
}
const extension = gl.getExtension('WEBGL_debug_renderer_info');
if (!extension) {
return 'webgl-hidden';
}
return [
gl.getParameter(extension.UNMASKED_VENDOR_WEBGL),
gl.getParameter(extension.UNMASKED_RENDERER_WEBGL),
].join(':');
} catch {
return 'webgl-error';
}
};
const fingerprintPayload = [
navigator.userAgent || 'unknown-ua',
navigator.language || 'unknown-language',
navigator.platform || 'unknown-platform',
Intl.DateTimeFormat().resolvedOptions().timeZone || 'unknown-timezone',
`${window.screen?.width || 0}x${window.screen?.height || 0}x${window.devicePixelRatio || 1}`,
readWebglVendor(),
].join('|');
const encodeHex = (buffer) => Array.from(new Uint8Array(buffer))
.map((part) => part.toString(16).padStart(2, '0'))
.join('');
window.crypto.subtle.digest('SHA-256', new TextEncoder().encode(fingerprintPayload))
.then((buffer) => {
const fingerprint = encodeHex(buffer);
forms.forEach((form) => {
const input = form.querySelector('input[name="_bot_fingerprint"]');
if (input) {
input.value = fingerprint;
}
});
})
.catch(() => {});
})();
</script>