10-04-2026
This commit is contained in:
parent
4d6b4930b2
commit
4bb89aad8c
836 changed files with 52961 additions and 5950 deletions
303
packages/acme/contact-form/README.md
Normal file
303
packages/acme/contact-form/README.md
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
# Acme Contact Form
|
||||
|
||||
Ein wiederverwendbares, spam-geschütztes Kontaktformular-Package für Laravel mit Livewire. Bietet Honeypot, zeitbasierte Prüfung, Inhaltsanalyse, Rate Limiting und mehr – ohne externe Dienste, DSGVO-konform.
|
||||
|
||||
## Features
|
||||
|
||||
- **Spam-Schutz**: Honeypot, Zeitprüfung, Inhaltsanalyse, Wegwerf-E-Mails, Rate Limiting, IP-Blacklist, Bot-User-Agent-Erkennung
|
||||
- **Konfigurierbare Felder**: Presets (simple, full) oder eigene Felddefinitionen
|
||||
- **Livewire**: Reaktives Formular ohne Page-Reload
|
||||
- **Flexibel**: Einfache Formulare bis komplexe Multi-Feld-Formulare
|
||||
- **DSGVO-konform**: Keine externen Services, alles in-house
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- PHP 8.2+
|
||||
- Laravel 10+ / 11+ / 12+
|
||||
- Livewire 3+
|
||||
|
||||
## Installation
|
||||
|
||||
### 1. Package installieren
|
||||
|
||||
```bash
|
||||
composer require acme/contact-form
|
||||
```
|
||||
|
||||
Für lokale Entwicklung (Path-Repository):
|
||||
|
||||
```json
|
||||
{
|
||||
"repositories": [
|
||||
{
|
||||
"type": "path",
|
||||
"url": "package/acme/contact-form"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"acme/contact-form": "@dev"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
composer update acme/contact-form
|
||||
```
|
||||
|
||||
### 2. Konfiguration
|
||||
|
||||
```bash
|
||||
php artisan vendor:publish --tag=contact-form-config
|
||||
```
|
||||
|
||||
In `.env`:
|
||||
|
||||
```env
|
||||
CONTACT_FORM_RECIPIENT=kontakt@ihre-domain.de
|
||||
CONTACT_FORM_MAX_ATTEMPTS=3
|
||||
CONTACT_FORM_DECAY_MINUTES=15
|
||||
CONTACT_FORM_MIN_FILL_TIME=3
|
||||
```
|
||||
|
||||
### 3. Übersetzungen (optional)
|
||||
|
||||
```bash
|
||||
php artisan vendor:publish --tag=contact-form-lang
|
||||
```
|
||||
|
||||
## Verwendung
|
||||
|
||||
### Einfaches Formular (Preset: simple)
|
||||
|
||||
Name, E-Mail, Nachricht, Datenschutz-Checkbox:
|
||||
|
||||
```blade
|
||||
<livewire:contact-form />
|
||||
```
|
||||
|
||||
Oder mit explizitem Preset:
|
||||
|
||||
```blade
|
||||
<livewire:contact-form preset="simple" subject="Kontaktanfrage" />
|
||||
```
|
||||
|
||||
### Vollständiges Formular (Preset: full)
|
||||
|
||||
Vorname, Nachname, E-Mail, Telefon, Unternehmen, Nachricht, Datenschutz:
|
||||
|
||||
```blade
|
||||
<livewire:contact-form preset="full" subject="Neue Kontaktanfrage" />
|
||||
```
|
||||
|
||||
### Eigenes Formular mit benutzerdefinierten Feldern
|
||||
|
||||
Sie können beliebige Felder definieren. Jedes Feld benötigt:
|
||||
|
||||
- `type`: `text`, `email`, `tel`, `textarea`, `select`, `checkbox`, `honeypot`
|
||||
- `rules`: Laravel-Validierungsregeln (Array)
|
||||
- `label`: Anzeigename (optional)
|
||||
- `placeholder`: Platzhalter (optional)
|
||||
- `required`: true/false (optional, wird aus rules abgeleitet)
|
||||
- `name`: Formularfeld-Name (optional, Standard: Array-Key)
|
||||
- `options`: Für `select` – Array [value => label] (optional)
|
||||
|
||||
**Beispiel: Minimales Formular (nur E-Mail + Nachricht)**
|
||||
|
||||
```blade
|
||||
<livewire:contact-form
|
||||
:fields="[
|
||||
'email' => [
|
||||
'type' => 'email',
|
||||
'rules' => ['required', 'email:rfc,dns', 'max:150'],
|
||||
'label' => 'Ihre E-Mail',
|
||||
'placeholder' => 'name@beispiel.de',
|
||||
],
|
||||
'message' => [
|
||||
'type' => 'textarea',
|
||||
'rules' => ['required', 'string', 'max:2000'],
|
||||
'label' => 'Nachricht',
|
||||
],
|
||||
'privacy' => [
|
||||
'type' => 'checkbox',
|
||||
'rules' => ['accepted'],
|
||||
'label' => 'Ich stimme der Datenschutzerklärung zu.',
|
||||
],
|
||||
'honeypot' => [
|
||||
'type' => 'honeypot',
|
||||
'name' => 'website',
|
||||
'rules' => ['nullable', 'string', 'max:0'],
|
||||
],
|
||||
]"
|
||||
subject="Feedback"
|
||||
/>
|
||||
```
|
||||
|
||||
**Beispiel: Formular mit Select-Feld**
|
||||
|
||||
```blade
|
||||
<livewire:contact-form
|
||||
:fields="[
|
||||
'name' => [
|
||||
'type' => 'text',
|
||||
'rules' => ['required', 'string', 'max:120'],
|
||||
'label' => 'Name',
|
||||
],
|
||||
'email' => [
|
||||
'type' => 'email',
|
||||
'rules' => ['required', 'email:rfc,dns'],
|
||||
'label' => 'E-Mail',
|
||||
],
|
||||
'topic' => [
|
||||
'type' => 'select',
|
||||
'rules' => ['required', 'string', 'in:general,support,sales'],
|
||||
'label' => 'Betreff',
|
||||
'placeholder' => 'Bitte wählen...',
|
||||
'options' => [
|
||||
'general' => 'Allgemeine Anfrage',
|
||||
'support' => 'Technischer Support',
|
||||
'sales' => 'Verkauf',
|
||||
],
|
||||
],
|
||||
'message' => [
|
||||
'type' => 'textarea',
|
||||
'rules' => ['required', 'string', 'max:2000'],
|
||||
'label' => 'Nachricht',
|
||||
],
|
||||
'privacy' => [
|
||||
'type' => 'checkbox',
|
||||
'rules' => ['accepted'],
|
||||
'label' => 'Datenschutz akzeptiert',
|
||||
],
|
||||
'honeypot' => [
|
||||
'type' => 'honeypot',
|
||||
'name' => 'company_check',
|
||||
'rules' => ['nullable', 'string', 'max:0'],
|
||||
],
|
||||
]"
|
||||
subject="Anfrage über Kontaktformular"
|
||||
/>
|
||||
```
|
||||
|
||||
**Wichtig**: Jedes Formular sollte ein Honeypot-Feld enthalten. Das Feld wird für Nutzer unsichtbar dargestellt; Bots füllen es oft aus und werden so erkannt.
|
||||
|
||||
### Eigene Presets in der Config
|
||||
|
||||
In `config/contact-form.php` können Sie eigene Presets definieren:
|
||||
|
||||
```php
|
||||
'presets' => [
|
||||
'mein-formular' => [
|
||||
'name' => [
|
||||
'type' => 'text',
|
||||
'rules' => ['required', 'string', 'max:120'],
|
||||
'label' => 'Name',
|
||||
],
|
||||
'email' => [
|
||||
'type' => 'email',
|
||||
'rules' => ['required', 'email:rfc,dns'],
|
||||
'label' => 'E-Mail',
|
||||
],
|
||||
'message' => [
|
||||
'type' => 'textarea',
|
||||
'rules' => ['required', 'string', 'max:2000'],
|
||||
'label' => 'Nachricht',
|
||||
],
|
||||
'privacy' => [
|
||||
'type' => 'checkbox',
|
||||
'rules' => ['accepted'],
|
||||
'label' => 'Datenschutz',
|
||||
],
|
||||
'honeypot' => [
|
||||
'type' => 'honeypot',
|
||||
'name' => 'website',
|
||||
'rules' => ['nullable', 'string', 'max:0'],
|
||||
],
|
||||
],
|
||||
],
|
||||
```
|
||||
|
||||
Verwendung:
|
||||
|
||||
```blade
|
||||
<livewire:contact-form preset="mein-formular" />
|
||||
```
|
||||
|
||||
## Spam-Schutz im Detail
|
||||
|
||||
| Maßnahme | Beschreibung |
|
||||
|----------|--------------|
|
||||
| **Honeypot** | Verstecktes Feld – wenn ausgefüllt → Spam |
|
||||
| **Zeitprüfung** | Formular < 3 Sek. ausgefüllt → Spam |
|
||||
| **Inhaltsanalyse** | Spam-Keywords, URLs, XSS-Muster → Spam |
|
||||
| **Wegwerf-E-Mails** | tempmail.com, mailinator.com etc. → Spam |
|
||||
| **Rate Limiting** | Max. 3 Anfragen/IP in 15 Min. (konfigurierbar) |
|
||||
| **IP-Blacklist** | Blockierte IPs in Config |
|
||||
| **Bot-User-Agent** | curl, wget, python-requests etc. → Spam |
|
||||
|
||||
Bei Spam wird **immer** eine Erfolgsmeldung angezeigt (Täuschung von Bots), aber **keine E-Mail** versendet. Alle Anfragen werden geloggt.
|
||||
|
||||
## Service und SpamDetector direkt nutzen
|
||||
|
||||
Falls Sie einen eigenen Controller oder eine eigene Livewire-Komponente verwenden möchten:
|
||||
|
||||
```php
|
||||
use Acme\ContactForm\ContactFormService;
|
||||
use Acme\ContactForm\SpamDetector;
|
||||
|
||||
// Spam prüfen
|
||||
$spamDetector = SpamDetector::fromConfig();
|
||||
$isSpam = $spamDetector->detect($validatedData, $formLoadedAt);
|
||||
|
||||
// Anfrage verarbeiten
|
||||
$service = app(ContactFormService::class);
|
||||
$service->handle([
|
||||
'name' => $request->input('name'),
|
||||
'email' => $request->input('email'),
|
||||
'message' => $request->input('message'),
|
||||
'ip' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
'is_spam' => $isSpam,
|
||||
], 'Betreff der E-Mail', 'Log-Kontext');
|
||||
```
|
||||
|
||||
## Konfiguration
|
||||
|
||||
| Option | Beschreibung | Standard |
|
||||
|--------|--------------|----------|
|
||||
| `recipient` | E-Mail-Empfänger | aus .env |
|
||||
| `rate_limit.max_attempts` | Max. Anfragen pro IP | 3 |
|
||||
| `rate_limit.decay_minutes` | Zeitfenster in Minuten | 15 |
|
||||
| `blacklisted_ips` | IP-Adressen blockieren | [] |
|
||||
| `honeypot_fields` | Namen der Honeypot-Felder | company_check, website, url |
|
||||
| `spam.min_fill_time_seconds` | Min. Zeit zum Ausfüllen | 3 |
|
||||
| `spam.suspicious_patterns` | Regex für Spam-Erkennung | siehe Config |
|
||||
| `spam.disposable_email_domains` | Wegwerf-Domains | siehe Config |
|
||||
|
||||
## Styling anpassen
|
||||
|
||||
Die Standard-Views nutzen generische Klassen (`border-gray-300`, `focus:ring-primary`). Passen Sie sie an Ihr Design an:
|
||||
|
||||
```bash
|
||||
php artisan vendor:publish --tag=contact-form-views
|
||||
```
|
||||
|
||||
Die Views liegen dann unter `resources/views/vendor/contact-form/`.
|
||||
|
||||
## Event nach Absenden
|
||||
|
||||
Nach erfolgreichem Absenden wird das Event `contact-form-submitted` dispatched. Sie können darauf reagieren:
|
||||
|
||||
```blade
|
||||
<div x-data="{ submitted: false }"
|
||||
x-on:contact-form-submitted.window="submitted = true">
|
||||
<livewire:contact-form />
|
||||
|
||||
<template x-if="submitted">
|
||||
<p>Vielen Dank für Ihre Nachricht!</p>
|
||||
</template>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Lizenz
|
||||
|
||||
MIT License
|
||||
38
packages/acme/contact-form/composer.json
Normal file
38
packages/acme/contact-form/composer.json
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"name": "acme/contact-form",
|
||||
"description": "Ein wiederverwendbares, spam-geschütztes Kontaktformular-Package für Laravel mit Livewire",
|
||||
"type": "library",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"laravel",
|
||||
"livewire",
|
||||
"contact-form",
|
||||
"spam-protection",
|
||||
"honeypot"
|
||||
],
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Acme\\ContactForm\\": "src/"
|
||||
}
|
||||
},
|
||||
"authors": [
|
||||
{
|
||||
"name": "Acme",
|
||||
"email": "info@acme.de"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"illuminate/support": "^10.0|^11.0|^12.0",
|
||||
"livewire/livewire": "^3.0|^4.0"
|
||||
},
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Acme\\ContactForm\\ContactFormServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true
|
||||
}
|
||||
126
packages/acme/contact-form/config/contact-form.php
Normal file
126
packages/acme/contact-form/config/contact-form.php
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Email Recipient
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The email address that contact form submissions will be sent to.
|
||||
|
|
||||
*/
|
||||
'recipient' => env('CONTACT_FORM_RECIPIENT', 'contact@example.com'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Rate Limiting
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Maximum number of submissions per IP within the given time window.
|
||||
|
|
||||
*/
|
||||
'rate_limit' => [
|
||||
'max_attempts' => env('CONTACT_FORM_MAX_ATTEMPTS', 3),
|
||||
'decay_minutes' => env('CONTACT_FORM_DECAY_MINUTES', 15),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| IP Blacklist
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| IP addresses that will always be blocked as spam.
|
||||
|
|
||||
*/
|
||||
'blacklisted_ips' => [],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Spam Detection
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configuration for the built-in spam detection.
|
||||
|
|
||||
*/
|
||||
'honeypot_fields' => ['company_check', 'website', 'url', 'website_url'],
|
||||
|
||||
'spam' => [
|
||||
'min_fill_time_seconds' => env('CONTACT_FORM_MIN_FILL_TIME', 3),
|
||||
'suspicious_patterns' => [
|
||||
'/\b(viagra|cialis|casino|poker|lottery|bitcoin|crypto)\b/i',
|
||||
'/\b[A-Z]{10,}\b/',
|
||||
'/<script|<iframe|javascript:/i',
|
||||
],
|
||||
'max_links_in_message' => 2,
|
||||
'max_repeated_chars' => 10,
|
||||
'disposable_email_domains' => [
|
||||
'tempmail.com',
|
||||
'guerrillamail.com',
|
||||
'10minutemail.com',
|
||||
'mailinator.com',
|
||||
'throwaway.email',
|
||||
'yopmail.com',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Form Presets
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Predefined field configurations for different form types.
|
||||
| See README for customization and defining your own forms.
|
||||
|
|
||||
*/
|
||||
'presets' => [
|
||||
'simple' => [
|
||||
'name' => ['type' => 'text', 'rules' => ['required', 'string', 'max:120'], 'label' => 'Name', 'required' => true],
|
||||
'email' => ['type' => 'email', 'rules' => ['required', 'email:rfc,dns', 'max:150'], 'label' => 'Email', 'required' => true],
|
||||
'message' => ['type' => 'textarea', 'rules' => ['required', 'string', 'max:2000'], 'label' => 'Message', 'required' => true],
|
||||
'privacy' => ['type' => 'checkbox', 'rules' => ['accepted'], 'label' => 'Privacy Policy', 'required' => true],
|
||||
'honeypot' => ['type' => 'honeypot', 'name' => 'website', 'rules' => ['nullable', 'string', 'max:0']],
|
||||
],
|
||||
'full' => [
|
||||
'first_name' => ['type' => 'text', 'rules' => ['required', 'string', 'max:100'], 'label' => 'First Name', 'required' => true],
|
||||
'last_name' => ['type' => 'text', 'rules' => ['required', 'string', 'max:100'], 'label' => 'Last Name', 'required' => true],
|
||||
'email' => ['type' => 'email', 'rules' => ['required', 'email:rfc,dns', 'max:150'], 'label' => 'Email', 'required' => true],
|
||||
'phone' => ['type' => 'tel', 'rules' => ['nullable', 'string', 'max:80'], 'label' => 'Phone', 'required' => false],
|
||||
'company' => ['type' => 'text', 'rules' => ['required', 'string', 'max:150'], 'label' => 'Company', 'required' => true],
|
||||
'message' => ['type' => 'textarea', 'rules' => ['required', 'string', 'max:2000'], 'label' => 'Message', 'required' => true],
|
||||
'privacy' => ['type' => 'checkbox', 'rules' => ['accepted'], 'label' => 'Privacy Policy', 'required' => true],
|
||||
'honeypot' => ['type' => 'honeypot', 'name' => 'company_check', 'rules' => ['nullable', 'string', 'max:0']],
|
||||
],
|
||||
'business' => [
|
||||
'first_name' => ['type' => 'text', 'rules' => ['required', 'string', 'max:100'], 'label' => 'First Name', 'required' => true],
|
||||
'last_name' => ['type' => 'text', 'rules' => ['required', 'string', 'max:100'], 'label' => 'Last Name', 'required' => true],
|
||||
'email' => ['type' => 'email', 'rules' => ['required', 'email:rfc,dns', 'max:150'], 'label' => 'Email', 'required' => true],
|
||||
'phone' => ['type' => 'tel', 'rules' => ['nullable', 'string', 'max:80'], 'label' => 'Phone', 'required' => false],
|
||||
'company' => ['type' => 'text', 'rules' => ['required', 'string', 'max:150'], 'label' => 'Company', 'required' => true],
|
||||
'project_type' => [
|
||||
'type' => 'select',
|
||||
'rules' => ['nullable', 'string', 'max:150'],
|
||||
'label' => 'Project Type',
|
||||
'options' => [
|
||||
'consulting' => 'Consulting',
|
||||
'implementation' => 'Implementation',
|
||||
'support' => 'Support',
|
||||
],
|
||||
],
|
||||
'message' => ['type' => 'textarea', 'rules' => ['required', 'string', 'max:2000'], 'label' => 'Message', 'required' => true],
|
||||
'timeline' => [
|
||||
'type' => 'select',
|
||||
'rules' => ['nullable', 'string', 'max:150'],
|
||||
'label' => 'Timeline',
|
||||
'options' => [
|
||||
'asap' => 'As soon as possible',
|
||||
'3months' => 'Within 3 months',
|
||||
'6months' => 'Within 6 months',
|
||||
],
|
||||
],
|
||||
'privacy' => ['type' => 'checkbox', 'rules' => ['accepted'], 'label' => 'Privacy Policy', 'required' => true],
|
||||
'honeypot' => ['type' => 'honeypot', 'name' => 'company_check', 'rules' => ['nullable', 'string', 'max:0']],
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
9
packages/acme/contact-form/lang/de/contact-form.php
Normal file
9
packages/acme/contact-form/lang/de/contact-form.php
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'default_subject' => 'Neue Kontaktanfrage',
|
||||
'success_message' => 'Vielen Dank! Ihre Nachricht wurde erfolgreich gesendet.',
|
||||
'submit' => 'Absenden',
|
||||
'sending' => 'Wird gesendet...',
|
||||
'select_placeholder' => 'Bitte wählen...',
|
||||
];
|
||||
9
packages/acme/contact-form/lang/en/contact-form.php
Normal file
9
packages/acme/contact-form/lang/en/contact-form.php
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'default_subject' => 'New Contact Request',
|
||||
'success_message' => 'Thank you! Your message has been sent successfully.',
|
||||
'submit' => 'Submit',
|
||||
'sending' => 'Sending...',
|
||||
'select_placeholder' => 'Please select...',
|
||||
];
|
||||
92
packages/acme/contact-form/resources/views/form.blade.php
Normal file
92
packages/acme/contact-form/resources/views/form.blade.php
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
<div>
|
||||
<form wire:submit="submit" class="space-y-6">
|
||||
@foreach ($fields as $key => $field)
|
||||
@php
|
||||
$name = $field['name'] ?? $key;
|
||||
$label = $field['label'] ?? ucfirst(str_replace('_', ' ', $name));
|
||||
$required = $field['required'] ?? (in_array('required', $field['rules'] ?? []));
|
||||
$placeholder = $field['placeholder'] ?? '';
|
||||
@endphp
|
||||
|
||||
@if ($field['type'] === 'honeypot')
|
||||
<div class="opacity-0 absolute top-0 left-0 h-0 w-0 -z-10 overflow-hidden" aria-hidden="true">
|
||||
<label for="{{ $name }}">{{ $label }}</label>
|
||||
<input type="text" id="{{ $name }}" name="{{ $name }}"
|
||||
wire:model="formData.{{ $name }}"
|
||||
tabindex="-1" autocomplete="off">
|
||||
</div>
|
||||
@elseif ($field['type'] === 'textarea')
|
||||
<div>
|
||||
<label for="{{ $name }}" class="block text-sm font-medium mb-2">
|
||||
{{ $label }}@if ($required) <span aria-hidden="true">*</span>@endif
|
||||
</label>
|
||||
<textarea id="{{ $name }}" name="{{ $name }}" rows="5"
|
||||
wire:model="formData.{{ $name }}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="{{ $placeholder }}"
|
||||
@if ($required) required @endif></textarea>
|
||||
@error("formData.{$name}")
|
||||
<p class="text-sm text-red-600 mt-1">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
@elseif ($field['type'] === 'checkbox')
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" id="{{ $name }}" name="{{ $name }}"
|
||||
wire:model="formData.{{ $name }}"
|
||||
class="rounded border-gray-300"
|
||||
@if ($required) required @endif>
|
||||
<label for="{{ $name }}" class="text-sm">{{ $label }}</label>
|
||||
</div>
|
||||
@error("formData.{$name}")
|
||||
<p class="text-sm text-red-600 mt-1">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
@elseif ($field['type'] === 'select')
|
||||
<div>
|
||||
<label for="{{ $name }}" class="block text-sm font-medium mb-2">
|
||||
{{ $label }}@if ($required) <span aria-hidden="true">*</span>@endif
|
||||
</label>
|
||||
<select id="{{ $name }}" name="{{ $name }}"
|
||||
wire:model="formData.{{ $name }}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
@if ($required) required @endif>
|
||||
<option value="">{{ $placeholder ?: __('contact-form::contact-form.select_placeholder') }}</option>
|
||||
@foreach ($field['options'] ?? [] as $value => $optionLabel)
|
||||
<option value="{{ $value }}">{{ $optionLabel }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error("formData.{$name}")
|
||||
<p class="text-sm text-red-600 mt-1">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
@else
|
||||
<div>
|
||||
<label for="{{ $name }}" class="block text-sm font-medium mb-2">
|
||||
{{ $label }}@if ($required) <span aria-hidden="true">*</span>@endif
|
||||
</label>
|
||||
<input type="{{ $field['type'] ?? 'text' }}" id="{{ $name }}" name="{{ $name }}"
|
||||
wire:model="formData.{{ $name }}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="{{ $placeholder }}"
|
||||
@if ($required) required @endif>
|
||||
@error("formData.{$name}")
|
||||
<p class="text-sm text-red-600 mt-1">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
@if ($success)
|
||||
<div class="p-4 rounded-lg border border-green-200 bg-green-50 text-green-800 text-sm">
|
||||
{{ __('contact-form::contact-form.success_message') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<button type="submit" wire:loading.attr="disabled"
|
||||
class="w-full inline-flex items-center justify-center px-6 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors disabled:opacity-70 disabled:cursor-not-allowed">
|
||||
<span wire:loading.remove>{{ __('contact-form::contact-form.submit') }}</span>
|
||||
<span wire:loading>{{ __('contact-form::contact-form.sending') }}</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
135
packages/acme/contact-form/src/ContactFormService.php
Normal file
135
packages/acme/contact-form/src/ContactFormService.php
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
<?php
|
||||
|
||||
namespace Acme\ContactForm;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class ContactFormService
|
||||
{
|
||||
public function __construct(
|
||||
protected array $config = []
|
||||
) {
|
||||
$this->config = array_merge(
|
||||
config('contact-form', []),
|
||||
$config
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a contact form submission.
|
||||
*/
|
||||
public function handle(array $payload, string $subject = '', string $logContext = 'contact-form'): void
|
||||
{
|
||||
$isSpam = $payload['is_spam'] ?? false;
|
||||
|
||||
if (! $isSpam) {
|
||||
$isSpam = $this->performServerSideSpamCheck($payload);
|
||||
$payload['is_spam'] = $isSpam;
|
||||
}
|
||||
|
||||
if ($isSpam) {
|
||||
Log::info("SPAM detected - {$logContext}", $payload);
|
||||
Log::info("Email NOT sent (spam detected - {$logContext})", [
|
||||
'ip' => $payload['ip'] ?? 'unknown',
|
||||
'email' => $payload['email'] ?? 'unknown',
|
||||
]);
|
||||
} else {
|
||||
Log::info("{$logContext} received", $payload);
|
||||
$this->notify($subject ?: __('contact-form::contact-form.default_subject'), $payload);
|
||||
}
|
||||
}
|
||||
|
||||
protected function notify(string $subject, array $payload): void
|
||||
{
|
||||
$recipient = $this->config['recipient'] ?? null;
|
||||
|
||||
if (empty($recipient)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$body = $this->formatMailBody($subject, $payload);
|
||||
|
||||
try {
|
||||
Mail::raw($body, function ($message) use ($recipient, $subject): void {
|
||||
$message->to($recipient)->subject($subject);
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Contact form email could not be sent', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function formatMailBody(string $subject, array $payload): string
|
||||
{
|
||||
$lines = [$subject, str_repeat('-', 40)];
|
||||
|
||||
foreach ($payload as $key => $value) {
|
||||
if ($key === 'is_spam') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = is_array($value) ? json_encode($value, JSON_UNESCAPED_UNICODE) : (string) $value;
|
||||
$label = ucfirst(str_replace('_', ' ', $key));
|
||||
$lines[] = "{$label}: {$value}";
|
||||
}
|
||||
|
||||
return implode(PHP_EOL, $lines);
|
||||
}
|
||||
|
||||
protected function performServerSideSpamCheck(array $payload): bool
|
||||
{
|
||||
$ip = $payload['ip'] ?? 'unknown';
|
||||
$config = $this->config;
|
||||
|
||||
$maxAttempts = $config['rate_limit']['max_attempts'] ?? 3;
|
||||
$decayMinutes = $config['rate_limit']['decay_minutes'] ?? 15;
|
||||
|
||||
$cacheKey = 'contact_form_'.md5($ip);
|
||||
$attempts = cache()->get($cacheKey, 0);
|
||||
|
||||
if ($attempts >= $maxAttempts) {
|
||||
Log::warning('Contact form rate limit exceeded', [
|
||||
'ip' => $ip,
|
||||
'attempts' => $attempts,
|
||||
'max_attempts' => $maxAttempts,
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
cache()->put($cacheKey, $attempts + 1, now()->addMinutes($decayMinutes));
|
||||
|
||||
$blacklistedIPs = $config['blacklisted_ips'] ?? [];
|
||||
if (in_array($ip, $blacklistedIPs)) {
|
||||
Log::warning('Blacklisted IP detected', ['ip' => $ip]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$userAgent = $payload['user_agent'] ?? '';
|
||||
$botPatterns = [
|
||||
'/bot/i',
|
||||
'/crawler/i',
|
||||
'/spider/i',
|
||||
'/scraper/i',
|
||||
'/curl/i',
|
||||
'/wget/i',
|
||||
'/python-requests/i',
|
||||
];
|
||||
|
||||
foreach ($botPatterns as $pattern) {
|
||||
if (preg_match($pattern, $userAgent)) {
|
||||
Log::warning('Bot user-agent detected', [
|
||||
'ip' => $ip,
|
||||
'user_agent' => $userAgent,
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
namespace Acme\ContactForm;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Livewire\Livewire;
|
||||
|
||||
class ContactFormServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->mergeConfigFrom(
|
||||
__DIR__.'/../config/contact-form.php',
|
||||
'contact-form'
|
||||
);
|
||||
|
||||
$this->app->singleton(ContactFormService::class, function (): ContactFormService {
|
||||
return new ContactFormService;
|
||||
});
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
$this->loadViewsFrom(__DIR__.'/../resources/views', 'contact-form');
|
||||
|
||||
$this->loadTranslationsFrom(__DIR__.'/../lang', 'contact-form');
|
||||
|
||||
Livewire::component('contact-form', \Acme\ContactForm\Livewire\ContactForm::class);
|
||||
|
||||
if ($this->app->runningInConsole()) {
|
||||
$this->publishes([
|
||||
__DIR__.'/../config/contact-form.php' => config_path('contact-form.php'),
|
||||
], 'contact-form-config');
|
||||
|
||||
$this->publishes([
|
||||
__DIR__.'/../resources/views' => resource_path('views/vendor/contact-form'),
|
||||
], 'contact-form-views');
|
||||
|
||||
$this->publishes([
|
||||
__DIR__.'/../lang' => lang_path('vendor/contact-form'),
|
||||
], 'contact-form-lang');
|
||||
}
|
||||
}
|
||||
}
|
||||
109
packages/acme/contact-form/src/Livewire/ContactForm.php
Normal file
109
packages/acme/contact-form/src/Livewire/ContactForm.php
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
<?php
|
||||
|
||||
namespace Acme\ContactForm\Livewire;
|
||||
|
||||
use Acme\ContactForm\ContactFormService;
|
||||
use Acme\ContactForm\SpamDetector;
|
||||
use Illuminate\View\View;
|
||||
use Livewire\Component;
|
||||
|
||||
class ContactForm extends Component
|
||||
{
|
||||
/** @var array<string, mixed> */
|
||||
public array $formData = [];
|
||||
|
||||
public ?int $formLoadedAt = null;
|
||||
|
||||
public bool $success = false;
|
||||
|
||||
public string $preset = 'simple';
|
||||
|
||||
public string $subject = '';
|
||||
|
||||
public string $logContext = 'contact-form';
|
||||
|
||||
/**
|
||||
* @var array<string, array{type: string, rules: array, label?: string, placeholder?: string, required?: bool, name?: string, options?: array}>
|
||||
*/
|
||||
public array $customFields = [];
|
||||
|
||||
public function mount(
|
||||
string $preset = 'simple',
|
||||
string $subject = '',
|
||||
array $fields = []
|
||||
): void {
|
||||
$this->preset = $preset;
|
||||
$this->subject = $subject;
|
||||
$this->customFields = $fields;
|
||||
$this->formLoadedAt = time();
|
||||
$this->initializeFormData();
|
||||
}
|
||||
|
||||
protected function getFields(): array
|
||||
{
|
||||
if (! empty($this->customFields)) {
|
||||
return $this->customFields;
|
||||
}
|
||||
|
||||
$presets = config('contact-form.presets', []);
|
||||
|
||||
return $presets[$this->preset] ?? $presets['simple'];
|
||||
}
|
||||
|
||||
protected function initializeFormData(): void
|
||||
{
|
||||
foreach ($this->getFields() as $key => $field) {
|
||||
$name = $field['name'] ?? $key;
|
||||
$this->formData[$name] = match ($field['type']) {
|
||||
'checkbox' => false,
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
protected function getRules(): array
|
||||
{
|
||||
$rules = [];
|
||||
|
||||
foreach ($this->getFields() as $key => $field) {
|
||||
$name = $field['name'] ?? $key;
|
||||
$rules["formData.{$name}"] = $field['rules'] ?? ['nullable'];
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
public function submit(ContactFormService $service): void
|
||||
{
|
||||
$validated = $this->validate($this->getRules());
|
||||
|
||||
$flattened = $validated['formData'] ?? [];
|
||||
$spamDetector = SpamDetector::fromConfig();
|
||||
$isSpam = $spamDetector->detect($flattened, $this->formLoadedAt);
|
||||
|
||||
$payload = array_merge($flattened, [
|
||||
'ip' => request()->ip(),
|
||||
'user_agent' => request()->userAgent(),
|
||||
'is_spam' => $isSpam,
|
||||
]);
|
||||
|
||||
$service->handle($payload, $this->subject, $this->logContext);
|
||||
|
||||
$this->success = true;
|
||||
$this->dispatch('contact-form-submitted');
|
||||
$this->resetForm();
|
||||
}
|
||||
|
||||
protected function resetForm(): void
|
||||
{
|
||||
$this->initializeFormData();
|
||||
$this->formLoadedAt = time();
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('contact-form::form', [
|
||||
'fields' => $this->getFields(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
165
packages/acme/contact-form/src/SpamDetector.php
Normal file
165
packages/acme/contact-form/src/SpamDetector.php
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
<?php
|
||||
|
||||
namespace Acme\ContactForm;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class SpamDetector
|
||||
{
|
||||
public function __construct(
|
||||
protected array $config
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Checks the given data for spam indicators.
|
||||
*/
|
||||
public function detect(array $validated, ?int $formLoadedAt = null, ?Request $request = null): bool
|
||||
{
|
||||
$request = $request ?? request();
|
||||
|
||||
if ($this->checkHoneypot($validated, $request)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->checkFillTime($formLoadedAt, $request)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->checkContent($validated, $request)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->checkDisposableEmail($validated, $request)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks honeypot fields — configured fields must be empty.
|
||||
*/
|
||||
protected function checkHoneypot(array $validated, Request $request): bool
|
||||
{
|
||||
$honeypotFields = $this->config['honeypot_fields'] ?? ['company_check', 'website', 'url', 'website_url'];
|
||||
|
||||
foreach ($honeypotFields as $field) {
|
||||
if (isset($validated[$field]) && ! empty($validated[$field])) {
|
||||
$this->logSpam('Honeypot field filled', ['field' => $field], $request);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($validated as $key => $value) {
|
||||
if (str_contains((string) $key, '_check') && ! empty($value)) {
|
||||
$this->logSpam('Honeypot field filled', ['field' => $key], $request);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Time-based check: form submitted too quickly.
|
||||
*/
|
||||
protected function checkFillTime(?int $formLoadedAt, Request $request): bool
|
||||
{
|
||||
if ($formLoadedAt === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$minSeconds = $this->config['spam']['min_fill_time_seconds'] ?? 3;
|
||||
$timeSpent = time() - $formLoadedAt;
|
||||
|
||||
if ($timeSpent < $minSeconds) {
|
||||
$this->logSpam('Form submitted too quickly', ['time_spent' => $timeSpent], $request);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content analysis: suspicious patterns, excessive links, repeated characters, XSS.
|
||||
*/
|
||||
protected function checkContent(array $validated, Request $request): bool
|
||||
{
|
||||
$patterns = $this->config['spam']['suspicious_patterns'] ?? [];
|
||||
$maxLinks = $this->config['spam']['max_links_in_message'] ?? 2;
|
||||
$maxRepeated = $this->config['spam']['max_repeated_chars'] ?? 10;
|
||||
|
||||
foreach ($validated as $key => $value) {
|
||||
if (! is_string($value) || str_contains($key, 'honeypot')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($patterns as $pattern) {
|
||||
if (preg_match($pattern, $value)) {
|
||||
$this->logSpam('Suspicious content detected', ['pattern' => $pattern, 'field' => $key], $request);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
$linkCount = substr_count(strtolower($value), 'http://') +
|
||||
substr_count(strtolower($value), 'https://') +
|
||||
substr_count(strtolower($value), 'www.');
|
||||
|
||||
if ($linkCount > $maxLinks) {
|
||||
$this->logSpam('Too many links', ['link_count' => $linkCount], $request);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (preg_match('/(.)\1{'.($maxRepeated + 1).',}/', $value)) {
|
||||
$this->logSpam('Too many repeated characters', [], $request);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for disposable/throwaway email domains.
|
||||
*/
|
||||
protected function checkDisposableEmail(array $validated, Request $request): bool
|
||||
{
|
||||
$email = $validated['email'] ?? $validated['e-mail'] ?? null;
|
||||
if (empty($email) || ! is_string($email)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$domains = $this->config['spam']['disposable_email_domains'] ?? [];
|
||||
$emailDomain = substr((string) strrchr($email, '@'), 1);
|
||||
|
||||
if (in_array(strtolower($emailDomain), $domains)) {
|
||||
$this->logSpam('Disposable email address', ['domain' => $emailDomain], $request);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function logSpam(string $reason, array $context, Request $request): void
|
||||
{
|
||||
Log::warning('Spam detected (contact-form): '.$reason, array_merge([
|
||||
'ip' => $request->ip(),
|
||||
], $context));
|
||||
}
|
||||
|
||||
/**
|
||||
* Static factory for convenient instantiation.
|
||||
*/
|
||||
public static function fromConfig(?array $config = null): self
|
||||
{
|
||||
return new self($config ?? config('contact-form', []));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue