Add honeypot protection to contact forms
This commit is contained in:
@@ -64,7 +64,6 @@ class MarketingController extends Controller
|
|||||||
'name' => 'required|string|max:255',
|
'name' => 'required|string|max:255',
|
||||||
'email' => 'required|email|max:255',
|
'email' => 'required|email|max:255',
|
||||||
'message' => 'required|string|max:1000',
|
'message' => 'required|string|max:1000',
|
||||||
'nickname' => 'present|size:0',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$locale = app()->getLocale();
|
$locale = app()->getLocale();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Support\LocaleConfig;
|
|||||||
use Illuminate\Foundation\Inspiring;
|
use Illuminate\Foundation\Inspiring;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Middleware;
|
use Inertia\Middleware;
|
||||||
|
use Spatie\Honeypot\Honeypot;
|
||||||
|
|
||||||
class HandleInertiaRequests extends Middleware
|
class HandleInertiaRequests extends Middleware
|
||||||
{
|
{
|
||||||
@@ -67,6 +68,7 @@ class HandleInertiaRequests extends Middleware
|
|||||||
'error' => fn () => $request->session()->get('error'),
|
'error' => fn () => $request->session()->get('error'),
|
||||||
'verification' => fn () => $request->session()->get('verification'),
|
'verification' => fn () => $request->session()->get('verification'),
|
||||||
],
|
],
|
||||||
|
'honeypot' => fn () => new Honeypot(config('honeypot')),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"minishlink/web-push": "*",
|
"minishlink/web-push": "*",
|
||||||
"sentry/sentry-laravel": "*",
|
"sentry/sentry-laravel": "*",
|
||||||
"simplesoftwareio/simple-qrcode": "^4.2",
|
"simplesoftwareio/simple-qrcode": "^4.2",
|
||||||
|
"spatie/laravel-honeypot": "*",
|
||||||
"spatie/laravel-translatable": "^6.11",
|
"spatie/laravel-translatable": "^6.11",
|
||||||
"staudenmeir/belongs-to-through": "^2.17",
|
"staudenmeir/belongs-to-through": "^2.17",
|
||||||
"stripe/stripe-php": "*",
|
"stripe/stripe-php": "*",
|
||||||
|
|||||||
78
composer.lock
generated
78
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "5e1d60e650853d6113b01e1adaf49d65",
|
"content-hash": "a4956012b0e374c8f74b61a892e6b984",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "anourvalar/eloquent-serialize",
|
"name": "anourvalar/eloquent-serialize",
|
||||||
@@ -6804,6 +6804,82 @@
|
|||||||
],
|
],
|
||||||
"time": "2024-05-17T09:06:10+00:00"
|
"time": "2024-05-17T09:06:10+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "spatie/laravel-honeypot",
|
||||||
|
"version": "4.6.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/spatie/laravel-honeypot.git",
|
||||||
|
"reference": "62ec9dbecd2a17a4e2af62b09675f89813295cac"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/spatie/laravel-honeypot/zipball/62ec9dbecd2a17a4e2af62b09675f89813295cac",
|
||||||
|
"reference": "62ec9dbecd2a17a4e2af62b09675f89813295cac",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"illuminate/contracts": "^11.0|^12.0",
|
||||||
|
"illuminate/encryption": "^11.0|^12.0",
|
||||||
|
"illuminate/http": "^11.0|^12.0",
|
||||||
|
"illuminate/support": "^11.0|^12.0",
|
||||||
|
"illuminate/validation": "^11.0|^12.0",
|
||||||
|
"nesbot/carbon": "^2.0|^3.0",
|
||||||
|
"php": "^8.2",
|
||||||
|
"spatie/laravel-package-tools": "^1.9",
|
||||||
|
"symfony/http-foundation": "^7.0|^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"livewire/livewire": "^3.0",
|
||||||
|
"orchestra/testbench": "^9.0|^10.0",
|
||||||
|
"pestphp/pest": "^2.0|^3.0|^4.0",
|
||||||
|
"pestphp/pest-plugin-livewire": "^1.0|^2.1|^3.0|^4.0",
|
||||||
|
"spatie/pest-plugin-snapshots": "^1.1|^2.1",
|
||||||
|
"spatie/phpunit-snapshot-assertions": "^4.2|^5.1",
|
||||||
|
"spatie/test-time": "^1.2.1"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"providers": [
|
||||||
|
"Spatie\\Honeypot\\HoneypotServiceProvider"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Spatie\\Honeypot\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Freek Van der Herten",
|
||||||
|
"email": "freek@spatie.be",
|
||||||
|
"homepage": "https://spatie.be",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Preventing spam submitted through forms",
|
||||||
|
"homepage": "https://github.com/spatie/laravel-honeypot",
|
||||||
|
"keywords": [
|
||||||
|
"laravel-honeypot",
|
||||||
|
"spatie"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/spatie/laravel-honeypot/tree/4.6.2"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://spatie.be/open-source/support-us",
|
||||||
|
"type": "custom"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2025-11-28T09:57:48+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "spatie/laravel-package-tools",
|
"name": "spatie/laravel-package-tools",
|
||||||
"version": "1.92.7",
|
"version": "1.92.7",
|
||||||
|
|||||||
@@ -25,6 +25,20 @@ interface Props {
|
|||||||
packages: Package[];
|
packages: Package[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface HoneypotPayload {
|
||||||
|
enabled: boolean;
|
||||||
|
nameFieldName: string;
|
||||||
|
validFromFieldName: string;
|
||||||
|
encryptedValidFrom: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContactFormData = {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
message: string;
|
||||||
|
[key: string]: string;
|
||||||
|
};
|
||||||
|
|
||||||
const heroBulletIcons = [Sparkles, ShieldCheck, Camera];
|
const heroBulletIcons = [Sparkles, ShieldCheck, Camera];
|
||||||
const howStepIcons = [QrCode, Smartphone, ShieldCheck];
|
const howStepIcons = [QrCode, Smartphone, ShieldCheck];
|
||||||
|
|
||||||
@@ -36,13 +50,24 @@ const Home: React.FC<Props> = ({ packages }) => {
|
|||||||
variant: heroCtaVariant,
|
variant: heroCtaVariant,
|
||||||
trackClick: trackHeroCtaClick,
|
trackClick: trackHeroCtaClick,
|
||||||
} = useCtaExperiment('home_hero_cta');
|
} = useCtaExperiment('home_hero_cta');
|
||||||
const { flash } = usePage<{ flash?: { success?: string } }>().props;
|
const { flash, honeypot } = usePage<{ flash?: { success?: string }; honeypot?: HoneypotPayload }>().props;
|
||||||
const shouldReduceMotion = useReducedMotion();
|
const shouldReduceMotion = useReducedMotion();
|
||||||
const { data, setData, post, processing, errors, reset } = useForm({
|
const honeypotDefaults = React.useMemo(() => {
|
||||||
|
if (!honeypot?.enabled) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
[honeypot.nameFieldName]: '',
|
||||||
|
[honeypot.validFromFieldName]: honeypot.encryptedValidFrom,
|
||||||
|
};
|
||||||
|
}, [honeypot?.enabled, honeypot?.encryptedValidFrom, honeypot?.nameFieldName, honeypot?.validFromFieldName]);
|
||||||
|
|
||||||
|
const { data, setData, post, processing, errors, reset } = useForm<ContactFormData>({
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
message: '',
|
message: '',
|
||||||
nickname: '',
|
...honeypotDefaults,
|
||||||
});
|
});
|
||||||
|
|
||||||
const viewportOnce = { once: true, amount: 0.25 };
|
const viewportOnce = { once: true, amount: 0.25 };
|
||||||
@@ -619,91 +644,101 @@ const Home: React.FC<Props> = ({ packages }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
<input
|
{honeypot?.enabled ? (
|
||||||
type="text"
|
<div className="hidden" aria-hidden>
|
||||||
name="nickname"
|
<input
|
||||||
value={data.nickname}
|
type="text"
|
||||||
onChange={(event) => setData('nickname', event.target.value)}
|
name={honeypot.nameFieldName}
|
||||||
className="hidden"
|
id={honeypot.nameFieldName}
|
||||||
tabIndex={-1}
|
value={data[honeypot.nameFieldName] ?? ''}
|
||||||
autoComplete="off"
|
onChange={(event) => setData(honeypot.nameFieldName, event.target.value)}
|
||||||
aria-hidden
|
autoComplete="off"
|
||||||
/>
|
tabIndex={-1}
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
/>
|
||||||
<div className="flex flex-col gap-2">
|
<input
|
||||||
<label htmlFor="name" className="text-sm font-semibold text-gray-600 dark:text-gray-200">
|
type="text"
|
||||||
{t('home.name_label')} *
|
name={honeypot.validFromFieldName}
|
||||||
</label>
|
value={data[honeypot.validFromFieldName] ?? honeypot.encryptedValidFrom}
|
||||||
<Input
|
readOnly
|
||||||
id="name"
|
tabIndex={-1}
|
||||||
value={data.name}
|
/>
|
||||||
onChange={(event) => setData('name', event.target.value)}
|
</div>
|
||||||
className="h-12 rounded-xl border-gray-200/70 bg-white/90 shadow-inner shadow-gray-200/40 focus-visible:ring-rose-300/60 dark:border-gray-700 dark:bg-gray-900/70"
|
) : null}
|
||||||
autoComplete="name"
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
required
|
<div className="flex flex-col gap-2">
|
||||||
aria-invalid={Boolean(errors.name)}
|
<label htmlFor="name" className="text-sm font-semibold text-gray-600 dark:text-gray-200">
|
||||||
aria-describedby={errors.name ? 'contact-name-error' : undefined}
|
{t('home.name_label')} *
|
||||||
/>
|
</label>
|
||||||
{errors.name && (
|
<Input
|
||||||
<p id="contact-name-error" className="text-sm font-medium text-rose-600 dark:text-rose-300">{errors.name}</p>
|
id="name"
|
||||||
)}
|
value={data.name}
|
||||||
</div>
|
onChange={(event) => setData('name', event.target.value)}
|
||||||
<div className="flex flex-col gap-2">
|
className="h-12 rounded-xl border-gray-200/70 bg-white/90 shadow-inner shadow-gray-200/40 focus-visible:ring-rose-300/60 dark:border-gray-700 dark:bg-gray-900/70"
|
||||||
<label htmlFor="email" className="text-sm font-semibold text-gray-600 dark:text-gray-200">
|
autoComplete="name"
|
||||||
{t('home.email_label')} *
|
required
|
||||||
</label>
|
aria-invalid={Boolean(errors.name)}
|
||||||
<Input
|
aria-describedby={errors.name ? 'contact-name-error' : undefined}
|
||||||
id="email"
|
/>
|
||||||
type="email"
|
{errors.name && (
|
||||||
value={data.email}
|
<p id="contact-name-error" className="text-sm font-medium text-rose-600 dark:text-rose-300">{errors.name}</p>
|
||||||
onChange={(event) => setData('email', event.target.value)}
|
)}
|
||||||
className="h-12 rounded-xl border-gray-200/70 bg-white/90 shadow-inner shadow-gray-200/40 focus-visible:ring-rose-300/60 dark:border-gray-700 dark:bg-gray-900/70"
|
</div>
|
||||||
autoComplete="email"
|
<div className="flex flex-col gap-2">
|
||||||
required
|
<label htmlFor="email" className="text-sm font-semibold text-gray-600 dark:text-gray-200">
|
||||||
aria-invalid={Boolean(errors.email)}
|
{t('home.email_label')} *
|
||||||
aria-describedby={errors.email ? 'contact-email-error' : undefined}
|
</label>
|
||||||
/>
|
<Input
|
||||||
{errors.email && (
|
id="email"
|
||||||
<p id="contact-email-error" className="text-sm font-medium text-rose-600 dark:text-rose-300">{errors.email}</p>
|
type="email"
|
||||||
)}
|
value={data.email}
|
||||||
</div>
|
onChange={(event) => setData('email', event.target.value)}
|
||||||
</div>
|
className="h-12 rounded-xl border-gray-200/70 bg-white/90 shadow-inner shadow-gray-200/40 focus-visible:ring-rose-300/60 dark:border-gray-700 dark:bg-gray-900/70"
|
||||||
<div className="flex flex-col gap-2">
|
autoComplete="email"
|
||||||
<label htmlFor="message" className="text-sm font-semibold text-gray-600 dark:text-gray-200">
|
required
|
||||||
{t('home.message_label')} *
|
aria-invalid={Boolean(errors.email)}
|
||||||
</label>
|
aria-describedby={errors.email ? 'contact-email-error' : undefined}
|
||||||
<Textarea
|
/>
|
||||||
id="message"
|
{errors.email && (
|
||||||
rows={5}
|
<p id="contact-email-error" className="text-sm font-medium text-rose-600 dark:text-rose-300">{errors.email}</p>
|
||||||
value={data.message}
|
)}
|
||||||
onChange={(event) => setData('message', event.target.value)}
|
</div>
|
||||||
className="rounded-xl border-gray-200/70 bg-white/90 shadow-inner shadow-gray-200/40 focus-visible:ring-rose-300/60 dark:border-gray-700 dark:bg-gray-900/70"
|
</div>
|
||||||
required
|
<div className="flex flex-col gap-2">
|
||||||
aria-invalid={Boolean(errors.message)}
|
<label htmlFor="message" className="text-sm font-semibold text-gray-600 dark:text-gray-200">
|
||||||
aria-describedby={errors.message ? 'contact-message-error' : undefined}
|
{t('home.message_label')} *
|
||||||
/>
|
</label>
|
||||||
{errors.message && (
|
<Textarea
|
||||||
<p id="contact-message-error" className="text-sm font-medium text-rose-600 dark:text-rose-300">{errors.message}</p>
|
id="message"
|
||||||
)}
|
rows={5}
|
||||||
</div>
|
value={data.message}
|
||||||
<div className="space-y-3 text-sm text-muted-foreground">
|
onChange={(event) => setData('message', event.target.value)}
|
||||||
<p>{t('home.contact_privacy')}</p>
|
className="rounded-xl border-gray-200/70 bg-white/90 shadow-inner shadow-gray-200/40 focus-visible:ring-rose-300/60 dark:border-gray-700 dark:bg-gray-900/70"
|
||||||
<Button
|
required
|
||||||
type="submit"
|
aria-invalid={Boolean(errors.message)}
|
||||||
disabled={processing}
|
aria-describedby={errors.message ? 'contact-message-error' : undefined}
|
||||||
className="h-12 w-full rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] text-base font-semibold text-white shadow-[0_18px_35px_-18px_rgba(236,72,153,0.7)] transition hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5] disabled:cursor-not-allowed disabled:opacity-60"
|
/>
|
||||||
>
|
{errors.message && (
|
||||||
{processing ? (
|
<p id="contact-message-error" className="text-sm font-medium text-rose-600 dark:text-rose-300">{errors.message}</p>
|
||||||
<span className="flex items-center justify-center gap-2">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
{t('home.sending')}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
t('home.send')
|
|
||||||
)}
|
)}
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
<div className="space-y-3 text-sm text-muted-foreground">
|
||||||
</form>
|
<p>{t('home.contact_privacy')}</p>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={processing}
|
||||||
|
className="h-12 w-full rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] text-base font-semibold text-white shadow-[0_18px_35px_-18px_rgba(236,72,153,0.7)] transition hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5] disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{processing ? (
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
{t('home.sending')}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
t('home.send')
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -6,15 +6,40 @@ import MarketingLayout from '@/layouts/mainWebsite';
|
|||||||
import { Loader2, CheckCircle2 } from 'lucide-react';
|
import { Loader2, CheckCircle2 } from 'lucide-react';
|
||||||
import { motion, useReducedMotion } from 'framer-motion';
|
import { motion, useReducedMotion } from 'framer-motion';
|
||||||
|
|
||||||
|
interface HoneypotPayload {
|
||||||
|
enabled: boolean;
|
||||||
|
nameFieldName: string;
|
||||||
|
validFromFieldName: string;
|
||||||
|
encryptedValidFrom: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContactFormData = {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
message: string;
|
||||||
|
[key: string]: string;
|
||||||
|
};
|
||||||
|
|
||||||
const Kontakt: React.FC = () => {
|
const Kontakt: React.FC = () => {
|
||||||
const { data, setData, post, processing, errors, reset } = useForm({
|
const { honeypot, flash } = usePage<{ flash?: { success?: string }; honeypot?: HoneypotPayload }>().props;
|
||||||
|
const honeypotDefaults = React.useMemo(() => {
|
||||||
|
if (!honeypot?.enabled) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
[honeypot.nameFieldName]: '',
|
||||||
|
[honeypot.validFromFieldName]: honeypot.encryptedValidFrom,
|
||||||
|
};
|
||||||
|
}, [honeypot?.enabled, honeypot?.encryptedValidFrom, honeypot?.nameFieldName, honeypot?.validFromFieldName]);
|
||||||
|
|
||||||
|
const { data, setData, post, processing, errors, reset } = useForm<ContactFormData>({
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
message: '',
|
message: '',
|
||||||
nickname: '',
|
...honeypotDefaults,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { flash } = usePage<{ flash?: { success?: string } }>().props;
|
|
||||||
const { t } = useTranslation('marketing');
|
const { t } = useTranslation('marketing');
|
||||||
const { localizedPath } = useLocalizedRoutes();
|
const { localizedPath } = useLocalizedRoutes();
|
||||||
const shouldReduceMotion = useReducedMotion();
|
const shouldReduceMotion = useReducedMotion();
|
||||||
@@ -51,16 +76,26 @@ const Kontakt: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<form key={`kontakt-form-${Object.keys(errors).length}`} onSubmit={handleSubmit} className="space-y-4">
|
<form key={`kontakt-form-${Object.keys(errors).length}`} onSubmit={handleSubmit} className="space-y-4">
|
||||||
<input
|
{honeypot?.enabled ? (
|
||||||
type="text"
|
<div className="hidden" aria-hidden>
|
||||||
name="nickname"
|
<input
|
||||||
value={data.nickname}
|
type="text"
|
||||||
onChange={(e) => setData('nickname', e.target.value)}
|
name={honeypot.nameFieldName}
|
||||||
className="hidden"
|
id={honeypot.nameFieldName}
|
||||||
tabIndex={-1}
|
value={data[honeypot.nameFieldName] ?? ''}
|
||||||
autoComplete="off"
|
onChange={(event) => setData(honeypot.nameFieldName, event.target.value)}
|
||||||
aria-hidden
|
autoComplete="off"
|
||||||
/>
|
tabIndex={-1}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name={honeypot.validFromFieldName}
|
||||||
|
value={data[honeypot.validFromFieldName] ?? honeypot.encryptedValidFrom}
|
||||||
|
readOnly
|
||||||
|
tabIndex={-1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 font-sans-marketing">{t('kontakt.name')}</label>
|
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 font-sans-marketing">{t('kontakt.name')}</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ vi.mock('@inertiajs/react', () => ({
|
|||||||
Head: () => null,
|
Head: () => null,
|
||||||
Link: ({ children, href }: { children: React.ReactNode; href: string }) => <a href={href}>{children}</a>,
|
Link: ({ children, href }: { children: React.ReactNode; href: string }) => <a href={href}>{children}</a>,
|
||||||
useForm: () => ({
|
useForm: () => ({
|
||||||
data: { name: '', email: '', message: '', nickname: '' },
|
data: { name: '', email: '', message: '' },
|
||||||
setData: vi.fn(),
|
setData: vi.fn(),
|
||||||
post: vi.fn(),
|
post: vi.fn(),
|
||||||
processing: false,
|
processing: false,
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ vi.mock('@inertiajs/react', () => ({
|
|||||||
Head: () => null,
|
Head: () => null,
|
||||||
Link: ({ children, href }: { children: React.ReactNode; href: string }) => <a href={href}>{children}</a>,
|
Link: ({ children, href }: { children: React.ReactNode; href: string }) => <a href={href}>{children}</a>,
|
||||||
useForm: () => ({
|
useForm: () => ({
|
||||||
data: { name: '', email: '', message: '', nickname: '' },
|
data: { name: '', email: '', message: '' },
|
||||||
setData: vi.fn(),
|
setData: vi.fn(),
|
||||||
post: vi.fn(),
|
post: vi.fn(),
|
||||||
processing: false,
|
processing: false,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ use App\Support\LocaleConfig;
|
|||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
|
use Spatie\Honeypot\ProtectAgainstSpam;
|
||||||
|
|
||||||
require __DIR__.'/auth.php';
|
require __DIR__.'/auth.php';
|
||||||
require __DIR__.'/settings.php';
|
require __DIR__.'/settings.php';
|
||||||
@@ -88,13 +89,13 @@ Route::prefix('{locale}')
|
|||||||
Route::get('/contact', [MarketingController::class, 'contactView'])
|
Route::get('/contact', [MarketingController::class, 'contactView'])
|
||||||
->name('marketing.contact');
|
->name('marketing.contact');
|
||||||
Route::post('/contact', [MarketingController::class, 'contact'])
|
Route::post('/contact', [MarketingController::class, 'contact'])
|
||||||
->middleware('throttle:contact-form')
|
->middleware(['throttle:contact-form', ProtectAgainstSpam::class])
|
||||||
->name('marketing.contact.submit');
|
->name('marketing.contact.submit');
|
||||||
|
|
||||||
Route::get('/kontakt', [MarketingController::class, 'contactView'])
|
Route::get('/kontakt', [MarketingController::class, 'contactView'])
|
||||||
->name('kontakt');
|
->name('kontakt');
|
||||||
Route::post('/kontakt', [MarketingController::class, 'contact'])
|
Route::post('/kontakt', [MarketingController::class, 'contact'])
|
||||||
->middleware('throttle:contact-form')
|
->middleware(['throttle:contact-form', ProtectAgainstSpam::class])
|
||||||
->name('kontakt.submit');
|
->name('kontakt.submit');
|
||||||
|
|
||||||
Route::get('/blog', [MarketingController::class, 'blogIndex'])->name('blog');
|
Route::get('/blog', [MarketingController::class, 'blogIndex'])->name('blog');
|
||||||
|
|||||||
57
tests/Feature/Marketing/ContactFormTest.php
Normal file
57
tests/Feature/Marketing/ContactFormTest.php
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Marketing;
|
||||||
|
|
||||||
|
use App\Mail\ContactConfirmation;
|
||||||
|
use App\Mail\ContactRequest;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Spatie\Honeypot\EncryptedTime;
|
||||||
|
use Spatie\Honeypot\Honeypot;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ContactFormTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_contact_form_accepts_valid_submission(): void
|
||||||
|
{
|
||||||
|
config(['mail.contact_address' => 'contact@example.com']);
|
||||||
|
Mail::fake();
|
||||||
|
|
||||||
|
$honeypot = new Honeypot(config('honeypot'));
|
||||||
|
|
||||||
|
$response = $this->from('/de/kontakt')->post('/de/kontakt', [
|
||||||
|
'name' => 'Test User',
|
||||||
|
'email' => 'user@example.com',
|
||||||
|
'message' => 'Hello there!',
|
||||||
|
$honeypot->nameFieldName() => '',
|
||||||
|
$honeypot->validFromFieldName() => (string) EncryptedTime::create(now()->subSeconds(5)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertRedirect('/de/kontakt');
|
||||||
|
$response->assertSessionHas('success');
|
||||||
|
|
||||||
|
Mail::assertSent(ContactRequest::class);
|
||||||
|
Mail::assertQueued(ContactConfirmation::class, function (ContactConfirmation $mail) {
|
||||||
|
return $mail->hasTo('user@example.com');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_contact_form_blocks_spam_when_honeypot_filled(): void
|
||||||
|
{
|
||||||
|
Mail::fake();
|
||||||
|
|
||||||
|
$honeypot = new Honeypot(config('honeypot'));
|
||||||
|
|
||||||
|
$response = $this->from('/de/kontakt')->post('/de/kontakt', [
|
||||||
|
'name' => 'Spam Bot',
|
||||||
|
'email' => 'spam@example.com',
|
||||||
|
'message' => 'Spam message',
|
||||||
|
$honeypot->nameFieldName() => 'filled',
|
||||||
|
$honeypot->validFromFieldName() => (string) EncryptedTime::create(now()->subSeconds(5)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertContent('');
|
||||||
|
|
||||||
|
Mail::assertNothingSent();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user