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 ? (
|
||||
|
||||
Reference in New Issue
Block a user