Files

345 lines
12 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
require_once __DIR__ . '/app.php';
app_send_security_headers();
header('Cache-Control: no-store');
function renderPage(string $title, string $messageHtml, bool $isError = false, int $httpStatus = 200): void
{
http_response_code($httpStatus);
$logoPath = "img/silias-logo.png";
$statusClass = $isError
? "alert-danger border-0 shadow-sm"
: "alert-success border-0 shadow-sm";
$statusTitle = $isError ? "Fehler" : "Auftrag eingegangen";
echo '<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="css/bootstrap.css">
<link rel="stylesheet" href="css/custom.css?v=2.2">
<title>' . htmlspecialchars($title) . '</title>
<style>
.brand-card { border-radius: 1rem; }
.brand-box {
border: none;
border-radius: 1rem;
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
box-shadow: 0 10px 25px rgba(0,0,0,.04);
}
.brand-eyebrow {
font-size: .8rem;
letter-spacing: .04em;
text-transform: uppercase;
color: #6c757d;
margin-bottom: .25rem;
max-width: 420px;
}
</style>
</head>
<body class="bg-light">
<nav class="navbar navbar-light site-header border-bottom py-2">
<div class="container">
<a class="brand-lockup" href="index.php" aria-label="Silias Zefix Export Startseite">
<span class="brand-logo-crop" aria-hidden="true"><img src="img/silias-logo.png" alt=""></span>
<span class="brand-divider" aria-hidden="true"></span>
<span class="brand-product">Zefix Export<small>Ein kostenloses Silias-Tool</small></span>
</a>
</div>
</nav>
<main class="container py-4 py-md-5">
<div class="row justify-content-center">
<div class="col-12 col-md-10 col-lg-8 col-xl-7">
<div class="card shadow-sm brand-card">
<div class="card-body p-4 p-md-5">
<h1 class="h4 mb-3 d-flex align-items-center gap-2">
' . ($isError ? '<span class="text-danger">✖</span>' : '<span class="text-success">✔</span>') . '
' . htmlspecialchars($statusTitle) . '
</h1>
<div class="alert ' . $statusClass . ' mb-4">
' . $messageHtml . '
</div>
<div class="d-grid gap-2 d-sm-flex align-items-center">
<a class="btn btn-primary px-4" href="index.php">Zurück zur Startseite</a>
<a class="btn btn-link" href="https://www.silias.ch" target="_blank">Mehr über Silias →</a>
</div>
<hr class="my-4">
<div class="brand-box p-4">
<div class="d-flex align-items-start mb-3">
<div class="flex-shrink-0">
<span class="brand-logo-crop d-block">
<img src="' . htmlspecialchars($logoPath) . '" alt="Silias">
</span>
</div>
<div class="ms-4">
<div class="brand-eyebrow">
Ihr Partner für moderne IT-Infrastruktur & Automatisierung
</div>
<div class="fw-semibold fs-5">Silias KLG</div>
<div class="text-muted small">
IT, Automatisierung & individuelle Web-Lösungen
</div>
</div>
</div>
<div class="text-muted" style="font-size:.95rem;">
Wir bauen schlanke Tools wie dieses, automatisieren Prozesse und integrieren Systeme sauber in bestehende Umgebungen
(Hosting, Mail, ERP/CRM, Schnittstellen).<br><br>
Als Managed Service Provider betreuen wir KMU ganzheitlich von der Infrastruktur über Cloud,
Netzwerke und Telefonie bis zum laufenden Betrieb, Monitoring und persönlichen Support.
</div>
<div class="mt-3 d-flex flex-wrap gap-2">
<a class="btn btn-primary btn-sm" href="https://www.silias.ch" target="_blank">Website besuchen</a>
<a class="btn btn-outline-primary btn-sm" href="mailto:info@silias.ch">Kontakt</a>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<footer class="border-top bg-white">
<div class="container py-3 text-center text-muted small">
© ' . date('Y') . ' Silias KLG · <a href="datenschutz.php">Datenschutzerklärung</a>
</div>
</footer>
</body>
</html>';
}
/* ---------------- REQUEST AND ABUSE PROTECTION ---------------- */
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
header('Allow: POST');
renderPage('Methode nicht erlaubt', 'Bitte starten Sie den Export über das Formular auf der Startseite.', true, 405);
exit;
}
// Bots commonly fill fields that are deliberately hidden from real visitors.
$honeypot = isset($_POST['website']) && is_scalar($_POST['website']) ? trim((string)$_POST['website']) : '';
if ($honeypot !== '') {
renderPage(
'Auftrag eingegangen',
'Wir haben Ihren Auftrag erhalten. Sie erhalten von uns eine E-Mail, sobald die Daten zum Download bereit sind.',
false
);
exit;
}
if (!app_turnstile_is_configured()) {
renderPage('Export nicht verfügbar', 'Der Missbrauchsschutz ist noch nicht vollständig konfiguriert.', true, 503);
exit;
}
$clientIp = app_client_ip();
try {
$ipLimit = app_rate_limit(
'submit-ip',
$clientIp,
app_env_int('RATE_LIMIT_IP_MAX', 3, 1, 100),
app_env_int('RATE_LIMIT_IP_WINDOW_SECONDS', 900, 60, 86400)
);
} catch (Throwable $exception) {
error_log('Rate limit failure: ' . $exception->getMessage());
renderPage('Export nicht verfügbar', 'Der Export kann momentan nicht sicher verarbeitet werden. Bitte versuchen Sie es später erneut.', true, 503);
exit;
}
if (!$ipLimit['allowed']) {
header('Retry-After: ' . $ipLimit['retry_after']);
renderPage('Zu viele Anfragen', 'Von dieser Verbindung wurden zu viele Aufträge gesendet. Bitte versuchen Sie es später erneut.', true, 429);
exit;
}
$turnstileToken = isset($_POST['cf-turnstile-response']) && is_scalar($_POST['cf-turnstile-response'])
? (string)$_POST['cf-turnstile-response']
: '';
$turnstileResult = app_verify_turnstile($turnstileToken, $clientIp);
if (!$turnstileResult['success']) {
renderPage('Bot-Prüfung fehlgeschlagen', 'Die Sicherheitsprüfung konnte nicht bestätigt werden. Bitte laden Sie die Startseite neu und versuchen Sie es erneut.', true, 403);
exit;
}
$formStarted = isset($_POST['form_started']) && is_scalar($_POST['form_started']) ? (string)$_POST['form_started'] : '';
if (!app_validate_form_started_token($formStarted)) {
renderPage('Überprüfung fehlgeschlagen', 'Das Formular ist abgelaufen oder wurde zu schnell übermittelt. Bitte laden Sie die Startseite neu.', true, 400);
exit;
}
/* ---------------- INPUT VALIDATION ---------------- */
$emailValue = $_POST['email'] ?? '';
$email = is_scalar($emailValue) ? strtolower(trim((string)$emailValue)) : '';
if ($email === '' || strlen($email) > 254 || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
renderPage('Ungültige E-Mail', 'Bitte geben Sie eine gültige E-Mail-Adresse an.', true, 400);
exit;
}
$companyValue = $_POST['firma'] ?? '';
$companyName = is_scalar($companyValue) ? trim((string)$companyValue) : '';
$companyNameLength = function_exists('mb_strlen') ? mb_strlen($companyName) : strlen($companyName);
if ($companyNameLength > 200 || preg_match('//u', $companyName) !== 1 || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', $companyName)) {
renderPage('Ungültiger Firmenname', 'Der Firmenname enthält ungültige Zeichen oder ist zu lang.', true, 400);
exit;
}
$maximumSeats = app_env_int('MAX_SEATS_PER_JOB', 500, 1, 5000);
$maximumLegalForms = app_env_int('MAX_LEGAL_FORMS_PER_JOB', 50, 1, 200);
$seats = app_normalize_positive_id_list($_POST['sitze'] ?? null, $maximumSeats);
$legalForms = app_normalize_positive_id_list($_POST['rechtsformen'] ?? null, $maximumLegalForms);
if ($seats === null || $legalForms === null) {
renderPage('Ungültige Auswahl', 'Bitte wählen Sie eine zulässige Anzahl Orte und Rechtsformen aus.', true, 400);
exit;
}
sort($seats, SORT_NUMERIC);
sort($legalForms, SORT_NUMERIC);
$requestCount = count($seats) * count($legalForms);
$maximumRequests = app_env_int('MAX_REQUESTS_PER_JOB', 5000, 1, 50000);
if ($requestCount > $maximumRequests) {
renderPage(
'Auswahl zu gross',
'Diese Auswahl würde ' . number_format($requestCount, 0, ',', "'") . ' einzelne Abfragen erzeugen. Erlaubt sind maximal ' . number_format($maximumRequests, 0, ',', "'") . '. Bitte schränken Sie Orte oder Rechtsformen ein.',
true,
400
);
exit;
}
try {
$emailLimit = app_rate_limit(
'submit-email',
$email,
app_env_int('RATE_LIMIT_EMAIL_MAX', 5, 1, 100),
app_env_int('RATE_LIMIT_EMAIL_WINDOW_SECONDS', 86400, 300, 604800)
);
} catch (Throwable $exception) {
error_log('Rate limit failure: ' . $exception->getMessage());
renderPage('Export nicht verfügbar', 'Der Export kann momentan nicht sicher verarbeitet werden. Bitte versuchen Sie es später erneut.', true, 503);
exit;
}
if (!$emailLimit['allowed']) {
header('Retry-After: ' . $emailLimit['retry_after']);
renderPage('Zu viele Anfragen', 'Für diese E-Mail-Adresse wurden heute bereits mehrere Exporte angefordert.', true, 429);
exit;
}
/* ---------------- TASK BUILD AND ATOMIC QUEUE WRITE ---------------- */
$baseRequest = [
'maxEntries' => 50,
'offset' => 0,
'languageKey' => 'de',
'deletedFirms' => isset($_POST['geloeschteRechtseinheiten']),
'searchType' => 'exact',
];
if ($companyName !== '') {
$baseRequest['name'] = $companyName;
}
$requestsToDo = [];
foreach ($legalForms as $legalForm) {
foreach ($seats as $seat) {
$request = $baseRequest;
$request['legalForms'] = [$legalForm];
$request['legalSeats'] = [$seat];
$requestsToDo[] = $request;
}
}
$fingerprint = hash('sha256', json_encode([
'email' => $email,
'company' => $companyName,
'deleted' => $baseRequest['deletedFirms'],
'seats' => $seats,
'legalForms' => $legalForms,
], JSON_THROW_ON_ERROR));
try {
$taskDirectory = app_task_dir();
$queueLock = fopen(app_private_dir() . DIRECTORY_SEPARATOR . 'queue.lock', 'c+');
if ($queueLock === false || !flock($queueLock, LOCK_EX)) {
throw new RuntimeException('Queue lock could not be acquired.');
}
try {
$taskFiles = glob($taskDirectory . DIRECTORY_SEPARATOR . '*.json') ?: [];
$maximumQueueSize = app_env_int('MAX_QUEUE_SIZE', 20, 1, 1000);
if (count($taskFiles) >= $maximumQueueSize) {
renderPage('Warteschlange voll', 'Momentan werden bereits viele Exporte verarbeitet. Bitte versuchen Sie es später erneut.', true, 503);
exit;
}
foreach ($taskFiles as $existingTaskFile) {
$existing = json_decode((string)file_get_contents($existingTaskFile), true);
if (is_array($existing) && hash_equals((string)($existing['fingerprint'] ?? ''), $fingerprint)) {
renderPage(
'Auftrag bereits vorhanden',
'Ein identischer Auftrag wartet bereits auf die Verarbeitung. Sie erhalten den Download-Link per E-Mail.',
false
);
exit;
}
}
$taskData = [
'version' => 2,
'createdAt' => time(),
'requests' => $requestsToDo,
'email' => $email,
'fingerprint' => $fingerprint,
'downloadToken' => bin2hex(random_bytes(16)),
];
$taskFilename = $taskDirectory . DIRECTORY_SEPARATOR . time() . '-' . bin2hex(random_bytes(16)) . '.json';
$temporaryFilename = $taskFilename . '.tmp';
$written = file_put_contents($temporaryFilename, json_encode($taskData, JSON_THROW_ON_ERROR), LOCK_EX);
if ($written === false || !rename($temporaryFilename, $taskFilename)) {
@unlink($temporaryFilename);
throw new RuntimeException('Task file could not be written.');
}
@chmod($taskFilename, 0600);
} finally {
flock($queueLock, LOCK_UN);
fclose($queueLock);
}
renderPage(
'Auftrag eingegangen',
'Wir haben Ihren Auftrag erhalten. Sie erhalten von uns eine E-Mail, sobald die Daten zum Download bereit sind. Dies kann je nach Datenmenge länger dauern.',
false
);
} catch (Throwable $exception) {
error_log('Task creation failed: ' . $exception->getMessage());
renderPage('Fehler', 'Der Auftrag konnte momentan nicht gespeichert werden. Bitte versuchen Sie es später erneut.', true, 503);
}