216 lines
8.9 KiB
PHP
216 lines
8.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Controllers;
|
|
|
|
use App\Core\I18n;
|
|
use App\Core\Locale;
|
|
|
|
class HomeController extends Controller
|
|
{
|
|
/** Map of interest option translation key → internal identifier. */
|
|
private const INTEREST_KEYS = [
|
|
'visit' => 'form.interest.visit',
|
|
'info' => 'form.interest.info',
|
|
'apply' => 'form.interest.apply',
|
|
];
|
|
|
|
public function index(): void
|
|
{
|
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
|
session_start();
|
|
}
|
|
|
|
$locale = LocaleController::current();
|
|
|
|
$escapeContactValue = static fn(string $value): string
|
|
=> htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
|
|
|
|
$containsHeaderInjection = static function (string $value): bool {
|
|
return (bool) preg_match('/[\r\n]/', $value);
|
|
};
|
|
|
|
// ── Pull flashed state ────────────────────────────────────────
|
|
$formSuccess = !empty($_SESSION['form_success']);
|
|
$formErrors = $_SESSION['form_errors'] ?? [];
|
|
$formFieldErrors = $_SESSION['form_field_errors'] ?? [];
|
|
$formData = $_SESSION['form_data'] ?? null;
|
|
unset(
|
|
$_SESSION['form_success'],
|
|
$_SESSION['form_errors'],
|
|
$_SESSION['form_field_errors'],
|
|
$_SESSION['form_data'],
|
|
);
|
|
|
|
if ($formSuccess) {
|
|
$formData = self::emptyFormData();
|
|
$formFieldErrors = [];
|
|
} elseif (!is_array($formData)) {
|
|
$formData = self::emptyFormData();
|
|
$formFieldErrors = is_array($formFieldErrors) ? $formFieldErrors : [];
|
|
} else {
|
|
$formFieldErrors = is_array($formFieldErrors) ? $formFieldErrors : [];
|
|
}
|
|
|
|
// ── CSRF token ────────────────────────────────────────────────
|
|
if (empty($_SESSION['csrf_token'])) {
|
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
|
}
|
|
|
|
// ── Form processing ───────────────────────────────────────────
|
|
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
|
|
$csrfToken = (string) ($_POST['csrf_token'] ?? '');
|
|
if (!hash_equals($_SESSION['csrf_token'] ?? '', $csrfToken)) {
|
|
$_SESSION['form_errors'] = ['form.error.csrf'];
|
|
header('Location: /#kontakt');
|
|
exit;
|
|
}
|
|
|
|
$formData['fname'] = trim((string) ($_POST['fname'] ?? ''));
|
|
$formData['lname'] = trim((string) ($_POST['lname'] ?? ''));
|
|
$formData['email'] = trim((string) ($_POST['email'] ?? ''));
|
|
$formData['phone'] = trim((string) ($_POST['phone'] ?? ''));
|
|
$formData['interest'] = trim((string) ($_POST['interest'] ?? 'visit'));
|
|
$formData['message'] = trim((string) ($_POST['message'] ?? ''));
|
|
|
|
// Honeypot: bots succeed silently.
|
|
$honeypot = trim((string) ($_POST['website'] ?? ''));
|
|
if ($honeypot !== '') {
|
|
$_SESSION['form_success'] = true;
|
|
header('Location: /#kontakt');
|
|
exit;
|
|
}
|
|
|
|
// Per-field errors enable aria-invalid + aria-describedby.
|
|
$formFieldErrors = [];
|
|
if ($formData['fname'] === '') {
|
|
$formFieldErrors['fname'][] = 'form.error.fname_required';
|
|
}
|
|
if ($formData['lname'] === '') {
|
|
$formFieldErrors['lname'][] = 'form.error.lname_required';
|
|
}
|
|
if ($formData['email'] === '' || !filter_var($formData['email'], FILTER_VALIDATE_EMAIL)) {
|
|
$formFieldErrors['email'][] = 'form.error.email_invalid';
|
|
}
|
|
if ($formData['message'] === '') {
|
|
$formFieldErrors['message'][] = 'form.error.message_required';
|
|
}
|
|
if ($containsHeaderInjection($formData['email']) || $containsHeaderInjection($formData['fname'] . ' ' . $formData['lname'])) {
|
|
$formErrors[] = 'form.error.header_injection';
|
|
}
|
|
|
|
$formTime = isset($_POST['form_time']) ? (int) $_POST['form_time'] : 0;
|
|
if ($formTime > 0 && (time() - $formTime) < 3) {
|
|
$formErrors[] = 'form.error.too_fast';
|
|
}
|
|
|
|
$lastSubmit = $_SESSION['last_contact_submit'] ?? 0;
|
|
if ($lastSubmit && (time() - $lastSubmit) < 60) {
|
|
$formErrors[] = 'form.error.rate_limit';
|
|
}
|
|
|
|
if (empty($formErrors) && empty($formFieldErrors)) {
|
|
$interestKey = self::INTEREST_KEYS[$formData['interest']] ?? 'form.interest.visit';
|
|
$interestLabel = I18n::t($interestKey, [], $locale);
|
|
|
|
$to = 'mki@kies-media.de';
|
|
$subject = 'Kontaktanfrage: ' . $interestLabel;
|
|
$body = sprintf(
|
|
"Von: %s %s\nE-Mail: %s\n%sAnliegen: %s\n\n%s",
|
|
$formData['fname'],
|
|
$formData['lname'],
|
|
$formData['email'],
|
|
$formData['phone'] !== '' ? "Telefon: {$formData['phone']}\n" : '',
|
|
$interestLabel,
|
|
$formData['message']
|
|
);
|
|
|
|
$headers = "From: {$formData['email']}\r\n";
|
|
$headers .= "Reply-To: {$formData['email']}\r\n";
|
|
$headers .= "Content-Type: text/plain; charset=UTF-8\r\n";
|
|
$headers .= "X-Mailer: PHP/" . phpversion();
|
|
|
|
if (mail($to, $subject, $body, $headers)) {
|
|
$_SESSION['last_contact_submit'] = time();
|
|
$_SESSION['form_success'] = true;
|
|
header('Location: /#kontakt');
|
|
exit;
|
|
}
|
|
|
|
$formErrors[] = 'form.error.send_failed';
|
|
}
|
|
|
|
$_SESSION['form_errors'] = $formErrors;
|
|
$_SESSION['form_field_errors'] = $formFieldErrors;
|
|
$_SESSION['form_data'] = $formData;
|
|
header('Location: /#kontakt');
|
|
exit;
|
|
}
|
|
|
|
// ── Structured data (JSON-LD) — localized ────────────────────
|
|
$structuredData = json_encode([
|
|
'@context' => 'https://schema.org',
|
|
'@type' => 'RealEstateListing',
|
|
'name' => I18n::t('structured.listing_name', [], $locale),
|
|
'description'=> I18n::t('structured.listing_description', [], $locale),
|
|
'url' => I18n::t('site.canonical_base', [], $locale) . '/',
|
|
'image' => I18n::t('site.canonical_base', [], $locale) . '/bilder/Außenansicht-2.png',
|
|
'datePosted' => '2026-05-14',
|
|
'address' => [
|
|
'@type' => 'PostalAddress',
|
|
'streetAddress' => I18n::t('address.street', [], $locale),
|
|
'addressLocality' => I18n::t('address.city', [], $locale),
|
|
'postalCode' => '98553',
|
|
'addressCountry' => 'DE',
|
|
],
|
|
'offers' => [
|
|
'@type' => 'Offer',
|
|
'price' => '1300',
|
|
'priceCurrency' => 'EUR',
|
|
'priceSpecification' => [
|
|
'@type' => 'UnitPriceSpecification',
|
|
'price' => '1300',
|
|
'priceCurrency' => 'EUR',
|
|
'unitCode' => 'MON',
|
|
'description' => I18n::t('structured.price_description', [], $locale),
|
|
],
|
|
],
|
|
'floorSize' => [
|
|
'@type' => 'QuantitativeValue',
|
|
'value' => '227',
|
|
'unitCode' => 'MTK',
|
|
],
|
|
'numberOfRooms' => [
|
|
'@type' => 'QuantitativeValue',
|
|
'value' => '6',
|
|
],
|
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
|
|
$this->render('home/index', [
|
|
'formSuccess' => $formSuccess,
|
|
'formErrors' => $formErrors,
|
|
'formFieldErrors' => $formFieldErrors,
|
|
'formData' => $formData,
|
|
'interestKeys' => self::INTEREST_KEYS,
|
|
'escapeContactValue' => $escapeContactValue,
|
|
'structuredData' => $structuredData,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @return array{fname: string, lname: string, email: string, phone: string, interest: string, message: string}
|
|
*/
|
|
private static function emptyFormData(): array
|
|
{
|
|
return [
|
|
'fname' => '',
|
|
'lname' => '',
|
|
'email' => '',
|
|
'phone' => '',
|
|
'interest' => 'visit',
|
|
'message' => '',
|
|
];
|
|
}
|
|
}
|