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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user