Add comment spam classification and captcha checks.

Score artwork comments with local signatures plus Together AI, and optionally require Turnstile before posting.
This commit is contained in:
2026-09-20 14:48:50 +02:00
parent 37bacc1334
commit 586c44ba74
16 changed files with 858 additions and 135 deletions
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'
import axios from 'axios'
import ReactMarkdown from 'react-markdown'
import EmojiPickerButton from './EmojiPickerButton'
import TurnstileField from '../security/TurnstileField'
/* ── Toolbar icon components ──────────────────────────────────────────────── */
function BoldIcon() {
@@ -96,6 +97,8 @@ export default function CommentForm({
const [tab, setTab] = useState('write') // 'write' | 'preview'
const [submitting, setSubmitting] = useState(false)
const [errors, setErrors] = useState([])
const [captcha, setCaptcha] = useState({ required: false, token: '', provider: 'turnstile', siteKey: '', scriptUrl: '', inputName: 'cf-turnstile-response', nonce: 0 })
const captchaAutoSubmitRef = useRef(false)
const textareaRef = useRef(null)
const formRef = useRef(null)
const resolvedSubmitUrl = submitUrl || (artworkId ? `/api/artworks/${artworkId}/comments` : null)
@@ -234,6 +237,17 @@ export default function CommentForm({
}
}, [wrapSelection, insertLink])
const handleCaptchaToken = useCallback((token) => {
if (!token) {
captchaAutoSubmitRef.current = false
setCaptcha((current) => ({ ...current, token: '' }))
return
}
captchaAutoSubmitRef.current = true
setCaptcha((current) => ({ ...current, token }))
}, [])
/* ── Submit ───────────────────────────────────────────────────────────── */
const handleSubmit = useCallback(
async (e) => {
@@ -251,18 +265,36 @@ export default function CommentForm({
setErrors([])
try {
const { data } = await axios.post(resolvedSubmitUrl, {
const payload = {
[contentField]: trimmed,
parent_id: parentId || null,
})
}
if (captcha.required) payload[captcha.inputName] = captcha.token || ''
const { data } = await axios.post(resolvedSubmitUrl, payload)
setContent('')
setTab('write')
setCaptcha((current) => ({ ...current, required: false, token: '' }))
onPosted?.(data.data)
onCancelReply?.()
} catch (err) {
if (err.response?.status === 422) {
const fieldErrors = err.response.data?.errors ?? {}
const response = err.response.data ?? {}
const fieldErrors = response.errors ?? {}
if (response.requires_captcha) {
const challenge = response.captcha ?? {}
setCaptcha((current) => ({
...current,
required: true,
token: '',
nonce: current.nonce + 1,
provider: challenge.provider || response.captcha_provider || current.provider,
siteKey: challenge.siteKey || response.captcha_site_key || current.siteKey,
scriptUrl: challenge.scriptUrl || response.captcha_script_url || current.scriptUrl,
inputName: challenge.inputName || response.captcha_input || current.inputName,
}))
}
const allErrors = [
...(Array.isArray(fieldErrors[contentField]) ? fieldErrors[contentField] : []),
...Object.entries(fieldErrors)
@@ -277,9 +309,19 @@ export default function CommentForm({
setSubmitting(false)
}
},
[content, contentField, isLoggedIn, loginUrl, onPosted, parentId, onCancelReply, resolvedSubmitUrl],
[captcha, content, contentField, isLoggedIn, loginUrl, onPosted, parentId, onCancelReply, resolvedSubmitUrl],
)
// Turnstile is a gate in front of the existing submission. Once it returns
// a valid token, continue the original submit automatically so the user
// does not need to press the button a second time.
useEffect(() => {
if (!captcha.required || !captcha.token || submitting || !captchaAutoSubmitRef.current) return
captchaAutoSubmitRef.current = false
handleSubmit({ preventDefault() {} })
}, [captcha.required, captcha.token, handleSubmit, submitting])
/* ── Logged-out state ─────────────────────────────────────────────────── */
if (!isLoggedIn) {
return (
@@ -441,6 +483,20 @@ export default function CommentForm({
</div>
{/* Errors */}
{captcha.required && captcha.siteKey && (
<div className="rounded-xl border border-amber-400/20 bg-amber-500/10 p-4">
<p className="mb-3 text-sm text-amber-100">Complete the captcha challenge to continue.</p>
<TurnstileField
key={`comment-captcha-${captcha.nonce}`}
provider={captcha.provider}
siteKey={captcha.siteKey}
scriptUrl={captcha.scriptUrl}
onToken={handleCaptchaToken}
className="rounded-lg border border-white/10 bg-black/20 p-3"
/>
</div>
)}
{errors.length > 0 && (
<ul className="space-y-1 rounded-xl border border-red-500/20 bg-red-500/[0.06] px-4 py-2.5" role="alert">
{errors.map((e, i) => (
@@ -455,7 +511,7 @@ export default function CommentForm({
<div className="flex justify-end">
<button
type="submit"
disabled={submitting || !content.trim()}
disabled={submitting || !content.trim() || (captcha.required && !captcha.token)}
className="rounded-full bg-accent px-6 py-2 text-sm font-semibold text-white shadow-lg shadow-accent/20 transition-all duration-200 hover:bg-accent/90 hover:shadow-xl hover:shadow-accent/25 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-deep disabled:pointer-events-none disabled:opacity-40 disabled:shadow-none"
>
{submitting ? (
@@ -7,6 +7,8 @@ const providerAdapters = {
return api.render(container, {
sitekey: siteKey,
theme,
appearance: 'always',
size: 'normal',
callback: (token) => onToken?.(token || ''),
'expired-callback': () => onToken?.(''),
'error-callback': () => onToken?.(''),
@@ -80,6 +82,15 @@ function loadCaptchaScript(src) {
const existing = document.querySelector(`script[src="${src}"]`)
if (existing) {
// The registration page may have loaded the same Turnstile script
// already. In that case its load event has fired before this promise
// was created, so waiting only for another load event would never
// resolve and the widget would remain an empty container.
if (window.turnstile || window.grecaptcha || window.hcaptcha) {
resolve()
return
}
if (existing.dataset.loaded === 'true') {
resolve()
return
@@ -109,6 +120,11 @@ function loadCaptchaScript(src) {
export default function TurnstileField({ provider = 'turnstile', siteKey, scriptUrl = '', onToken, theme = 'dark', className = '' }) {
const containerRef = useRef(null)
const widgetIdRef = useRef(null)
const onTokenRef = useRef(onToken)
useEffect(() => {
onTokenRef.current = onToken
}, [onToken])
useEffect(() => {
const adapter = providerAdapters[provider] || providerAdapters.turnstile
@@ -130,17 +146,17 @@ export default function TurnstileField({ provider = 'turnstile', siteKey, script
widgetIdRef.current = adapter.render(api, containerRef.current, {
siteKey,
theme,
onToken,
onToken: (token) => onTokenRef.current?.(token),
})
}
loadCaptchaScript(scriptUrl).catch(() => onToken?.('')).finally(() => {
loadCaptchaScript(scriptUrl).catch(() => onTokenRef.current?.('')).finally(() => {
const api = window[adapter.globalName]
if (typeof api?.ready === 'function') {
api.ready(mountWidget)
} else {
mountWidget()
}
// Do not call turnstile.ready() here. Cloudflare rejects ready() when
// api.js was loaded with async/defer (as it is on the registration
// page). Polling until render() exists works for both an already-loaded
// shared script and a script that is still loading.
mountWidget()
if (widgetIdRef.current === null) {
intervalId = window.setInterval(mountWidget, 250)
@@ -152,10 +168,10 @@ export default function TurnstileField({ provider = 'turnstile', siteKey, script
if (intervalId) {
window.clearInterval(intervalId)
}
adapter.cleanup(window[adapter.globalName], widgetIdRef.current, containerRef.current, onToken)
adapter.cleanup(window[adapter.globalName], widgetIdRef.current, containerRef.current, (token) => onTokenRef.current?.(token))
widgetIdRef.current = null
}
}, [className, onToken, provider, scriptUrl, siteKey, theme])
}, [provider, scriptUrl, siteKey, theme])
if (!siteKey) {
return null