From 7e350187892f50614525643ae4a6da89362abc9f Mon Sep 17 00:00:00 2001 From: Andreas Hnida Date: Fri, 26 Apr 2024 23:18:02 +0200 Subject: [PATCH] feat: improve form bot detection and validation with multi-language support --- assets/js/bestellformular.js | 344 ++++++++++++++++++++--------------- 1 file changed, 200 insertions(+), 144 deletions(-) diff --git a/assets/js/bestellformular.js b/assets/js/bestellformular.js index 973810b..3566f48 100644 --- a/assets/js/bestellformular.js +++ b/assets/js/bestellformular.js @@ -1,160 +1,216 @@ -document.addEventListener('DOMContentLoaded', function () { - console.log('bestellformular.js loaded') - // Rest of the code goes here - const FORMDEBUG = false - const btn = document.getElementById('bestellformular-btn') - const bestellformular = document.getElementById('bestellformular') +// Configuration and Messages +const debugEnabled = true +const mouseDebugEnabled = false +const zsrCheckEnabled = false +const interactionThreshold = 15 // Time in seconds +const interactionCountThreshold = 5 // Number of interactions +let botDetected = false - // custom Validation messages - const messagesGerman = { +const messages = { + de: { required: 'Bitte füllen Sie dieses Feld aus', email: 'Bitte geben Sie eine gültige E-Mail-Adresse ein', - minlength: 'Bitte geben Sie mindestens {0} Zeichen ein', - maxlength: 'Bitte geben Sie maximal {0} Zeichen ein', - min: 'Bitte geben Sie mindestens {0} ein', - max: 'Bitte geben Sie maximal {0} ein', - range: 'Bitte geben Sie zwischen {0} und {1} ein', - } - const messagesFrench = { + success: 'Die Bestellung wurde erfolgreich übermittelt!', + thankyou: 'Vielen Dank für Ihre Bestellung!', + zsrTooltip: 'Bitte geben Sie eine gültige ZSR-Nummer, oder "beantragt" ein.', + captcha: 'Geben Sie den angezeigten Captcha-Code ein', + captchaButton: 'Überprüfen', + }, + fr: { required: 'Veuillez remplir ce champ', email: 'Veuillez saisir une adresse email valide', - minlength: 'Veuillez saisir au moins {0} caractères', - maxlength: 'Veuillez saisir au plus {0} caractères', - min: 'Veuillez saisir au moins {0} caractères', - max: 'Veuillez saisir au plus {0} caractères', - range: 'Veuillez saisir au moins {0} et au plus {1} caractères', + success: 'La commande a bien été envoyée!', + thankyou: 'Merci de votre commande!', + zsrTooltip: 'Veuillez saisir une ZSR-Nummer valide, ou indiquer "demandé".', + captcha: 'Entrez le code Captcha affiché', + captchaButton: 'Vérifier', + }, +} + +// DOM Selectors +const debugLabel = document.createElement('div') +const submitButton = document.getElementById('bestellformular-btn') +const form = document.querySelector('form#bestellformular') +const notification = document.getElementById('notification') +const zsrTooltip = document.getElementById('zsr-tooltip') +const honeypotInput1 = document.getElementById('age') +const honeypotInput2 = document.getElementById('hobbies') +const verifyEmailInput = document.getElementById('verify_email') +const emailInput = document.getElementById('email') +const textInputs = document.querySelectorAll('input[type="text"]') +const captcha = document.querySelectorAll('.captcha') +const captchaInput = document.querySelectorAll('.captcha-input') +const captchaVerifyButton = document.querySelectorAll('.captcha-verify') + +// Utility variables +let startTime = Date.now() +let interactionCount = 0 +let userInteracted = false +let lastInteractionTime = null +const mousePositions = [] +const interactionTimes = [] +let isStraightLine = true + +// Utility functions +function log(thing) { + console.log(thing) +} + +function getCurrentLangMessages() { + log(messages[document.documentElement.lang.split('-')[0]]) + return messages[document.documentElement.lang.split('-')[0]] +} + +function setUserInteracted() { + const currentTime = Date.now() + if (lastInteractionTime) { + const timeDiff = (currentTime - lastInteractionTime) / 1000 // time in seconds + interactionTimes.push(timeDiff) + log('Time since last interaction: ' + timeDiff + ' seconds') } + lastInteractionTime = currentTime + userInteracted = true + interactionCount++ +} - // custom Validation rules - // determine which language depending on html lang attribute - const lang = document.documentElement.lang - console.log('lang', lang) - const messages = lang === 'de-DE' ? messagesGerman : messagesFrench - // set custom validation messages for each validator - console.log('messages', messages) +function handleMouseMove(event) { + mousePositions.push({ x: event.clientX, y: event.clientY }) + if (debugEnabled && mouseDebugEnabled) log('Mouse Position:', { x: event.clientX, y: event.clientY }) - let textInputs = document.querySelectorAll('input[type="text"]') - const emailInput = document.getElementById('email') + if (mousePositions.length > 2) { + const len = mousePositions.length + const { x: x1, y: y1 } = mousePositions[len - 3] + const { x: x2, y: y2 } = mousePositions[len - 2] + const { x: x3, y: y3 } = mousePositions[len - 1] - Array.from(textInputs).forEach(function (input) { - input.addEventListener('invalid', function () { - this.setCustomValidity(messages['required']) + // Calculate the area of the triangle formed by three consecutive points + const area = 0.5 * Math.abs(x1 * y2 + x2 * y3 + x3 * y1 - y1 * x2 - y2 * x3 - y3 * x1) + if (debugEnabled && mouseDebugEnabled) log('Triangle Area:', area) + + if (area > 0.5) { + // Threshold for detecting non-straight line, adjust as needed + isStraightLine = false + if (debugEnabled && mouseDebugEnabled) log('Detected non-straight line movement.') + } + } +} +function validateZSRNumber(form) { + const zsrNumber = form.elements['zsr_nummer'].value + return /^\d+$|^beantragt$/i.test(zsrNumber) +} + +function checkForBotBehavior() { + let timeSpent = (Date.now() - startTime) / 1000 + botDetected = + !userInteracted || + interactionCount === 0 || + timeSpent < interactionThreshold || + (isStraightLine && mousePositions.length > 0) || + honeypotInput1.value !== '' || + honeypotInput2.value !== '' || + verifyEmailInput.value !== '' + if (debugEnabled) + console.log( + 'Bot Detected: ' + + botDetected + + ' userInteracted:' + + userInteracted + + ' interactionCount:' + + interactionCount + + ' timeSpent:' + + timeSpent + + ' isStraightLine:' + + isStraightLine + + ' mousePositions:' + + mousePositions.length + + ' honeypotInput1:' + + honeypotInput1.value + + ' honeypotInput2:' + + honeypotInput2.value + + ' verifyEmailInput:' + + verifyEmailInput.value + ) +} +function handleSubmit(e) { + e.preventDefault() + const currentMessages = getCurrentLangMessages() + + if (zsrCheckEnabled && !validateZSRNumber(form)) { + zsrTooltip.className = 'input-tooltip visible' + zsrTooltip.setAttribute('data-tooltip', currentMessages.zsrTooltip) + zsrTooltip.scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'nearest', }) - }) - - emailInput.addEventListener('invalid', function () { - this.setCustomValidity(messages['email']) - }) - - // initieiere Zeitmessung zur Botprevention - var startTime = Date.now() - - // Messe ob mit der Seite agiert wird - var userInteracted = false - - function setUserInteracted() { - var timeSpent = (Date.now() - startTime) / 1000 // Zeit in Sekunden - if (timeSpent > 5) { - btn.disabled = false - } - userInteracted = true + return } - setTimeout(function () { - if (userInteracted) { - btn.disabled = false - } - }, 5000) - // Eventlistener für Interaktionen - setzt userInteracted auf true bei Interaktion + + checkForBotBehavior() + + const data = new FormData(form) + data.append('tra', botDetected) + fetch(form.action, { + method: 'POST', + mode: 'cors', + body: data, + }) + .then((response) => { + if (!response.ok) throw new Error('Network response was not ok') + return response.json() + }) + .then((data) => { + submitButton.disabled = true + submitButton.innerHTML = ` + + ` + setTimeout(() => { + // if data.success is true, show a success message + if (data.success) { + submitButton.style.display = 'none' + notification.innerHTML = `${currentMessages.thankyou}` + notification.className = 'bg-green-500 text-white px-4 py-2 rounded block' + } else { + submitButton.style.display = 'none' + notification.textContent = data.message + notification.className = 'bg-blue-500 text-white px-4 py-2 rounded block' + } + }, 3000) + }) + .catch((error) => { + submitButton.style.display = 'none' + notification.textContent = 'Fehler beim Senden der Nachricht.' + notification.className = 'bg-blue-500 text-white px-4 py-2 rounded block' + console.error(error) + }) +} + +function init() { + // Event Listeners document.addEventListener('mousedown', setUserInteracted) document.addEventListener('touchstart', setUserInteracted) document.addEventListener('keydown', setUserInteracted) - - bestellformular.addEventListener('submit', function (e) { - e.preventDefault() - - const form = e.target - const notification = document.getElementById('notification') - const zsrTooltip = document.getElementById('zsr-tooltip') - - // Spinner und button disabled anzeigen - - var endTime = Date.now() - var timeSpent = (endTime - startTime) / 1000 // Zeit in Sekunden - - // Setze die Werte für die Botvalidierung zum Auswerten in PHP - document.getElementById('age').value = timeSpent - document.getElementById('hobbies').value = userInteracted ? 'true' : 'false' - - // Validierung der ZSR-Nummer - const zsrNummer = form.elements['zsr_nummer'].value - const isNumberOrBeantragt = /^\d+$|^beantragt$/i.test(zsrNummer) - // TODO REGEX für ZSR-Nummer - if (!isNumberOrBeantragt) { - // Display error message for invalid zsr_nummer - zsrTooltip.className = 'input-tooltip' - // Scroll to the tooltip element - zsrTooltip.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' }) - return - } - - btn.innerHTML = ` - - - - - - ` - - btn.disabled = true - - if (FORMDEBUG) { - console.log('userInteracted: ' + userInteracted) - console.log('timeSpent: ' + timeSpent) - console.log('hobbies: ' + document.getElementById('hobbies').value) - console.log('age: ' + document.getElementById('age').value) - console.log('verify_email(honeypot): ' + document.getElementById('verify_email').value) - } - - // Formulardaten an den Server senden - const data = new FormData(form) - fetch(form.action, { - method: 'POST', - mode: 'cors', - body: data, - }) - .then((response) => { - if (!response.ok) { - throw new Error('Network response was not ok') - } - return response.json() - }) - .then((data) => { - // Erfolgsnachricht anzeigen - // TODO Nachricht anpassen wenn französische Version - notification.textContent = 'Bestellformular erfolgreich gesendet!' - btn.className = 'submitbutton text-white mx-auto submit-after-valid-captchaaaa fadeOut' - setTimeout(() => { - btn.style.visibility = 'hidden' - btn.style.display = 'none' - notification.style.visibility = 'visible' - notification.style.display = 'block' - notification.classList.remove('fadeIn') // Remove fadeIn class - void notification.offsetWidth - notification.className = 'bg-green-500 text-white px-4 py-2 rounded block fadeIn' - }, 1000) - // setTimeout(() => notification.className = 'bg-green-500 text-white px-4 py-2 rounded hidden', 5000); // Benachrichtigung nach 5 Sekunden ausblenden - }) - .catch((error) => { - // Fehlermeldung anzeigen - notification.textContent = 'Fehler beim Senden der Nachricht.' - console.log(error) - notification.className = 'bg-red-500 text-white px-4 py-2 rounded block' - // setTimeout(() => notification.className = 'bg-red-500 text-white px-4 py-2 rounded hidden', 5000); // Benachrichtigung nach 5 Sekunden ausblenden - }) + document.addEventListener('mousemove', handleMouseMove) + form.addEventListener('submit', handleSubmit) + emailInput.addEventListener('invalid', () => { + const currentMessages = getCurrentLangMessages() + emailInput.setCustomValidity(currentMessages.email) }) + textInputs.forEach((input) => { + input.addEventListener('invalid', () => { + const currentMessages = getCurrentLangMessages() + input.setCustomValidity(currentMessages.required) + }) + }) + log('captchaInput', captchaInput) + captchaInput?.forEach((input) => { + input.setAttribute('placeholder', getCurrentLangMessages().captcha) + }) + captchaVerifyButton?.forEach((button) => { + button.textContent = getCurrentLangMessages().captchaButton + }) +} + +document.addEventListener('DOMContentLoaded', function () { + init() })