feat(feedback): Rate-Limit und Spam-Erkennung für Feedback-Formular
Schützt den Feedback-Endpunkt vor Missbrauch durch pro-Nutzer-Limits, Honeypot, Zeitprüfung und einfache Inhaltsheuristiken. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -443,6 +443,16 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.feedback-form__honeypot {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.feedback-form__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -26,9 +26,11 @@ export default function FeedbackModal({
|
||||
const [category, setCategory] = useState<FeedbackCategory>('general')
|
||||
const [contactEmail, setContactEmail] = useState('')
|
||||
const [message, setMessage] = useState('')
|
||||
const [website, setWebsite] = useState('')
|
||||
const [submitState, setSubmitState] = useState<SubmitState>('idle')
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null)
|
||||
const closeTimerRef = useRef<number | null>(null)
|
||||
const openedAtRef = useRef<number>(Date.now())
|
||||
|
||||
const isBusy = submitState === 'submitting' || submitState === 'success'
|
||||
|
||||
@@ -58,9 +60,12 @@ export default function FeedbackModal({
|
||||
setCategory('general')
|
||||
setContactEmail('')
|
||||
setMessage('')
|
||||
setWebsite('')
|
||||
setSubmitState('idle')
|
||||
setStatusMessage(null)
|
||||
return
|
||||
}
|
||||
openedAtRef.current = Date.now()
|
||||
}, [open])
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
@@ -76,7 +81,9 @@ export default function FeedbackModal({
|
||||
message: message.trim(),
|
||||
contactEmail: contactEmail.trim() || undefined,
|
||||
logbookId,
|
||||
logbookTitle
|
||||
logbookTitle,
|
||||
openedAt: openedAtRef.current,
|
||||
website
|
||||
})
|
||||
setSubmitState('success')
|
||||
setStatusMessage(t('feedback.success'))
|
||||
@@ -91,7 +98,11 @@ export default function FeedbackModal({
|
||||
? t('feedback.error_not_configured')
|
||||
: error instanceof FeedbackApiError && error.code === 'INVALID_EMAIL'
|
||||
? t('feedback.error_invalid_email')
|
||||
: t('feedback.error_send')
|
||||
: error instanceof FeedbackApiError && error.code === 'RATE_LIMITED'
|
||||
? t('feedback.error_rate_limited')
|
||||
: error instanceof FeedbackApiError && error.code === 'SPAM_DETECTED'
|
||||
? t('feedback.error_spam')
|
||||
: t('feedback.error_send')
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -139,6 +150,18 @@ export default function FeedbackModal({
|
||||
)}
|
||||
|
||||
<form className="feedback-form" onSubmit={handleSubmit}>
|
||||
<label className="feedback-form__honeypot" aria-hidden="true">
|
||||
<span>Website</span>
|
||||
<input
|
||||
type="text"
|
||||
name="website"
|
||||
value={website}
|
||||
onChange={(event) => setWebsite(event.target.value)}
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="feedback-form__field">
|
||||
<span>{t('feedback.category_label')}</span>
|
||||
<select
|
||||
|
||||
@@ -416,7 +416,9 @@
|
||||
"success": "Vielen Dank! Ihr Feedback wurde gesendet.",
|
||||
"error_send": "Feedback konnte nicht gesendet werden. Bitte versuchen Sie es später erneut.",
|
||||
"error_invalid_email": "Bitte geben Sie eine gültige E-Mail-Adresse ein.",
|
||||
"error_not_configured": "Feedback ist auf diesem Server nicht verfügbar."
|
||||
"error_not_configured": "Feedback ist auf diesem Server nicht verfügbar.",
|
||||
"error_rate_limited": "Zu viele Feedback-Nachrichten in kurzer Zeit. Bitte warten Sie einige Minuten.",
|
||||
"error_spam": "Diese Nachricht konnte nicht gesendet werden. Bitte formulieren Sie sie anders."
|
||||
},
|
||||
"demo": {
|
||||
"logbook_title": "Demo-Logbuch Ostsee",
|
||||
|
||||
@@ -416,7 +416,9 @@
|
||||
"success": "Thank you! Your feedback has been sent.",
|
||||
"error_send": "Could not send feedback. Please try again later.",
|
||||
"error_invalid_email": "Please enter a valid email address.",
|
||||
"error_not_configured": "Feedback is not available on this server."
|
||||
"error_not_configured": "Feedback is not available on this server.",
|
||||
"error_rate_limited": "Too many feedback messages in a short time. Please wait a few minutes.",
|
||||
"error_spam": "This message could not be sent. Please rephrase it and try again."
|
||||
},
|
||||
"demo": {
|
||||
"logbook_title": "Baltic Sea Demo Logbook",
|
||||
|
||||
@@ -3,11 +3,11 @@ import { apiFetch } from './api.js'
|
||||
export type FeedbackCategory = 'bug' | 'feature' | 'general'
|
||||
|
||||
export class FeedbackApiError extends Error {
|
||||
code: 'NOT_CONFIGURED' | 'REQUEST_FAILED' | 'INVALID_EMAIL'
|
||||
code: 'NOT_CONFIGURED' | 'REQUEST_FAILED' | 'INVALID_EMAIL' | 'RATE_LIMITED' | 'SPAM_DETECTED'
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
code: 'NOT_CONFIGURED' | 'REQUEST_FAILED' | 'INVALID_EMAIL' = 'REQUEST_FAILED'
|
||||
code: 'NOT_CONFIGURED' | 'REQUEST_FAILED' | 'INVALID_EMAIL' | 'RATE_LIMITED' | 'SPAM_DETECTED' = 'REQUEST_FAILED'
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'FeedbackApiError'
|
||||
@@ -27,6 +27,8 @@ export async function sendFeedback(payload: {
|
||||
contactEmail?: string | null
|
||||
logbookId?: string | null
|
||||
logbookTitle?: string | null
|
||||
openedAt: number
|
||||
website?: string
|
||||
}): Promise<void> {
|
||||
const contactEmail = payload.contactEmail?.trim()
|
||||
if (contactEmail && !isValidFeedbackEmail(contactEmail)) {
|
||||
@@ -43,7 +45,9 @@ export async function sendFeedback(payload: {
|
||||
logbookId: payload.logbookId || undefined,
|
||||
logbookTitle: payload.logbookTitle || undefined,
|
||||
appVersion: typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : undefined,
|
||||
pageUrl: window.location.href
|
||||
pageUrl: window.location.href,
|
||||
openedAt: payload.openedAt,
|
||||
website: payload.website || undefined
|
||||
})
|
||||
})
|
||||
|
||||
@@ -51,8 +55,15 @@ export async function sendFeedback(payload: {
|
||||
throw new FeedbackApiError('Feedback is not configured on this server', 'NOT_CONFIGURED')
|
||||
}
|
||||
|
||||
if (res.status === 429) {
|
||||
throw new FeedbackApiError('Too many feedback submissions', 'RATE_LIMITED')
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
throw new FeedbackApiError(data.error || 'Failed to send feedback')
|
||||
throw new FeedbackApiError(
|
||||
data.error || 'Failed to send feedback',
|
||||
data.code === 'SPAM_DETECTED' ? 'SPAM_DETECTED' : 'REQUEST_FAILED'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user