diff --git a/.env.example b/.env.example index ac24c7ac..bce80b54 100644 --- a/.env.example +++ b/.env.example @@ -111,3 +111,8 @@ STRIPE_KEY= STRIPE_WEBHOOK_SECRET= # test | live — muss zum Key-Paar passen STRIPE_MODE=test + +# Cloudflare Turnstile (Captcha Kontaktformular, kostenlos): https://dash.cloudflare.com → Turnstile +# Lokal/Test: Cloudflare-Testschlüssel, die immer bestehen +TURNSTILE_SITE_KEY=1x00000000000000000000AA +TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA diff --git a/app/Http/Controllers/Auth/RegisterController.php b/app/Http/Controllers/Auth/RegisterController.php index 7e6c80e9..074ee497 100644 --- a/app/Http/Controllers/Auth/RegisterController.php +++ b/app/Http/Controllers/Auth/RegisterController.php @@ -8,6 +8,7 @@ use Illuminate\Foundation\Auth\RegistersUsers; use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Validator; +use Illuminate\Validation\Rules\Password; class RegisterController extends Controller { @@ -58,7 +59,7 @@ class RegisterController extends Controller return Validator::make($data, [ 'name' => 'required|string|max:255', 'email' => 'required|string|email:filter|max:255|unique:users', - 'password' => 'required|string|min:6|confirmed', + 'password' => ['required', 'string', 'confirmed', Password::defaults()], ]); } diff --git a/app/Http/Controllers/UserUpdatePasswordController.php b/app/Http/Controllers/UserUpdatePasswordController.php index 5c37a74f..83cb4bb3 100644 --- a/app/Http/Controllers/UserUpdatePasswordController.php +++ b/app/Http/Controllers/UserUpdatePasswordController.php @@ -1,39 +1,38 @@ middleware('auth'); } /** - * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View + * @return Factory|View */ public function updatePassword() { return view('user.update_password'); } - - public function updatePasswordStore() { - $rules = array( - 'old_password' => 'required|old_password:' . Auth::user()->password, - 'password' => 'required|string|min:8|confirmed', - ); + $rules = [ + 'old_password' => 'required|old_password:'.Auth::user()->password, + 'password' => ['required', 'string', 'confirmed', Password::defaults()], + ]; Validator::extend('old_password', function ($attribute, $value, $parameters, $validator) { @@ -47,6 +46,7 @@ class UserUpdatePasswordController extends Controller // get the error messages from the validator $messages = $validator->messages(); + // redirect our user back to the form with the errors from the validator return view('user.update_password')->withErrors($validator); @@ -54,33 +54,32 @@ class UserUpdatePasswordController extends Controller $user = Auth::user(); $data = Request::all(); $user->fill([ - 'password' => Hash::make($data['password']) + 'password' => Hash::make($data['password']), ])->save(); - \Session()->flash('alert-save', '1'); + return redirect(route('user_update_password')); } - /** - * @return \Illuminate\Contracts\View\Factory|\Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector|\Illuminate\View\View + * @return Factory|RedirectResponse|Redirector|View */ - public function updatePasswordFirst(){ - if(!Auth::user()->isPasswort()){ + public function updatePasswordFirst() + { + if (! Auth::user()->isPasswort()) { return view('user.update_password_first'); } + return redirect(route('user_update_password')); } - public function updatePasswordFirstStore() { - $rules = array( - 'password' => 'required|string|min:6|confirmed', - ); - + $rules = [ + 'password' => ['required', 'string', 'confirmed', Password::defaults()], + ]; $validator = Validator::make(Request::all(), $rules); @@ -88,6 +87,7 @@ class UserUpdatePasswordController extends Controller // get the error messages from the validator $messages = $validator->messages(); + // redirect our user back to the form with the errors from the validator return view('user.update_password_first')->withErrors($validator); @@ -95,12 +95,11 @@ class UserUpdatePasswordController extends Controller $user = Auth::user(); $data = Request::all(); $user->fill([ - 'password' => Hash::make($data['password']) + 'password' => Hash::make($data['password']), ])->save(); - - \Session()->flash('alert-save', '1'); + return redirect('/home'); } -} \ No newline at end of file +} diff --git a/app/Http/Controllers/Web/ContactController.php b/app/Http/Controllers/Web/ContactController.php index a2d56eb4..d49e211e 100644 --- a/app/Http/Controllers/Web/ContactController.php +++ b/app/Http/Controllers/Web/ContactController.php @@ -2,126 +2,65 @@ namespace App\Http\Controllers\Web; - -use Request; -use Validator; +use App\Http\Controllers\Controller; +use App\Http\Requests\Contact\ContactStoreRequest; +use App\Mail\MailContact; +use App\Services\FormSpamGuard; use App\Services\Shop; use App\Services\Util; -use GuzzleHttp\Client; -use App\Mail\MailContact; -use App\Http\Controllers\Controller; +use Illuminate\Contracts\View\View; use Illuminate\Support\Facades\Mail; - class ContactController extends Controller { - private $GOOGLE_ReCAPTCHA_KEY = "6LeeZosUAAAAAG907fMMqO4BFgsiR4ANDodd8FlU"; - private $GOOGLE_ReCAPTCHA_SECRET = "6LeeZosUAAAAADIy2fyR4RG3EuM-Zdz7Pa2Qmb1J"; + public function __construct(private FormSpamGuard $spamGuard) {} - /** - * Create a new controller instance. - * - * @return void - */ - public function __construct() - { - } - - - - public function create() + public function create(): View { $data = [ - 'GOOGLE_ReCAPTCHA_KEY' => $this->GOOGLE_ReCAPTCHA_KEY, + 'turnstile_site_key' => config('services.turnstile.site_key'), + 'form_token' => old(FormSpamGuard::TIMESTAMP_FIELD, $this->spamGuard->makeFormToken()), 'user_shop' => Util::getUserShop(), 'mylangs' => Shop::getLangChange('webshop'), 'yard_instance' => 'webshop', - ]; + return view('web.templates.kontakt', $data); } - public function store() + /** + * Die Anfrage geht nur an mivita und ggf. den Berater des Shops, nie an die eingegebene Adresse. + */ + public function store(ContactStoreRequest $request): View { + $blockReason = $this->spamGuard->blockReason($request); - $user_shop = Util::getUserShop(); - - - $rules = array( - 'salutation' => 'required', - 'first_name' => 'required', - 'last_name' => 'required', - 'email' => 'required|email', - 'message' => 'required', - 'g-recaptcha-response' => 'required|recaptcha', - 'accepted_data_protection' => 'required', - ); - if (!$user_shop || $user_shop->id === 22) { - $rules['sales_partnership'] = 'required'; - if (Request::get('sales_partnership') === 'JA') { - $rules['sales_partnership_message'] = 'required'; - } + if ($blockReason !== null) { + FormSpamGuard::log($request, 'contact', $blockReason); + } else { + $this->sendMail($request); } - - Validator::extend('recaptcha', function ($attribute, $value, $parameters, $validator) { - return $this->reCaptcha_validate($attribute, $value, $parameters, $validator); - }); - - $validator = Validator::make(Request::all(), $rules); - if ($validator->fails()) { - return back()->withErrors($validator)->withInput(Request::all()); - } - - - $contact = []; - $contact['salutation'] = Request::get('salutation'); - $contact['first_name'] = Request::get('first_name'); - $contact['last_name'] = Request::get('last_name'); - $contact['email'] = Request::get('email'); - $contact['phone'] = Request::get('phone'); - $contact['message'] = Request::get('message'); - if (!$user_shop){ - $contact['sales_partnership'] = Request::get('sales_partnership'); - $contact['sales_partnership_message'] = Request::get('sales_partnership_message'); - } - - - $contact_mail = config('app.contact_mail'); - if($user_shop){ - Mail::to($contact['email'])->bcc([$user_shop->user->email, $contact_mail])->locale(\App::getLocale())->send(new MailContact($contact)); - }else{ - Mail::to($contact['email'])->bcc($contact_mail)->locale(\App::getLocale())->send(new MailContact($contact)); - } - - $data = [ - 'user_shop' => Util::getUserShop(), - 'mylangs' => Shop::getLangChange('webshop'), - 'yard_instance' => 'webshop', - ]; - return view('web.templates.contact-final', $data); - + return view('web.templates.contact-final', [ + 'user_shop' => Util::getUserShop(), + 'mylangs' => Shop::getLangChange('webshop'), + 'yard_instance' => 'webshop', + ]); } - - private function reCaptcha_validate($attribute, $value, $parameters, $validator) + private function sendMail(ContactStoreRequest $request): void { + $contact = $request->safe()->only(['salutation', 'first_name', 'last_name', 'email', 'phone', 'message']); + if ($request->asksForSalesPartnership()) { + $contact['sales_partnership'] = $request->input('sales_partnership'); + $contact['sales_partnership_message'] = $request->input('sales_partnership_message'); + } - $client = new Client(); + $mail = Mail::to(config('app.contact_mail')); + if ($consultantEmail = Util::getUserShop()?->user?->email) { + $mail->bcc($consultantEmail); + } - $response = $client->post( - 'https://www.google.com/recaptcha/api/siteverify', - ['form_params' => - [ - 'secret' => $this->GOOGLE_ReCAPTCHA_SECRET, - 'response' => $value - ] - ] - ); - - $body = json_decode((string)$response->getBody()); - return $body->success; + $mail->locale(app()->getLocale())->send(new MailContact($contact)); } - - -} \ No newline at end of file +} diff --git a/app/Http/Controllers/Web/RegisterController.php b/app/Http/Controllers/Web/RegisterController.php index 48477f81..70e4a125 100644 --- a/app/Http/Controllers/Web/RegisterController.php +++ b/app/Http/Controllers/Web/RegisterController.php @@ -3,23 +3,19 @@ namespace App\Http\Controllers\Web; use App\Http\Controllers\Controller; +use App\Http\Requests\Register\RegisterUserRequest; use App\Mail\MailVerifyAccount; use App\Models\UserLevel; use App\Repositories\UserRepository; +use App\Services\FormSpamGuard; use App\Services\UserService; use App\Services\Util; use App\User; -use GuzzleHttp\Client; +use Illuminate\Http\RedirectResponse; use Illuminate\Support\Facades\Mail; -use Request; -use Validator; class RegisterController extends Controller { - private $GOOGLE_ReCAPTCHA_KEY = '6LeeZosUAAAAAG907fMMqO4BFgsiR4ANDodd8FlU'; - - private $GOOGLE_ReCAPTCHA_SECRET = '6LeeZosUAAAAADIy2fyR4RG3EuM-Zdz7Pa2Qmb1J'; - protected $userRepo; /** @@ -27,7 +23,7 @@ class RegisterController extends Controller * * @return void */ - public function __construct(UserRepository $userRepo) + public function __construct(UserRepository $userRepo, private FormSpamGuard $spamGuard) { $this->userRepo = $userRepo; } @@ -35,7 +31,7 @@ class RegisterController extends Controller public function index() { if (config('app.debug')) { - \Log::channel('doamin')->debug('RegisterController: index - Session user_shop', [ + \Log::channel('domain')->debug('RegisterController: index - Session user_shop', [ 'session_user_shop_id' => \Session::get('user_shop')?->id, 'session_user_shop_name' => \Session::get('user_shop')?->name, 'session_user_shop_user_id' => \Session::get('user_shop')?->user_id, @@ -47,7 +43,8 @@ class RegisterController extends Controller } $data = [ - 'GOOGLE_ReCAPTCHA_KEY' => $this->GOOGLE_ReCAPTCHA_KEY, + 'turnstile_site_key' => config('services.turnstile.site_key'), + 'form_token' => old(FormSpamGuard::TIMESTAMP_FIELD, $this->spamGuard->makeFormToken()), 'user_shop' => Util::getUserShop(), 'yard_instance' => 'webshop', ]; @@ -60,13 +57,12 @@ class RegisterController extends Controller if (! $member_id) { return redirect('/registrierung'); } - $user_id = (int) str_replace('m', '', $member_id) - config('mivita.add_number_id'); - $user = User::find($user_id); - if (! $user || ! $user->isActive() || ! $user->isActiveAccount()) { + if (! $this->findActiveSponsor($member_id)) { return redirect('/registrierung'); } $data = [ - 'GOOGLE_ReCAPTCHA_KEY' => $this->GOOGLE_ReCAPTCHA_KEY, + 'turnstile_site_key' => config('services.turnstile.site_key'), + 'form_token' => old(FormSpamGuard::TIMESTAMP_FIELD, $this->spamGuard->makeFormToken()), 'user_shop' => Util::getUserShop(), 'from_member_id' => $member_id, 'yard_instance' => 'webshop', @@ -75,32 +71,17 @@ class RegisterController extends Controller return view('web.templates.registrierung', $data); } - public function register() + public function register(RegisterUserRequest $request): RedirectResponse { + if ($blockReason = $this->spamGuard->blockReason($request)) { + FormSpamGuard::log($request, 'register', $blockReason); - $rules = [ - 'salutation' => 'required', - 'first_name' => 'required', - 'last_name' => 'required', - 'email' => 'required|string|email:filter|max:255|unique:users', - 'email-confirm' => 'required|same:email', - 'password' => 'required|string|min:6|confirmed', - 'password_confirmation' => 'required|string|min:6', - 'g-recaptcha-response' => 'required|recaptcha', - 'accepted_data_protection' => 'required', - ]; - - Validator::extend('recaptcha', function ($attribute, $value, $parameters, $validator) { - return $this->reCaptcha_validate($attribute, $value, $parameters, $validator); - }); - - $validator = Validator::make(Request::all(), $rules); - if ($validator->fails()) { - return back()->withErrors($validator)->withInput(Request::all()); + return redirect('/registrierung/finish'); } $user_shop = Util::getUserShop(); - $data = Request::all(); + $data = $request->validated(); + $data['lang'] = ! empty(\App::getLocale()) ? \App::getLocale() : 'de'; $user = $this->userRepo->create($data); $confirmation_code = UserService::createConfirmationCode(); @@ -108,10 +89,9 @@ class RegisterController extends Controller if ($user_shop) { $m_sponsor_id = $user_shop->user->id; } - if (isset($data['from_member_id'])) { - $m_sponsor_id = (int) str_replace('m', '', $data['from_member_id']) - config('mivita.add_number_id'); + if (filled($data['from_member_id'] ?? null) && $sponsor = $this->findActiveSponsor($data['from_member_id'])) { + $m_sponsor_id = $sponsor->id; } - $user->lang = ! empty(\App::getLocale()) ? \App::getLocale() : 'de'; $user->confirmation_code = $confirmation_code; $user->confirmation_code_to = date('Y-m-d H:i:s', strtotime('+1 week')); $user->confirmation_code_remider = 0; @@ -144,23 +124,18 @@ class RegisterController extends Controller return view('web.templates.registrierung_finish', $data); } - private function reCaptcha_validate($attribute, $value, $parameters, $validator) + /** + * Löst eine Partner-Nummer (z. B. "m1234") zu einem aktiven Berater auf. + */ + private function findActiveSponsor(string $memberId): ?User { + $userId = (int) str_replace('m', '', $memberId) - config('mivita.add_number_id'); + $user = User::find($userId); - $client = new Client; + if (! $user || ! $user->isActive() || ! $user->isActiveAccount()) { + return null; + } - $response = $client->post( - 'https://www.google.com/recaptcha/api/siteverify', - [ - 'form_params' => [ - 'secret' => $this->GOOGLE_ReCAPTCHA_SECRET, - 'response' => $value, - ], - ] - ); - - $body = json_decode((string) $response->getBody()); - - return $body->success; + return $user; } } diff --git a/app/Http/Controllers/WizardController.php b/app/Http/Controllers/WizardController.php index 9ee4aa63..5562138f 100644 --- a/app/Http/Controllers/WizardController.php +++ b/app/Http/Controllers/WizardController.php @@ -21,6 +21,7 @@ use App\User; use Auth; use Hash; use Illuminate\Support\Facades\Mail; +use Illuminate\Validation\Rules\Password; use Request; use Validator; use Yard; @@ -489,7 +490,7 @@ class WizardController extends Controller return redirect(route('wizard_create', [12])); } $rules = [ - 'password' => 'required|string|min:6|confirmed', + 'password' => ['required', 'string', 'confirmed', Password::defaults()], ]; $validator = Validator::make(Request::all(), $rules); if ($validator->fails()) { diff --git a/app/Http/Requests/Contact/ContactStoreRequest.php b/app/Http/Requests/Contact/ContactStoreRequest.php new file mode 100644 index 00000000..6806707c --- /dev/null +++ b/app/Http/Requests/Contact/ContactStoreRequest.php @@ -0,0 +1,84 @@ +> + */ + public function rules(): array + { + $rules = [ + 'salutation' => ['required', 'in:mr,ms'], + 'first_name' => ['required', 'string', new PersonName], + 'last_name' => ['required', 'string', new PersonName], + 'email' => ['required', 'email', 'max:255'], + 'phone' => ['nullable', 'string', 'max:30', 'regex:'.self::PHONE_PATTERN], + 'message' => ['required', 'string', 'max:3000', $this->maxLinksRule()], + 'accepted_data_protection' => ['accepted'], + 'cf-turnstile-response' => ['required', new TurnstileToken($this->ip())], + ]; + + if ($this->asksForSalesPartnership()) { + $rules['sales_partnership'] = ['required', 'in:JA,NEIN']; + $rules['sales_partnership_message'] = ['required_if:sales_partnership,JA', 'nullable', 'string', 'max:3000', $this->maxLinksRule()]; + } + + return $rules; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'phone.regex' => __('contact.phone_invalid'), + 'cf-turnstile-response.required' => __('form_protection.captcha_failed'), + ]; + } + + /** + * Abfrage zur Vertriebspartnerschaft nur auf der Hauptseite und im Firmen-Shop. + */ + public function asksForSalesPartnership(): bool + { + $userShop = Util::getUserShop(); + + return ! $userShop || $userShop->id === 22; + } + + protected function failedValidation(Validator $validator): void + { + FormSpamGuard::log($this, 'contact', 'validation', ['fields' => array_keys($validator->failed())]); + + parent::failedValidation($validator); + } + + private function maxLinksRule(): Closure + { + return function (string $attribute, mixed $value, Closure $fail): void { + if (preg_match_all('~https?://|www\.~i', (string) $value) > self::MAX_LINKS) { + $fail(__('contact.too_many_links')); + } + }; + } +} diff --git a/app/Http/Requests/Register/RegisterUserRequest.php b/app/Http/Requests/Register/RegisterUserRequest.php new file mode 100644 index 00000000..80b9cb74 --- /dev/null +++ b/app/Http/Requests/Register/RegisterUserRequest.php @@ -0,0 +1,54 @@ +> + */ + public function rules(): array + { + return [ + 'salutation' => ['required', 'in:mr,ms'], + 'first_name' => ['required', 'string', new PersonName], + 'last_name' => ['required', 'string', new PersonName], + 'email' => ['required', 'string', 'email:filter', 'max:255', 'unique:users'], + 'email-confirm' => ['required', 'same:email'], + 'password' => ['required', 'string', 'max:255', 'confirmed', Password::defaults()], + 'password_confirmation' => ['required', 'string'], + 'accepted_data_protection' => ['accepted'], + 'from_member_id' => ['nullable', 'string', 'max:20'], + 'cf-turnstile-response' => ['required', new TurnstileToken($this->ip())], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'cf-turnstile-response.required' => __('form_protection.captcha_failed'), + ]; + } + + protected function failedValidation(Validator $validator): void + { + FormSpamGuard::log($this, 'register', 'validation', ['fields' => array_keys($validator->failed())]); + + parent::failedValidation($validator); + } +} diff --git a/app/Mail/MailContact.php b/app/Mail/MailContact.php index 144f0408..ed649024 100644 --- a/app/Mail/MailContact.php +++ b/app/Mail/MailContact.php @@ -1,59 +1,59 @@ $data + */ public function __construct($data) { $this->data = $data; $this->user_shop = Util::getUserShop(); - $this->subject = __('email.your_request_from').' mivita.care'; - if($this->user_shop){ - $this->subject = __('email.your_request_from')." ".$this->user_shop->slug.'.mivita.care'; - } + $this->subject = __('contact.mail_subject', ['source' => $this->source()]); + $this->replyTo($data['email'], trim(($data['first_name'] ?? '').' '.($data['last_name'] ?? ''))); } public function build() { - $salutation = __('email.hello'); - if(isset($this->data['first_name'])){ - $salutation .= " ".$this->data['first_name']; - } - - if(isset($this->data['salutation'])){ - if($this->data['salutation'] === 'mr'){ + if (isset($this->data['salutation'])) { + if ($this->data['salutation'] === 'mr') { $this->data['salutation'] = 'Herr'; } - if($this->data['salutation'] === 'ms'){ + if ($this->data['salutation'] === 'ms') { $this->data['salutation'] = 'Frau'; } } - $copy1line = __('email.your_request_from')." mivita.care"; - if($this->user_shop){ - $copy1line = __('email.your_request_from')." ".$this->user_shop->slug.'.mivita.care'; - } return $this->view('emails.contact')->with([ - 'salutation' => $salutation, - 'copy1line' => $copy1line, + 'salutation' => __('contact.mail_headline'), + 'copy1line' => __('contact.mail_intro', ['source' => $this->source()]), 'data' => $this->data, - 'copy3line' => __('email.copy3line'), + 'copy3line' => __('contact.mail_reply_hint'), 'greetings' => __('email.greetings'), 'sender' => __('email.sender'), ]); } -} \ No newline at end of file + + private function source(): string + { + return $this->user_shop ? $this->user_shop->slug.'.mivita.care' : 'mivita.care'; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8a035677..e7a8a55f 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -17,6 +17,7 @@ use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\Validator; use Illuminate\Support\ServiceProvider; +use Illuminate\Validation\Rules\Password; class AppServiceProvider extends ServiceProvider { @@ -29,6 +30,8 @@ class AppServiceProvider extends ServiceProvider { Schema::defaultStringLength(191); Validator::resolver(fn ($translator, $data, $rules, $messages, $attributes) => new SafeEmailValidator($translator, $data, $rules, $messages, $attributes)); + // Mindestanforderung für alle neu gesetzten Passwörter (Registrierung, Wizard, Ändern, Zurücksetzen) + Password::defaults(fn () => Password::min(8)); Paginator::defaultView('pagination::bootstrap-4'); Paginator::defaultSimpleView('pagination::simple-bootstrap-4'); diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index cdb7445f..3eafdd0a 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -5,6 +5,8 @@ namespace App\Providers; // use App\Domain\DomainContext; // HOTFIX: Temporär deaktiviert use App\Domain\EarlyDomainParser; use App\Http\Controllers\HealthController; +use App\Services\FormSpamGuard; +use Closure; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider; use Illuminate\Http\Request; @@ -131,5 +133,38 @@ class RouteServiceProvider extends ServiceProvider RateLimiter::for('api', function (Request $request) { return Limit::perMinute(60)->by(optional($request->user())->id ?: $request->ip()); }); + + RateLimiter::for('contact-form', function (Request $request) { + $tooManyRequests = $this->formTooManyRequestsResponse('contact'); + + return [ + Limit::perMinutes(10, 5)->by('contact-ip:'.$request->ip())->response($tooManyRequests), + Limit::perDay(5)->by('contact-email:'.mb_strtolower((string) $request->input('email')))->response($tooManyRequests), + ]; + }); + + RateLimiter::for('register-form', function (Request $request) { + $tooManyRequests = $this->formTooManyRequestsResponse('register'); + + return [ + Limit::perMinutes(10, 5)->by('register-ip:'.$request->ip())->response($tooManyRequests), + Limit::perDay(20)->by('register-ip-day:'.$request->ip())->response($tooManyRequests), + ]; + }); + } + + /** + * Zurück zum Formular mit Hinweis statt nackter 429-Seite. + * + * @param 'contact'|'register' $form + */ + private function formTooManyRequestsResponse(string $form): Closure + { + return function (Request $request) use ($form) { + FormSpamGuard::log($request, $form, 'rate_limit'); + + return back()->withInput($request->except('password', 'password_confirmation')) + ->withErrors(['email' => __('form_protection.too_many_requests')]); + }; } } diff --git a/app/Repositories/UserRepository.php b/app/Repositories/UserRepository.php index d5953a7c..fbacd280 100644 --- a/app/Repositories/UserRepository.php +++ b/app/Repositories/UserRepository.php @@ -51,10 +51,12 @@ class UserRepository extends BaseRepository public function create($data) { - $this->model = User::create([ + $this->model = new User([ 'email' => $data['email'], 'password' => Hash::make($data['password']), ]); + $this->model->lang = $data['lang'] ?? 'de'; + $this->model->save(); $account = UserAccount::create([ 'm_salutation' => $data['salutation'], diff --git a/app/Rules/PersonName.php b/app/Rules/PersonName.php new file mode 100644 index 00000000..6ec943ef --- /dev/null +++ b/app/Rules/PersonName.php @@ -0,0 +1,29 @@ + self::MAX_LENGTH || ! preg_match(self::PATTERN, $value)) { + $fail(__('form_protection.name_invalid')); + } + } +} diff --git a/app/Rules/TurnstileToken.php b/app/Rules/TurnstileToken.php new file mode 100644 index 00000000..6bc15aa6 --- /dev/null +++ b/app/Rules/TurnstileToken.php @@ -0,0 +1,23 @@ +verify($value, $this->ip)) { + $fail(__('form_protection.captcha_failed')); + } + } +} diff --git a/app/Services/FormSpamGuard.php b/app/Services/FormSpamGuard.php new file mode 100644 index 00000000..8291271d --- /dev/null +++ b/app/Services/FormSpamGuard.php @@ -0,0 +1,69 @@ +timestamp); + } + + /** + * Liefert den Grund, warum die Anfrage als Bot gilt, oder null. + */ + public function blockReason(Request $request): ?string + { + if (filled($request->input(self::HONEYPOT_FIELD))) { + return 'honeypot'; + } + + try { + $startedAt = (int) decrypt((string) $request->input(self::TIMESTAMP_FIELD)); + } catch (DecryptException) { + return 'invalid_form_token'; + } + + if (now()->timestamp - $startedAt < self::MIN_SECONDS) { + return 'too_fast'; + } + + return null; + } + + /** + * Protokolliert eine abgefangene Anfrage ohne Freitext-Inhalte. + * + * @param 'contact'|'register' $form + * @param array $context + */ + public static function log(Request $request, string $form, string $reason, array $context = []): void + { + Log::channel('form_spam')->info('Formular abgefangen', [ + 'form' => $form, + 'reason' => $reason, + 'ip' => $request->ip(), + 'host' => $request->getHost(), + 'user_shop_id' => Util::getUserShop()?->id, + 'email_domain' => Str::after((string) $request->input('email'), '@') ?: null, + ...$context, + ]); + } +} diff --git a/app/Services/TurnstileVerifier.php b/app/Services/TurnstileVerifier.php new file mode 100644 index 00000000..65f5572a --- /dev/null +++ b/app/Services/TurnstileVerifier.php @@ -0,0 +1,46 @@ +error('Turnstile: TURNSTILE_SECRET_KEY ist nicht gesetzt.'); + + return false; + } + + try { + $response = Http::asForm()->timeout(5)->post(self::VERIFY_URL, [ + 'secret' => $secret, + 'response' => $token, + 'remoteip' => $ip, + ]); + } catch (ConnectionException $e) { + Log::channel('form_spam')->warning('Turnstile: Cloudflare nicht erreichbar.', ['error' => $e->getMessage()]); + + return false; + } + + return $response->successful() && $response->json('success') === true; + } +} diff --git a/config/logging.php b/config/logging.php index 12ae6a5f..c9aa225a 100755 --- a/config/logging.php +++ b/config/logging.php @@ -96,6 +96,13 @@ return [ 'level' => env('LOG_LEVEL', 'info'), 'days' => 30, ], + // Abgefangene Anfragen aus Kontakt- und Registrierungsformular (ohne Freitext) + 'form_spam' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/form-spam.log'), + 'level' => 'info', + 'days' => 30, + ], 'stack' => [ 'driver' => 'stack', 'channels' => ['single'], diff --git a/config/services.php b/config/services.php index 24fed224..f91c839a 100755 --- a/config/services.php +++ b/config/services.php @@ -37,6 +37,12 @@ return [ 'secret' => env('STRIPE_SECRET'), ], + // Cloudflare Turnstile (kostenloses Captcha) für das Kontaktformular + 'turnstile' => [ + 'site_key' => env('TURNSTILE_SITE_KEY'), + 'secret_key' => env('TURNSTILE_SECRET_KEY'), + ], + 'vies' => [ 'enabled' => env('VIES_ENABLED', true), 'timeout' => env('VIES_TIMEOUT', 10), diff --git a/dev/2026-10-09-spam/README.md b/dev/2026-10-09-spam/README.md new file mode 100644 index 00000000..2a5dce79 --- /dev/null +++ b/dev/2026-10-09-spam/README.md @@ -0,0 +1,233 @@ +# Spam über das Kontaktformular – Entwicklungsplan & Protokoll + +Stand: 09.10.2026 · Status: **Kontaktformular + Registrierung umgesetzt, als `v1.6.0` getaggt** (Deploy siehe Abschnitt 7) + +Beispiel-Mail: [spam-mail.md](spam-mail.md) + +--- + +## 1. Befund + +Betroffen ist das Kontaktformular `/kontakt`, das auf allen Shops läuft (Hauptseite, `shop.*`, alle Berater-Shops wie `freshzone.mivita.care`). + +- Route: `routes/shared/common.php` (bzw. `routes/domains/main.php`) → `Web\ContactController@store` +- Mail: `App\Mail\MailContact`, Vorlage `resources/views/emails/contact.blade.php` +- Formular: `resources/views/web/templates/kontakt.blade.php` + +### Was in der Beispiel-Mail passiert + +Die Spam-Mail ist **keine Nachricht an uns**, sondern die **Kopie an den Absender** („Deine Anfrage von freshzone.mivita.care“). Der Controller schickt die Mail so: + +```php +Mail::to($contact['email']) // ← Adresse aus dem Formular + ->bcc([$user_shop->user->email, $contact_mail]) + ->send(new MailContact($contact)); +``` + +Der Spammer trägt also die Adresse eines **fremden Opfers** ein (hier `igor.makhataev@icloud.com`) und seinen Werbetext in Vorname, Nachname und Nachricht. Unser Server verschickt dann eine Mail **im Namen von mivita.care** mit „Hallo “ und dem Phishing-Link an das Opfer. Wir und der Berater bekommen nur die BCC-Kopie. + +Das heißt: + +1. **Unser Formular wird als Spam-Versender missbraucht.** Das ist der eigentliche Grund, warum das Formular für Spammer interessant ist – nicht, um uns zu erreichen. +2. **Ruf der Absender-Domain:** Beschwerden der Empfänger („Als Spam markieren“) landen bei unserer Domain. Das kann dazu führen, dass auch echte Mails (Bestellbestätigungen, Rechnungen, Abo-Mails) im Spam landen. +3. Jeder Berater mit Shop bekommt den Spam zusätzlich per BCC. + +### Weitere Schwachstellen + +| # | Schwachstelle | Fundstelle | +| - | ------------- | ---------- | +| A | Bestätigungsmail geht an beliebige eingegebene Adresse, inklusive aller Eingaben | `ContactController@store` | +| B | Kein Rate-Limit (nur globales `throttle:60,1` für die Web-Gruppe) | `routes/shared/common.php` | +| C | reCAPTCHA **v2 (Checkbox)** – wird von bezahlten Captcha-Lösediensten zuverlässig geknackt | `kontakt.blade.php` | +| D | reCAPTCHA-Schlüssel **inkl. Secret hart im Code** und damit im Git | `ContactController` (und `RegisterController`) | +| E | Keine Plausibilitätsprüfung: Vorname/Nachname ohne Längenbegrenzung, Links erlaubt, beliebige Schrift | Validierungsregeln | +| F | Eingaben werden **ungefiltert als HTML** in die Mail geschrieben (`{!! nl2br($value) !!}`) – man könnte auch Links/Bilder einschleusen | `emails/contact.blade.php` | +| G | Kein Protokoll: Wir sehen nicht, wie viele Versuche kommen und was geblockt wird | – | +| H | Validierung inline im Controller statt Form Request | `ContactController` | + +--- + +## 2. Vorgeschlagene Maßnahmen + +Reihenfolge nach Wirkung. Stufe 1 allein dürfte den größten Teil erledigen, weil das Formular für Spammer damit wertlos wird. + +### Stufe 1 – sofort, höchste Wirkung + +**1.1 Keine Inhalte mehr an die eingegebene Adresse schicken** (behebt A) +- Die Anfrage geht nur noch an `kontakt@mivita.care` bzw. den Berater, mit `Reply-To` = Adresse des Absenders (Antworten funktionieren wie bisher). +- Der Absender sieht die Bestätigungsseite. Falls eine Bestätigungsmail gewünscht ist: nur ein **neutraler Text ohne Eingaben** („Wir haben Deine Anfrage erhalten …“), siehe Entscheidung E1. + +**1.2 Rate-Limit** (behebt B) +- Eigener Limiter für `POST /kontakt`, z. B. max. 3 Anfragen / 10 Minuten pro IP und max. 3 / Tag pro E-Mail-Adresse. + +**1.3 Honeypot + Mindest-Ausfüllzeit** (unsichtbar für echte Besucher) +- Verstecktes Feld, das nur Bots ausfüllen. +- Verschlüsselter Zeitstempel beim Laden des Formulars; Absenden schneller als ~3 Sekunden → verwerfen. +- Abgefangene Anfragen bekommen trotzdem die normale Erfolgsseite, damit der Bot nichts lernt. + +**1.4 Plausibilitätsregeln** (behebt E) +- Vorname/Nachname: max. 50 Zeichen, keine URLs, keine Zeilenumbrüche, nur lateinische Schrift inkl. Umlaute/Akzente (Shops laufen auf de/en/es/fr). +- Nachricht: max. ca. 3.000 Zeichen, max. 1–2 Links. +- Telefon: nur Ziffern, Leerzeichen, `+ - / ( )`. +- Vorname = Nachname = Nachricht (wie im Beispiel) → verwerfen. + +**1.5 Mail-Vorlage absichern** (behebt F) +- `{!! nl2br($value) !!}` → `{!! nl2br(e($value)) !!}`. + +### Stufe 2 – Aufräumen & Absichern + +**2.1 Captcha** (behebt C, D) – siehe Entscheidung E2 +- Schlüssel in `.env` / `config/services.php` verschieben, **Secret bei Google neu erzeugen** (das alte liegt im Git). +- Bei der Prüfung zusätzlich `hostname` kontrollieren. +- Optional Wechsel auf Cloudflare Turnstile oder reCAPTCHA v3 (unsichtbar, schwerer automatisiert zu lösen). + +**2.2 Form Request** (behebt H) +- `ContactStoreRequest` mit Regeln und Fehlermeldungen (de/en/es/fr), Controller verschlanken. + +**2.3 Protokoll geblockter Anfragen** (behebt G) – siehe Entscheidung E4 +- Eigener Log-Kanal `contact-spam` (Grund, IP, Shop, Zeit – **ohne** Nachrichtentext wegen Datenschutz) oder kleine DB-Tabelle mit Zählern. +- Damit sehen wir nach der Umstellung, ob es wirkt, und können Regeln nachschärfen. + +### Stufe 3 – nur falls nötig + +- Wortfilter / Sperrliste für Domains und Linkdienste (z. B. `share.google`, Kurz-URLs). +- Gleiche Prüfung auf das Registrierungsformular übertragen (nutzt dieselben reCAPTCHA-Schlüssel). + +--- + +## 3. Entscheidungen (abgestimmt am 09.10.2026) + +| # | Frage | Entscheidung | +| - | ----- | ------------ | +| E1 | Bestätigungsmail an den Absender? | **Nein.** Nur Bestätigungsseite. Die Anfrage geht an `kontakt@mivita.care` (+ Berater per BCC), `Reply-To` = Absender. | +| E2 | Captcha | **Wechsel auf Cloudflare Turnstile.** Vorgabe: kostenlos. Turnstile ist dauerhaft kostenlos, ohne Mengenlimit und ohne Zahlungsdaten; die Seite muss nicht über Cloudflare laufen. reCAPTCHA v3 läuft inzwischen über Google Cloud (nur 10.000 Prüfungen/Monat frei, Cloud-Projekt nötig) und ist datenschutzrechtlich ungünstiger. | +| E3 | Nicht-lateinische Schrift in Namensfeldern blocken? | **Ja.** | +| E4 | Protokoll | **Logdatei** `storage/logs/contact-spam-YYYY-MM-DD.log` (30 Tage). Admin-Übersicht nur bei Bedarf. | +| E5 | Berater bekommen weiter eine Kopie? | **Ja**, per BCC. | + +--- + +## 4. Umsetzung Kontaktformular (Stand 09.10.2026) + +### Was jetzt passiert, wenn jemand das Formular abschickt + +1. **Rate-Limit** (`throttle:contact-form`, `RouteServiceProvider`): max. 5 Anfragen / 10 Min. pro IP und max. 5 / Tag pro E-Mail-Adresse. Darüber: Fehlermeldung am Formular. +2. **Validierung** (`App\Http\Requests\Contact\ContactStoreRequest`): + - Vor-/Nachname (`App\Rules\PersonName`): max. 50 Zeichen, nur lateinische Buchstaben inkl. Umlaute/Akzente, Leerzeichen, `- ' .` + - Telefon: max. 30 Zeichen, nur `0-9 + - / ( ) .` und Leerzeichen + - Nachricht: max. 3.000 Zeichen, max. 2 Links + - Turnstile-Token wird bei Cloudflare geprüft (`App\Rules\TurnstileToken` → `App\Services\TurnstileVerifier`). Ist Cloudflare nicht erreichbar oder fehlt das Secret → abgelehnt. +3. **Unsichtbare Bot-Erkennung** (`App\Services\FormSpamGuard`): verstecktes Feld `form_extra` (Honeypot) und verschlüsselter Zeitstempel `form_token` (Absenden schneller als 3 Sek. → Bot). Bots bekommen die normale Erfolgsseite, es wird aber nichts verschickt. +4. **Versand** (`App\Mail\MailContact`): an `config('app.contact_mail')`, Berater des Shops per BCC, `Reply-To` = Absender. Betreff: „Neue Kontaktanfrage über .mivita.care“. **Keine Mail mehr an die eingegebene Adresse.** +5. Eingaben werden in der Mail HTML-escaped (`resources/views/emails/contact.blade.php`). + +--- + +## 5. Umsetzung Registrierung (Stand 09.10.2026) + +Die Registrierung (`/registrierung`, `/reg/{partnernummer}` auf Hauptseite, `shop.*` und Berater-Shops) hatte dieselben Schwächen. Zusätzlich verschickt sie eine Bestätigungsmail an die eingegebene Adresse **mit dem Vornamen in der Anrede** – ohne Namensprüfung hätte sich auch das als Spam-Versender missbrauchen lassen. + +1. **Turnstile statt reCAPTCHA** (`data-action="register"`). Damit ist reCAPTCHA im ganzen System entfernt; die alten Schlüssel stehen nicht mehr im Code. +2. **Rate-Limit** (`throttle:register-form`): max. 5 Versuche / 10 Min. und 20 / Tag pro IP (großzügiger, weil Berater auf Veranstaltungen mehrere Partner aus einem WLAN registrieren). +3. **Validierung** (`App\Http\Requests\Register\RegisterUserRequest`): Namensregel `PersonName` wie beim Kontaktformular, Anrede nur `mr`/`ms`, Datenschutz muss akzeptiert sein. Passwort min. 8 Zeichen (systemweit, siehe unten). +4. **Honeypot + Mindestzeit** (`FormSpamGuard`): Bots werden auf die Erfolgsseite geleitet, es wird kein Benutzer angelegt und keine Mail verschickt. +5. **Sponsor-Manipulation verhindert:** Die Partner-Nummer `from_member_id` wurde beim Absenden ungeprüft übernommen – man konnte sich jedem beliebigen (auch gesperrten) Berater unterhängen. Jetzt gilt sie nur, wenn der Berater aktiv ist und einen gültigen Account hat; sonst greift wie bisher Shop-Inhaber bzw. Firma. +6. Nur noch geprüfte Felder werden an `UserRepository::create()` übergeben; die Sprache wird direkt beim Anlegen gesetzt. + +### Passwort-Mindestlänge systemweit 8 Zeichen + +Zentral in `AppServiceProvider::boot()`: `Password::defaults(fn () => Password::min(8))`. Alle Stellen, an denen ein neues Passwort gesetzt wird, nutzen `Password::defaults()`: Registrierung, Wizard (Schritt 11), erstes Passwort nach Freischaltung, Passwort ändern, Passwort zurücksetzen (Laravel-Standard). Vorher galten teils 6, teils 8 Zeichen. Bestehende kürzere Passwörter funktionieren beim Login weiter – die Regel greift nur beim Setzen. Texte in `resources/lang/*/passwords.php` angepasst. Tests: `tests/Feature/PasswordPolicyTest.php`. + +### Sicherheitslücke im CRM geschlossen + +`Auth::routes()` in `routes/domains/crm.php` hat neben Login auch `POST /register` auf `my.mivita.care` registriert. Das Formular dazu leitete zwar zum Login um, **der POST selbst legte aber ohne Captcha einen Benutzer an und meldete ihn direkt an** (auf dem Dev-System geprüft: Antwort 419 = Route aktiv, nur CSRF fehlte). Jetzt `Auth::routes(['register' => false])`; der Link „Register“ in `layouts/app.blade.php` ist entfernt. + +Empfehlung: Auf Live einmal prüfen, ob es Benutzer **ohne `account_id`** gibt – das wären Konten, die über diesen Weg angelegt wurden: + +```sql +SELECT id, email, created_at FROM users WHERE account_id IS NULL OR account_id = 0 ORDER BY created_at DESC; +``` + +--- + +## 6. Gemeinsames Protokoll & Stellschrauben + +### Protokoll abgefangener Anfragen + +`storage/logs/form-spam-*.log` (30 Tage), eine Zeile pro Vorfall mit `form` (`contact` / `register`), `reason`, IP, Host, Shop-ID, Domain der E-Mail-Adresse – kein Freitext, keine Passwörter. + +| reason | Bedeutung | +| ------ | --------- | +| `validation` | Validierung fehlgeschlagen, `fields` nennt die Felder (betrifft auch echte Tippfehler) | +| `rate_limit` | zu viele Anfragen | +| `honeypot` | verstecktes Feld ausgefüllt | +| `too_fast` | schneller als 3 Sek. abgeschickt | +| `invalid_form_token` | Zeitstempel fehlt/manipuliert (Formular nicht über die Seite geladen) | + +Auswertung auf dem Server (Bash): + +```bash +grep -ho '"form":"[a-z]*","reason":"[a-z_]*"' storage/logs/form-spam-*.log | sort | uniq -c +``` + +### Stellschrauben für spätere Anpassungen + +| Was | Wo | +| --- | -- | +| Rate-Limits | `RouteServiceProvider::configureRateLimiting()` → `contact-form`, `register-form` | +| Namensregel | `App\Rules\PersonName` (Muster, max. Länge) | +| Telefon-Muster, max. Links (Kontakt) | Konstanten in `ContactStoreRequest` | +| Mindest-Ausfüllzeit, Feldnamen | `FormSpamGuard` | +| Fehlermeldungen | `resources/lang/*/form_protection.php` (gemeinsam), `resources/lang/*/contact.php` (Kontakt + Mailtexte) | + +Hinweis Routing: Auf der Hauptdomain überschreibt `routes/domains/main.php` die Kontakt-Route aus `routes/shared/common.php`. Änderungen an der Route deshalb **an beiden Stellen** machen. Die Registrierungs-Route steht in `main.php`, `shop.php` und `user-shop.php`. + +### Geänderte / neue Dateien + +- Controller: `Web/ContactController.php`, `Web/RegisterController.php` +- Requests: `Requests/Contact/ContactStoreRequest.php`, `Requests/Register/RegisterUserRequest.php` (neu) +- Regeln/Services: `Rules/PersonName.php`, `Rules/TurnstileToken.php`, `Services/TurnstileVerifier.php`, `Services/FormSpamGuard.php` (neu) +- `Mail/MailContact.php`, `Repositories/UserRepository.php` (`create`: Sprache beim Anlegen) +- Views: `web/templates/kontakt.blade.php`, `web/templates/registrierung.blade.php`, `emails/contact.blade.php`, `layouts/app.blade.php` +- Routen: `RouteServiceProvider.php`, `routes/shared/common.php`, `routes/domains/{main,shop,user-shop,crm}.php` +- Config: `config/services.php` (`turnstile`), `config/logging.php` (`form_spam`), `.env.example` +- Sprachen: `resources/lang/{de,en,es,fr}/contact.php`, `form_protection.php` (neu) +- Passwort: `AppServiceProvider.php`, `UserUpdatePasswordController.php`, `WizardController.php`, `Auth/RegisterController.php`, `resources/lang/*/passwords.php` +- Tests: `tests/Feature/ContactFormSpamTest.php` (26), `tests/Feature/RegisterFormSpamTest.php` (16), `tests/Feature/PasswordPolicyTest.php` (5) + +--- + +## 7. Deployment – vor dem Go-Live erledigen + +1. **Turnstile-Widget anlegen** (kostenlos): https://dash.cloudflare.com → Turnstile → „Add widget“ + - Hostnames: `mivita.care`, `mivita.shop` (Subdomains wie `freshzone.mivita.care` sind automatisch abgedeckt) + - Widget Mode: **Managed** +2. Schlüssel in die Live-`.env` eintragen: + ``` + TURNSTILE_SITE_KEY=... + TURNSTILE_SECRET_KEY=... + ``` + ✅ Widget angelegt am 09.10.2026. **Ohne diese Schlüssel lehnen Kontaktformular und Registrierung jede Anfrage ab.** Lokal sind die Cloudflare-Testschlüssel eingetragen, die immer bestehen. +3. **Datenschutzerklärung** anpassen: Google reCAPTCHA raus, Cloudflare Turnstile (Kontakt + Registrierung) rein. +4. Deploy wie üblich per Tag (`dev/betrieb/README.md`), keine Migration: `bash dev/betrieb/deploy.sh v1.6.0` +5. Danach den alten reCAPTCHA-Schlüssel in der Google-Konsole löschen (das Secret liegt in der Git-Historie). +6. SQL-Abfrage aus Abschnitt 5 (Benutzer ohne Account) auf Live ausführen. +7. Nach ein paar Tagen das Log auswerten (Befehl oben) und hier im Protokoll eintragen. + +### Noch offen (nur bei Bedarf) + +- Wortfilter / Sperrliste für Linkdienste, falls trotzdem Spam durchkommt. + +--- + +## 8. Protokoll + +| Datum | Was | Version | +| ----- | --- | ------- | +| 09.10.2026 | Analyse, Plan erstellt (dieses Dokument) | – | +| 09.10.2026 | Entscheidungen E1–E5 abgestimmt (E2: Turnstile statt reCAPTCHA, weil kostenlos) | – | +| 09.10.2026 | Stufe 1 + 2 umgesetzt, 26 Tests grün (`tests/Feature/ContactFormSpamTest.php`) | noch nicht deployt | +| 09.10.2026 | Registrierung auf Turnstile + gleiche Schutzschichten, Sponsor-Prüfung, offenes `POST /register` im CRM geschlossen; Schutz-Bausteine verallgemeinert (`FormSpamGuard`, `PersonName`, Log `form-spam`), 15 Tests grün (`tests/Feature/RegisterFormSpamTest.php`) | noch nicht deployt | +| 09.10.2026 | Passwort-Mindestlänge systemweit auf 8 Zeichen (zentral über `Password::defaults()`), Turnstile-Widget bei Cloudflare angelegt | noch nicht deployt | +| 09.10.2026 | Echte Turnstile-Schlüssel lokal geprüft: Secret von Cloudflare akzeptiert, Site-Key auf Hauptseite und Berater-Shop ausgeliefert, gefälschtes Token wird abgelehnt und protokolliert | noch nicht deployt | +| 09.10.2026 | Auf dem Testsystem im Browser geprüft (läuft), Commit + Tag für den Deploy | v1.6.0 | diff --git a/dev/2026-10-09-spam/spam-mail.md b/dev/2026-10-09-spam/spam-mail.md new file mode 100644 index 00000000..512068f8 --- /dev/null +++ b/dev/2026-10-09-spam/spam-mail.md @@ -0,0 +1,24 @@ +Hallo Приветствуем! Напоминаем, что на вашем бонусном счёте находится 926USD. Бонус будет аннулирован через 10 дней. Воспользоваться бонусом или вывести средства вы можете по ссылке: https://share.google/O8hzS8W7vPuroPXdL Всего доброго, команда сайта + +Deine Anfrage von freshzone.mivita.care + +Anrede: +Frau +Vorname: +Приветствуем! Напоминаем, что на вашем бонусном счёте находится 926USD. Бонус будет аннулирован через 10 дней. Воспользоваться бонусом или вывести средства вы можете по ссылке: https://share.google/O8hzS8W7vPuroPXdL +Всего доброго, команда сайта +Nachname: +Приветствуем! Напоминаем, что на вашем бонусном счёте находится 926USD. Бонус будет аннулирован через 10 дней. Воспользоваться бонусом или вывести средства вы можете по ссылке: https://share.google/O8hzS8W7vPuroPXdL +Всего доброго, команда сайта +E-Mail: +igor.makhataev@icloud.com +Telefon: +88538273161 +Nachricht: +Приветствуем! Напоминаем, что на вашем бонусном счёте находится 918USD. Бонус будет аннулирован через 11 дней. Воспользоваться бонусом или вывести средства вы можете по ссылке: https://share.google/SzivlySgl5DxoQgWn +Всего доброго, команда сайта + + +Für weitere Fragen stehen wir Dir gerne zur Verfügung. + +Mit freundlichen Grüßen \ No newline at end of file diff --git a/resources/lang/de/contact.php b/resources/lang/de/contact.php new file mode 100644 index 00000000..86c21f2a --- /dev/null +++ b/resources/lang/de/contact.php @@ -0,0 +1,10 @@ + 'Bitte gib eine gültige Telefonnummer ein.', + 'too_many_links' => 'Die Nachricht enthält zu viele Links.', + 'mail_subject' => 'Neue Kontaktanfrage über :source', + 'mail_headline' => 'Neue Kontaktanfrage', + 'mail_intro' => 'Über das Kontaktformular von :source ist folgende Anfrage eingegangen.', + 'mail_reply_hint' => 'Antworte einfach auf diese E-Mail, um den Absender direkt zu erreichen.', +]; diff --git a/resources/lang/de/form_protection.php b/resources/lang/de/form_protection.php new file mode 100644 index 00000000..8a7d8d68 --- /dev/null +++ b/resources/lang/de/form_protection.php @@ -0,0 +1,8 @@ + 'Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.', + 'name_invalid' => 'Bitte gib einen gültigen Namen ein (nur Buchstaben, max. 50 Zeichen).', + 'too_many_requests' => 'Zu viele Anfragen. Bitte versuche es später erneut.', + 'honeypot_label' => 'Bitte dieses Feld leer lassen', +]; diff --git a/resources/lang/de/passwords.php b/resources/lang/de/passwords.php index 61ae075f..91ca93fa 100644 --- a/resources/lang/de/passwords.php +++ b/resources/lang/de/passwords.php @@ -1,9 +1,9 @@ 'Passwörter müssen mindestens 6 Zeichen lang sein und korrekt bestätigt werden.', - 'reset' => 'Das Passwort wurde zurückgesetzt!', - 'sent' => 'E-Mail zum Zurücksetzen Ihres Passworts wurde versendet', - 'token' => 'Der Passwort-Wiederherstellungs-Schlüssel ist ungültig oder abgelaufen.', - 'user' => 'Es konnte leider kein Nutzer mit dieser E-Mail-Adresse gefunden werden.', -); +return [ + 'password' => 'Passwörter müssen mindestens 8 Zeichen lang sein und korrekt bestätigt werden.', + 'reset' => 'Das Passwort wurde zurückgesetzt!', + 'sent' => 'E-Mail zum Zurücksetzen Ihres Passworts wurde versendet', + 'token' => 'Der Passwort-Wiederherstellungs-Schlüssel ist ungültig oder abgelaufen.', + 'user' => 'Es konnte leider kein Nutzer mit dieser E-Mail-Adresse gefunden werden.', +]; diff --git a/resources/lang/en/contact.php b/resources/lang/en/contact.php new file mode 100644 index 00000000..05f5b99d --- /dev/null +++ b/resources/lang/en/contact.php @@ -0,0 +1,10 @@ + 'Please enter a valid phone number.', + 'too_many_links' => 'The message contains too many links.', + 'mail_subject' => 'New contact request via :source', + 'mail_headline' => 'New contact request', + 'mail_intro' => 'The following request was submitted via the contact form on :source.', + 'mail_reply_hint' => 'Simply reply to this email to contact the sender directly.', +]; diff --git a/resources/lang/en/form_protection.php b/resources/lang/en/form_protection.php new file mode 100644 index 00000000..b30f5cd7 --- /dev/null +++ b/resources/lang/en/form_protection.php @@ -0,0 +1,8 @@ + 'The security check failed. Please try again.', + 'name_invalid' => 'Please enter a valid name (letters only, max. 50 characters).', + 'too_many_requests' => 'Too many requests. Please try again later.', + 'honeypot_label' => 'Please leave this field empty', +]; diff --git a/resources/lang/en/passwords.php b/resources/lang/en/passwords.php index 55b2352f..da5f540e 100644 --- a/resources/lang/en/passwords.php +++ b/resources/lang/en/passwords.php @@ -1,9 +1,9 @@ 'Passwords must be at least six characters and match the confirmation.', - 'reset' => 'Your password has been reset!', - 'sent' => 'We have e-mailed your password reset link!', - 'token' => 'This password reset token is invalid.', - 'user' => 'We can\'t find a user with that e-mail address.', -); +return [ + 'password' => 'Passwords must be at least eight characters and match the confirmation.', + 'reset' => 'Your password has been reset!', + 'sent' => 'We have e-mailed your password reset link!', + 'token' => 'This password reset token is invalid.', + 'user' => 'We can\'t find a user with that e-mail address.', +]; diff --git a/resources/lang/es/contact.php b/resources/lang/es/contact.php new file mode 100644 index 00000000..5e4a55fb --- /dev/null +++ b/resources/lang/es/contact.php @@ -0,0 +1,10 @@ + 'Por favor, introduce un número de teléfono válido.', + 'too_many_links' => 'El mensaje contiene demasiados enlaces.', + 'mail_subject' => 'Nueva solicitud de contacto a través de :source', + 'mail_headline' => 'Nueva solicitud de contacto', + 'mail_intro' => 'Se ha recibido la siguiente solicitud a través del formulario de contacto de :source.', + 'mail_reply_hint' => 'Responde simplemente a este correo para contactar directamente con el remitente.', +]; diff --git a/resources/lang/es/form_protection.php b/resources/lang/es/form_protection.php new file mode 100644 index 00000000..25d7495c --- /dev/null +++ b/resources/lang/es/form_protection.php @@ -0,0 +1,8 @@ + 'La verificación de seguridad ha fallado. Por favor, inténtalo de nuevo.', + 'name_invalid' => 'Por favor, introduce un nombre válido (solo letras, máx. 50 caracteres).', + 'too_many_requests' => 'Demasiadas solicitudes. Por favor, inténtalo más tarde.', + 'honeypot_label' => 'Por favor, deja este campo vacío', +]; diff --git a/resources/lang/es/passwords.php b/resources/lang/es/passwords.php index d130fe5b..60b29054 100644 --- a/resources/lang/es/passwords.php +++ b/resources/lang/es/passwords.php @@ -1,9 +1,9 @@ 'Las contraseñas deben tener al menos 6 caracteres y deben confirmarse correctamente.', - 'reset' => '¡La contraseña ha sido restablecida!', - 'sent' => 'Se ha enviado un correo electrónico para restablecer su contraseña.', - 'token' => 'La clave de recuperación de contraseña no es válida o ha caducado.', - 'user' => 'Lamentablemente no se pudo encontrar ningún usuario con esta dirección de correo electrónico.', -); +return [ + 'password' => 'Las contraseñas deben tener al menos 8 caracteres y deben confirmarse correctamente.', + 'reset' => '¡La contraseña ha sido restablecida!', + 'sent' => 'Se ha enviado un correo electrónico para restablecer su contraseña.', + 'token' => 'La clave de recuperación de contraseña no es válida o ha caducado.', + 'user' => 'Lamentablemente no se pudo encontrar ningún usuario con esta dirección de correo electrónico.', +]; diff --git a/resources/lang/fr/contact.php b/resources/lang/fr/contact.php new file mode 100644 index 00000000..48669ac1 --- /dev/null +++ b/resources/lang/fr/contact.php @@ -0,0 +1,10 @@ + 'Veuillez saisir un numéro de téléphone valide.', + 'too_many_links' => 'Le message contient trop de liens.', + 'mail_subject' => 'Nouvelle demande de contact via :source', + 'mail_headline' => 'Nouvelle demande de contact', + 'mail_intro' => 'La demande suivante a été envoyée via le formulaire de contact de :source.', + 'mail_reply_hint' => 'Répondez simplement à cet e-mail pour contacter directement l’expéditeur.', +]; diff --git a/resources/lang/fr/form_protection.php b/resources/lang/fr/form_protection.php new file mode 100644 index 00000000..d2102947 --- /dev/null +++ b/resources/lang/fr/form_protection.php @@ -0,0 +1,8 @@ + 'La vérification de sécurité a échoué. Veuillez réessayer.', + 'name_invalid' => 'Veuillez saisir un nom valide (lettres uniquement, 50 caractères max.).', + 'too_many_requests' => 'Trop de demandes. Veuillez réessayer plus tard.', + 'honeypot_label' => 'Veuillez laisser ce champ vide', +]; diff --git a/resources/lang/fr/passwords.php b/resources/lang/fr/passwords.php index bba4ae0a..79313cd0 100644 --- a/resources/lang/fr/passwords.php +++ b/resources/lang/fr/passwords.php @@ -1,7 +1,7 @@ 'Les mots de passe doivent contenir au moins 6 caractères et être correctement confirmés.', + 'password' => 'Les mots de passe doivent contenir au moins 8 caractères et être correctement confirmés.', 'reset' => 'Le mot de passe a été réinitialisé !', 'sent' => 'L’e-mail de réinitialisation de votre mot de passe a été envoyé.', 'token' => 'La clé de récupération du mot de passe est invalide ou a expiré.', diff --git a/resources/views/emails/contact.blade.php b/resources/views/emails/contact.blade.php index dc18157a..44024c91 100644 --- a/resources/views/emails/contact.blade.php +++ b/resources/views/emails/contact.blade.php @@ -154,7 +154,7 @@ {{__('email.'.$key)}}:   - {!! nl2br($value) !!} + {!! nl2br(e($value)) !!} @endforeach diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 475b5abe..2094f30c 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -40,9 +40,6 @@ - @else