diff --git a/.cursorrules b/.cursorrules index 34d51d1..da63c04 100644 --- a/.cursorrules +++ b/.cursorrules @@ -59,7 +59,7 @@ PreRegister is a Laravel 11 application for festival ticket pre-registration. Vi ## Security - CSRF on all forms -- Rate limiting on public endpoints (`throttle:10,1`) +- Rate limiting on public endpoints (`config/preregister.php` → `public_requests_per_minute`, applied in `routes/web.php`) - Never expose API keys in frontend, logs, or responses - Validate and restrict file uploads (image types, max size) - UUID slugs prevent URL enumeration diff --git a/.env.example b/.env.example index e7d65c5..86053d2 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,9 @@ APP_KEY= APP_DEBUG=true APP_URL=http://localhost +# Optional: max requests/minute per IP for public /r/{slug} and subscribe (default: 1000 when APP_ENV is local|testing, else 60). +# PUBLIC_REQUESTS_PER_MINUTE=120 + # Wall-clock times from the admin UI (datetime-local) are interpreted in this zone. APP_TIMEZONE=Europe/Amsterdam diff --git a/app/Http/Requests/Admin/UpdateMailwizzConfigRequest.php b/app/Http/Requests/Admin/UpdateMailwizzConfigRequest.php index 61b9727..ca0a0ba 100644 --- a/app/Http/Requests/Admin/UpdateMailwizzConfigRequest.php +++ b/app/Http/Requests/Admin/UpdateMailwizzConfigRequest.php @@ -41,9 +41,7 @@ class UpdateMailwizzConfigRequest extends FormRequest 'field_email' => ['required', 'string', 'max:255'], 'field_first_name' => ['required', 'string', 'max:255'], 'field_last_name' => ['required', 'string', 'max:255'], - 'field_phone' => $page->phone_enabled - ? ['required', 'string', 'max:255'] - : ['nullable', 'string', 'max:255'], + 'field_phone' => ['nullable', 'string', 'max:255'], 'tag_field' => ['required', 'string', 'max:255'], 'tag_value' => ['required', 'string', 'max:255'], ]; diff --git a/app/Http/Requests/SubscribePublicPageRequest.php b/app/Http/Requests/SubscribePublicPageRequest.php index ce80c6d..745db43 100644 --- a/app/Http/Requests/SubscribePublicPageRequest.php +++ b/app/Http/Requests/SubscribePublicPageRequest.php @@ -6,6 +6,7 @@ namespace App\Http\Requests; use App\Models\PreregistrationPage; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rules\Email; class SubscribePublicPageRequest extends FormRequest { @@ -22,13 +23,28 @@ class SubscribePublicPageRequest extends FormRequest /** @var PreregistrationPage $page */ $page = $this->route('publicPage'); + $emailRule = (new Email) + ->rfcCompliant() + ->preventSpoofing(); + return [ 'first_name' => ['required', 'string', 'max:255'], 'last_name' => ['required', 'string', 'max:255'], - 'email' => ['required', 'email', 'max:255'], + 'email' => ['required', 'string', 'max:255', $emailRule], 'phone' => $page->phone_enabled - ? ['required', 'string', 'max:20'] - : ['nullable', 'string', 'max:20'], + ? ['nullable', 'string', 'regex:/^[0-9]{8,15}$/'] + : ['nullable', 'string', 'max:255'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'email' => __('Please enter a valid email address.'), + 'phone.regex' => __('Please enter a valid phone number (8–15 digits).'), ]; } @@ -53,5 +69,18 @@ class SubscribePublicPageRequest extends FormRequest 'email' => strtolower(trim($email)), ]); } + + /** @var PreregistrationPage $page */ + $page = $this->route('publicPage'); + $phone = $this->input('phone'); + if (! $page->phone_enabled) { + $this->merge(['phone' => null]); + + return; + } + if (is_string($phone)) { + $digits = preg_replace('/\D+/', '', $phone); + $this->merge(['phone' => $digits === '' ? null : $digits]); + } } } diff --git a/app/Jobs/SyncSubscriberToMailwizz.php b/app/Jobs/SyncSubscriberToMailwizz.php index fb6127a..d3b34ac 100644 --- a/app/Jobs/SyncSubscriberToMailwizz.php +++ b/app/Jobs/SyncSubscriberToMailwizz.php @@ -63,7 +63,7 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue return; } - if (! $this->configIsComplete($config, $page->phone_enabled)) { + if (! $this->configIsComplete($config)) { Log::warning('SyncSubscriberToMailwizz: incomplete Mailwizz config', [ 'subscriber_id' => $subscriber->id, 'page_id' => $page->id, @@ -106,16 +106,12 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue ]); } - private function configIsComplete(MailwizzConfig $config, bool $phoneEnabled): bool + private function configIsComplete(MailwizzConfig $config): bool { if ($config->list_uid === '' || $config->field_email === '' || $config->field_first_name === '' || $config->field_last_name === '') { return false; } - if ($phoneEnabled && ($config->field_phone === null || $config->field_phone === '')) { - return false; - } - if ($config->tag_field === null || $config->tag_field === '' || $config->tag_value === null || $config->tag_value === '') { return false; } diff --git a/config/preregister.php b/config/preregister.php new file mode 100644 index 0000000..1feb642 --- /dev/null +++ b/config/preregister.php @@ -0,0 +1,23 @@ + (int) env('PUBLIC_REQUESTS_PER_MINUTE', (string) $defaultPerMinute), + +]; diff --git a/lang/nl.json b/lang/nl.json index 593e13a..d3a3a45 100644 --- a/lang/nl.json +++ b/lang/nl.json @@ -3,6 +3,8 @@ "Last name": "Achternaam", "Email": "E-mailadres", "Phone": "Mobiel", + "optional": "optioneel", + "Phone (optional)": "Mobiel (optioneel)", "Register": "Registreren", "days": "dagen", "day": "dag", @@ -14,5 +16,7 @@ "This pre-registration period has ended.": "Deze preregistratieperiode is afgelopen.", "Visit ticket shop": "Ga naar de ticketshop", "Thank you for registering!": "Bedankt voor je registratie!", - "You are already registered for this event.": "Je bent al geregistreerd voor dit evenement." + "You are already registered for this event.": "Je bent al geregistreerd voor dit evenement.", + "Please enter a valid email address.": "Voer een geldig e-mailadres in.", + "Please enter a valid phone number (8–15 digits).": "Voer een geldig telefoonnummer in (8 tot 15 cijfers)." } diff --git a/resources/css/app.css b/resources/css/app.css index 87c5f2d..6f88ebe 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -5,3 +5,12 @@ [x-cloak] { display: none !important; } + +/* Public landing: respect reduced motion for entrance animation */ +@media (prefers-reduced-motion: reduce) { + .animate-preregister-in { + animation: none; + opacity: 1; + transform: none; + } +} diff --git a/resources/js/app.js b/resources/js/app.js index 0bbd2c1..8d6e8f8 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -2,6 +2,36 @@ import './bootstrap'; import Alpine from 'alpinejs'; +/** + * Client-side email check (aligned loosely with RFC-style rules; server is authoritative). + */ +function isValidEmailFormat(value) { + const email = String(value).trim().toLowerCase(); + if (email.length < 5 || email.length > 255) { + return false; + } + if (!email.includes('@')) { + return false; + } + const at = email.lastIndexOf('@'); + const local = email.slice(0, at); + const domain = email.slice(at + 1); + if (!local || !domain || local.includes('..') || domain.includes('..')) { + return false; + } + if (domain.startsWith('.') || domain.endsWith('.')) { + return false; + } + if (!/^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+$/i.test(local)) { + return false; + } + if (!/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/i.test(domain)) { + return false; + } + const tld = domain.split('.').pop(); + return Boolean(tld && tld.length >= 2); +} + document.addEventListener('alpine:init', () => { Alpine.data('publicPreregisterPage', (config) => ({ phase: config.phase, @@ -12,6 +42,8 @@ document.addEventListener('alpine:init', () => { genericError: config.genericError, labelDay: config.labelDay, labelDays: config.labelDays, + invalidEmailMsg: config.invalidEmailMsg, + invalidPhoneMsg: config.invalidPhoneMsg, days: 0, hours: 0, minutes: 0, @@ -56,15 +88,36 @@ document.addEventListener('alpine:init', () => { return String(n).padStart(2, '0'); }, + validateEmailAndPhone() { + this.fieldErrors = {}; + let ok = true; + if (!isValidEmailFormat(this.email)) { + this.fieldErrors.email = [this.invalidEmailMsg]; + ok = false; + } + if (this.phoneEnabled) { + const digits = String(this.phone).replace(/\D/g, ''); + if (digits.length > 0 && (digits.length < 8 || digits.length > 15)) { + this.fieldErrors.phone = [this.invalidPhoneMsg]; + ok = false; + } + } + return ok; + }, + async submitForm() { + this.formError = ''; + this.fieldErrors = {}; + if (!this.validateEmailAndPhone()) { + return; + } + const form = this.$refs.form; if (form !== undefined && form !== null && !form.checkValidity()) { form.reportValidity(); return; } - this.formError = ''; - this.fieldErrors = {}; this.submitting = true; try { const body = { @@ -262,10 +315,6 @@ document.addEventListener('alpine:init', () => { this.errorMessage = cfg.strings.mapFieldsError; return; } - if (this.phoneEnabled && !this.fieldPhone) { - this.errorMessage = cfg.strings.mapPhoneError; - return; - } if (!this.tagField) { this.errorMessage = cfg.strings.tagFieldError; return; diff --git a/resources/views/admin/mailwizz/edit.blade.php b/resources/views/admin/mailwizz/edit.blade.php index 5670f54..6a55207 100644 --- a/resources/views/admin/mailwizz/edit.blade.php +++ b/resources/views/admin/mailwizz/edit.blade.php @@ -34,7 +34,6 @@ 'noListsError' => __('No mailing lists were returned. Check your API key or create a list in Mailwizz.'), 'selectListError' => __('Select a mailing list.'), 'mapFieldsError' => __('Map email, first name, and last name to Mailwizz fields.'), - 'mapPhoneError' => __('Map the phone field for this page.'), 'tagFieldError' => __('Select a checkbox list field for source / tag tracking.'), 'tagValueError' => __('Select the tag option that identifies this pre-registration.'), ], @@ -183,7 +182,7 @@
- +