From 4b9a5d7e788a993040bd1bfd1ee7a147ee15940b Mon Sep 17 00:00:00 2001 From: silisio Date: Wed, 22 Jul 2026 08:54:09 +0200 Subject: [PATCH] Add Turnstile protection and harden export workflow --- .gitattributes | 1 + .gitignore | 6 + README.md | 104 ++++++++++- app.php | 451 +++++++++++++++++++++++++++++++++++++++++++++++ cleanup.php | 27 +++ cronjobs.sh | 3 +- css/custom.css | 182 ++++++++++++++++++- datenschutz.php | 136 ++++++++++++++ deleteFiles.php | 28 ++- download.php | 47 +++++ emailSender.php | 52 +++--- gemeinden.php | 8 +- index.php | 230 ++++++++++++++++-------- legalForms.php | 8 +- submit.php | 277 +++++++++++++++++++++-------- taskExecuter.php | 241 ++++++++++++++++--------- zefixAPI.php | 47 +++-- 17 files changed, 1572 insertions(+), 276 deletions(-) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 app.php create mode 100644 cleanup.php create mode 100644 datenschutz.php create mode 100644 download.php diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfdb8b7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ce3f992 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/env_vars.php +/tasks/ +/download/ +/var/ +/*.log +/.phpunit.cache/ diff --git a/README.md b/README.md index d8cd913..157103a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,104 @@ -# Zefix_search +# Silias ZEFIX Export +Ein PHP-Dienst, der ZEFIX-Suchaufträge asynchron verarbeitet und den fertigen CSV-Export über einen zeitlich begrenzten Download-Link zustellt. + +Vorausgesetzt werden PHP 8.1 oder neuer, die Erweiterungen cURL und JSON sowie ein korrekt konfigurierter CA-Zertifikatsspeicher für die TLS-Prüfung von Cloudflare, ZEFIX und SMTP. + +## Missbrauchsschutz + +Der Export ist standardmässig **fail-closed**: Ohne vollständig konfigurierte Cloudflare-Turnstile-Schlüssel bleibt der Absende-Button deaktiviert und `submit.php` nimmt keine Aufträge an. + +Aktivierte Schutzschichten: + +- Cloudflare Turnstile im Managed-Modus, inklusive serverseitiger Siteverify-Prüfung +- Hostname- und Action-Prüfung für Turnstile-Tokens +- signierte Formularzeit und Honeypot-Feld ohne Session-Cookie +- Rate-Limits pro IP-Adresse und E-Mail-Adresse +- harte Grenzen für Orte, Rechtsformen, resultierende API-Aufrufe und Queue-Grösse +- Deduplizierung identischer offener Aufträge +- private Speicherung von Auftragsdaten und Exportdateien ausserhalb des Webroots +- 128-Bit-Download-Token und automatische Ablaufzeit +- CLI-Sperre für Executor, Cleanup und administratives Löschen +- eintägiger Cache für Gemeinden und Rechtsformen + +## Erforderliche Umgebungsvariablen + +| Variable | Bedeutung | +| --- | --- | +| `TURNSTILE_SECRET` | Geheimer Schlüssel des bestehenden Turnstile-Widgets für Siteverify | +| `APP_SECRET` | Zufälliger geheimer Wert für Formulartoken und pseudonymisierte Rate-Limits | +| `ZEFIX_PRIVATE_DIR` | Absoluter, dauerhaft beschreibbarer Pfad **ausserhalb** des Webroots | +| `username` | Benutzername der ZEFIX-API | +| `password` | Passwort der ZEFIX-API | +| `smtppassword` | Passwort des SMTP-Kontos | + +Verwendet wird ausschliesslich das bestehende Turnstile-Widget `Zefix` mit dem Sitekey `0x4AAAAAAD7CJrWpNiK0-A7h`. Es darf nicht neu erstellt oder rotiert werden. Das Widget muss im Cloudflare-Dashboard als **Managed** mit dem Hostnamen `zefix.silias.ch` konfiguriert sein; Pre-Clearance bleibt deaktiviert. Das Secret gehört ausschliesslich als `TURNSTILE_SECRET` in die Serverumgebung und niemals ins Repository. + +Empfohlene Werte: + +```text +TURNSTILE_ALLOWED_HOSTNAME=zefix.silias.ch +PUBLIC_BASE_URL=https://zefix.silias.ch +ZEFIX_PRIVATE_DIR=/var/lib/zefix-export +APP_SECRET= +``` + +Das bisherige, nicht versionierte `env_vars.php` wird für eine schonende Migration weiterhin geladen. Neue Installationen sollten echte Prozess-/PHP-FPM-Umgebungsvariablen verwenden. + +## Optionale Konfiguration + +| Variable | Standard | Bedeutung | +| --- | ---: | --- | +| `RATE_LIMIT_IP_MAX` | `3` | Aufträge pro IP-Zeitfenster | +| `RATE_LIMIT_IP_WINDOW_SECONDS` | `900` | IP-Zeitfenster | +| `RATE_LIMIT_EMAIL_MAX` | `5` | Aufträge pro E-Mail-Zeitfenster | +| `RATE_LIMIT_EMAIL_WINDOW_SECONDS` | `86400` | E-Mail-Zeitfenster | +| `MAX_SEATS_PER_JOB` | `500` | maximale Orte pro Auftrag | +| `MAX_LEGAL_FORMS_PER_JOB` | `50` | maximale Rechtsformen pro Auftrag | +| `MAX_REQUESTS_PER_JOB` | `5000` | maximales Produkt aus Orten × Rechtsformen | +| `MAX_QUEUE_SIZE` | `20` | maximale offene Aufträge | +| `DOWNLOAD_RETENTION_HOURS` | `48` | Gültigkeit fertiger Exporte | +| `STALE_TASK_RETENTION_HOURS` | `168` | maximale Lebensdauer offener Aufträge | +| `REFERENCE_CACHE_SECONDS` | `86400` | Cache für Gemeinden und Rechtsformen | +| `TRUSTED_PROXY_IPS` | leer | kommaseparierte IPs eigener Reverse-Proxies | +| `EXPORT_BCC_EMAIL` | leer | optionale BCC-Adresse; standardmässig kein BCC | +| `APP_ENV` | leer | nur lokal auf `development` setzen; erlaubt die offiziellen Turnstile-Testschlüssel auf Loopback | +| `SMTP_HOST` | `mxe98c.netcup.net` | SMTP-Server | +| `SMTP_USERNAME` | `info@silias.ch` | SMTP-Benutzer | +| `SMTP_SECURE` | `ssl` | SMTP-Verschlüsselung | +| `SMTP_PORT` | `465` | SMTP-Port | + +`TRUSTED_PROXY_IPS` darf nur tatsächlich kontrollierte Proxy-Adressen enthalten. Ohne Eintrag werden vom Browser gelieferte `X-Forwarded-For`- oder `CF-Connecting-IP`-Header bewusst ignoriert. + +## Cronjob + +`cronjobs.sh` führt zuerst die automatische Bereinigung und danach den Task-Executor aus. Beide PHP-Skripte akzeptieren ausschliesslich CLI-Aufrufe. + +Beispiel: + +```cron +*/2 * * * * /absoluter/pfad/cronjobs.sh +``` + +Ein manueller Cleanup kann mit `php cleanup.php` gestartet werden. Alle offenen Aufträge werden nur nach ausdrücklicher Bestätigung gelöscht: + +```bash +php deleteFiles.php --confirm +``` + +## Nginx-Härtung + +Die PHP-Skripte sperren Webzugriffe selbst. Zusätzlich sollten Altpfade und Wartungsskripte bereits in Nginx blockiert werden: + +```nginx +location ~ ^/(taskExecuter|cleanup|deleteFiles)\.php$ { return 404; } +location ^~ /tasks/ { return 404; } +location ^~ /download/ { return 404; } +location = /env_vars.php { return 404; } +``` + +Nach dem ersten CLI-Lauf werden alte JSON-Aufträge aus `tasks/` in den privaten Speicher migriert. Anschliessend sollten die alten öffentlichen Verzeichnisse `tasks/` und `download/` nach Kontrolle entfernt werden. + +## Datenschutz + +Die öffentliche Datenschutzerklärung liegt unter `datenschutz.php`. Sie beschreibt Turnstile, die verarbeiteten Daten und die implementierten Löschfristen. Wenn später Analyse-, Marketing- oder weitere Drittanbieter-Dienste ergänzt werden, müssen Text und gegebenenfalls die Einwilligungsverwaltung erneut geprüft werden. diff --git a/app.php b/app.php new file mode 100644 index 0000000..c8c117c --- /dev/null +++ b/app.php @@ -0,0 +1,451 @@ += $minimumSeconds && $age <= $maximumSeconds; +} + +function app_client_ip(): string +{ + $remoteAddress = $_SERVER['REMOTE_ADDR'] ?? ''; + if (!filter_var($remoteAddress, FILTER_VALIDATE_IP)) { + $remoteAddress = 'unknown'; + } + + $trustedProxies = array_values(array_filter(array_map( + 'trim', + explode(',', app_env('TRUSTED_PROXY_IPS')) + ))); + + if (!in_array($remoteAddress, $trustedProxies, true)) { + return $remoteAddress; + } + + $candidates = []; + if (isset($_SERVER['HTTP_CF_CONNECTING_IP'])) { + $candidates[] = trim((string)$_SERVER['HTTP_CF_CONNECTING_IP']); + } + if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) { + $forwarded = explode(',', (string)$_SERVER['HTTP_X_FORWARDED_FOR']); + $candidates[] = trim($forwarded[0]); + } + + foreach ($candidates as $candidate) { + if (filter_var($candidate, FILTER_VALIDATE_IP)) { + return $candidate; + } + } + + return $remoteAddress; +} + +/** @return array{allowed: bool, retry_after: int} */ +function app_rate_limit(string $scope, string $identity, int $limit, int $windowSeconds): array +{ + $now = time(); + $secret = app_security_secret(); + $hash = $secret !== '' + ? hash_hmac('sha256', strtolower($identity), $secret) + : hash('sha256', strtolower($identity)); + $filename = app_rate_limit_dir() . DIRECTORY_SEPARATOR . preg_replace('/[^a-z0-9_-]/i', '_', $scope) . '-' . $hash . '.json'; + $handle = fopen($filename, 'c+'); + if ($handle === false) { + throw new RuntimeException('Rate-limit storage could not be opened.'); + } + + try { + if (!flock($handle, LOCK_EX)) { + throw new RuntimeException('Rate-limit storage could not be locked.'); + } + + $contents = stream_get_contents($handle); + $state = $contents !== false && $contents !== '' ? json_decode($contents, true) : null; + if (!is_array($state) || !isset($state['started'], $state['count']) || ($now - (int)$state['started']) >= $windowSeconds) { + $state = ['started' => $now, 'count' => 0]; + } + + $allowed = (int)$state['count'] < $limit; + if ($allowed) { + $state['count'] = (int)$state['count'] + 1; + } + + rewind($handle); + ftruncate($handle, 0); + fwrite($handle, json_encode($state, JSON_THROW_ON_ERROR)); + fflush($handle); + @chmod($filename, 0600); + flock($handle, LOCK_UN); + + return [ + 'allowed' => $allowed, + 'retry_after' => max(1, $windowSeconds - ($now - (int)$state['started'])), + ]; + } finally { + fclose($handle); + } +} + +/** @return array{success: bool, error: string} */ +function app_verify_turnstile(string $token, string $remoteIp): array +{ + $secret = app_turnstile_secret_key(); + if ($secret === '' || $token === '' || strlen($token) > 2048) { + return ['success' => false, 'error' => 'missing-input']; + } + + $curl = curl_init('https://challenges.cloudflare.com/turnstile/v0/siteverify'); + if ($curl === false) { + return ['success' => false, 'error' => 'internal-error']; + } + + $payload = [ + 'secret' => $secret, + 'response' => $token, + ]; + if ($remoteIp !== 'unknown') { + $payload['remoteip'] = $remoteIp; + } + + curl_setopt_array($curl, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => http_build_query($payload), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_TIMEOUT => 10, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'], + ]); + + $response = curl_exec($curl); + $httpCode = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE); + $curlError = curl_error($curl); + curl_close($curl); + + if (!is_string($response) || $httpCode !== 200) { + error_log('Turnstile request failed: HTTP ' . $httpCode . ($curlError !== '' ? ' / ' . $curlError : '')); + return ['success' => false, 'error' => 'verification-unavailable']; + } + + $result = json_decode($response, true); + if (!is_array($result) || ($result['success'] ?? false) !== true) { + $errors = is_array($result['error-codes'] ?? null) ? implode(',', $result['error-codes']) : 'invalid-response'; + error_log('Turnstile validation rejected: ' . $errors); + return ['success' => false, 'error' => 'challenge-failed']; + } + + $officialTestSecrets = [ + '1x0000000000000000000000000000000AA', + '2x0000000000000000000000000000000AA', + '3x0000000000000000000000000000000AA', + ]; + $isLocalDevelopmentTest = app_env('APP_ENV') === 'development' + && in_array($remoteIp, ['127.0.0.1', '::1'], true) + && in_array($secret, $officialTestSecrets, true); + + // Cloudflare's dummy testing responses do not always contain the widget's + // hostname and action. This exception is restricted to loopback requests, + // development mode and Cloudflare's documented test secrets. + if ($isLocalDevelopmentTest) { + return ['success' => true, 'error' => '']; + } + + $allowedHostname = strtolower(app_env('TURNSTILE_ALLOWED_HOSTNAME', 'zefix.silias.ch')); + $hostname = strtolower((string)($result['hostname'] ?? '')); + if ($allowedHostname !== '' && !hash_equals($allowedHostname, $hostname)) { + error_log('Turnstile hostname mismatch.'); + return ['success' => false, 'error' => 'hostname-mismatch']; + } + + if (($result['action'] ?? '') !== 'turnstile-spin-v2') { + error_log('Turnstile action mismatch.'); + return ['success' => false, 'error' => 'action-mismatch']; + } + + return ['success' => true, 'error' => '']; +} + +/** @return int[]|null */ +function app_normalize_positive_id_list(mixed $value, int $maximumItems): ?array +{ + if (!is_array($value) || $value === [] || count($value) > $maximumItems) { + return null; + } + + $normalized = []; + foreach ($value as $item) { + if (is_array($item) || !is_scalar($item)) { + return null; + } + $item = (string)$item; + if (!ctype_digit($item)) { + return null; + } + $integer = (int)$item; + if ($integer <= 0) { + return null; + } + $normalized[$integer] = $integer; + } + + return array_values($normalized); +} + +function app_public_base_url(): string +{ + return rtrim(app_env('PUBLIC_BASE_URL', 'https://zefix.silias.ch'), '/'); +} + +function app_download_path(string $token): string +{ + if (!preg_match('/^[a-f0-9]{32}$/', $token)) { + throw new InvalidArgumentException('Invalid download token.'); + } + return app_download_dir() . DIRECTORY_SEPARATOR . $token . '.csv'; +} + +function app_cached_string(string $key, int $ttlSeconds, callable $loader): string +{ + $safeKey = preg_replace('/[^a-z0-9_-]/i', '_', $key); + $filename = app_cache_dir() . DIRECTORY_SEPARATOR . $safeKey . '.cache'; + if (is_file($filename) && filemtime($filename) !== false && filemtime($filename) >= time() - $ttlSeconds) { + $cached = file_get_contents($filename); + if (is_string($cached) && $cached !== '') { + return $cached; + } + } + + $lock = fopen($filename . '.lock', 'c+'); + if ($lock === false || !flock($lock, LOCK_EX)) { + if (is_resource($lock)) { + fclose($lock); + } + return (string)$loader(); + } + + try { + clearstatcache(true, $filename); + if (is_file($filename) && filemtime($filename) !== false && filemtime($filename) >= time() - $ttlSeconds) { + return (string)file_get_contents($filename); + } + + $fresh = (string)$loader(); + if ($fresh !== '') { + $temporary = $filename . '.' . bin2hex(random_bytes(4)) . '.tmp'; + file_put_contents($temporary, $fresh, LOCK_EX); + @chmod($temporary, 0600); + if (!@rename($temporary, $filename)) { + // Windows cannot atomically replace an existing file with rename(). + @unlink($filename); + if (!@rename($temporary, $filename)) { + @unlink($temporary); + } + } + return $fresh; + } + + return is_file($filename) ? (string)file_get_contents($filename) : ''; + } finally { + flock($lock, LOCK_UN); + fclose($lock); + } +} + +function app_cleanup_directory(string $directory, int $maximumAgeSeconds, string $extension): int +{ + if (!is_dir($directory)) { + return 0; + } + + $deleted = 0; + $cutoff = time() - $maximumAgeSeconds; + foreach (glob($directory . DIRECTORY_SEPARATOR . '*.' . $extension) ?: [] as $filename) { + $modified = filemtime($filename); + if ($modified !== false && $modified < $cutoff && is_file($filename) && unlink($filename)) { + $deleted++; + } + } + return $deleted; +} + +function app_migrate_legacy_tasks(): int +{ + $legacyDirectory = __DIR__ . DIRECTORY_SEPARATOR . 'tasks'; + if (!is_dir($legacyDirectory)) { + return 0; + } + + $migrated = 0; + foreach (glob($legacyDirectory . DIRECTORY_SEPARATOR . '*.json') ?: [] as $source) { + $target = app_task_dir() . DIRECTORY_SEPARATOR . basename($source); + if (is_file($target)) { + continue; + } + if (@rename($source, $target) || (@copy($source, $target) && @unlink($source))) { + @chmod($target, 0600); + $migrated++; + } + } + return $migrated; +} diff --git a/cleanup.php b/cleanup.php new file mode 100644 index 0000000..f7917aa --- /dev/null +++ b/cleanup.php @@ -0,0 +1,27 @@ +> "$LOG_FILE"; exit 1; } start_ts=$(date +%s) +$PHP_BIN "$BASE_DIR/cleanup.php" >> "$LOG_FILE" 2>&1 $PHP_BIN "$BASE_DIR/taskExecuter.php" >> "$LOG_FILE" 2>&1 status=$? @@ -23,4 +24,4 @@ echo "Duration: ${duration}s" >> "$LOG_FILE" echo "CRON END $(date '+%Y-%m-%d %H:%M:%S')" >> "$LOG_FILE" echo "" >> "$LOG_FILE" -exit $status \ No newline at end of file +exit $status diff --git a/css/custom.css b/css/custom.css index a743c8b..cd77d37 100644 --- a/css/custom.css +++ b/css/custom.css @@ -3,23 +3,104 @@ :root { --silias-radius: 14px; + --silias-ink: #0b1236; + --silias-muted: #667085; + --silias-primary: #3b4cca; + --silias-primary-dark: #25339b; + --silias-surface: #ffffff; + --silias-soft: #f3f5ff; + --silias-border: #e4e8f2; } html, body { - height: 100%; + min-height: 100%; } body { /* Bootstrap uses system font stack, keep it */ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + color: var(--silias-ink); + background: + radial-gradient(circle at 8% 0%, rgba(90, 103, 216, .12), transparent 28rem), + #f7f8fc !important; + background-repeat: no-repeat !important; + background-size: 100% 720px !important; } /* Navbar */ +.site-header { + background: rgba(255, 255, 255, .94); + backdrop-filter: blur(12px); +} + .navbar-brand { letter-spacing: 0.2px; } +.brand-lockup { + display: inline-flex; + align-items: center; + gap: .85rem; + color: var(--silias-ink); + text-decoration: none; +} + +.brand-logo-crop { + position: relative; + width: 126px; + height: 45px; + overflow: hidden; + flex: 0 0 auto; +} + +.brand-logo-crop img { + position: absolute; + left: -2%; + top: 50%; + width: 104%; + height: auto; + transform: translateY(-50%); +} + +.brand-divider { + width: 1px; + height: 28px; + background: var(--silias-border); +} + +.brand-product { + font-size: .9rem; + font-weight: 700; + line-height: 1.15; +} + +.brand-product small { + display: block; + margin-top: .18rem; + color: var(--silias-muted); + font-size: .72rem; + font-weight: 500; +} + +.hero-eyebrow { + color: var(--silias-primary); + font-size: .78rem; + font-weight: 800; + letter-spacing: .09em; + text-transform: uppercase; +} + +.hero-title { + max-width: 760px; + letter-spacing: -.025em; +} + +.hero-copy { + max-width: 720px; + color: var(--silias-muted); +} + /* Card polish */ .card { border-radius: var(--silias-radius); @@ -28,6 +109,61 @@ body { box-shadow: 0 10px 25px rgba(0,0,0,.06) !important; } +.export-shell { + border: 1px solid rgba(228, 232, 242, .9); + box-shadow: 0 18px 48px rgba(21, 31, 80, .09) !important; +} + +.form-section { + padding: 1.25rem; + border: 1px solid var(--silias-border); + border-radius: 1rem; + background: var(--silias-surface); +} + +.section-heading { + display: flex; + align-items: flex-start; + gap: .8rem; + margin-bottom: 1rem; +} + +.section-number { + display: inline-grid; + width: 2rem; + height: 2rem; + place-items: center; + flex: 0 0 auto; + border-radius: .65rem; + background: var(--silias-soft); + color: var(--silias-primary-dark); + font-weight: 800; +} + +.section-heading h2 { + margin: 0; + font-size: 1.05rem; +} + +.section-heading p { + margin: .2rem 0 0; + color: var(--silias-muted); + font-size: .9rem; +} + +.selection-meta { + color: var(--silias-muted); + font-size: .84rem; + font-weight: 600; +} + +.privacy-note { + padding: .9rem 1rem; + border: 1px solid var(--silias-border); + border-radius: .85rem; + background: #fafbff; +} + /* Form */ .form-label { font-weight: 600; @@ -44,7 +180,8 @@ body { .form-control:focus, .form-select:focus { - box-shadow: 0 0 0 .2rem rgba(13,110,253,.15); + border-color: #7c89e8; + box-shadow: 0 0 0 .2rem rgba(59, 76, 202, .14); } /* Buttons: DO NOT globally style "button" tag */ @@ -56,17 +193,31 @@ body { padding-bottom: .85rem; } +.btn-primary { + border-color: var(--silias-primary); + background: var(--silias-primary); +} + +.btn-primary:hover, +.btn-primary:focus { + border-color: var(--silias-primary-dark); + background: var(--silias-primary-dark); +} + /* Scroll panels (if you keep class from index.php) */ .scrollWindow { border-radius: 1rem; + scrollbar-color: #b8bfdc transparent; + scrollbar-width: thin; } /* Checkbox rows */ .form-check { - padding: .25rem 0; + padding: .3rem 0 .3rem 1.75rem; } .form-check-input { cursor: pointer; + margin-left: -1.75rem; } .form-check-label { cursor: pointer; @@ -81,6 +232,11 @@ body { footer { margin-top: 0; } + +.site-footer { + color: var(--silias-muted); + background: #fff; +} footer a { text-decoration: none; } @@ -115,10 +271,24 @@ footer a:hover { .card-body { padding: 1.25rem !important; } + + .brand-logo-crop { + width: 104px; + height: 38px; + } + + .brand-divider, + .brand-product small { + display: none; + } + + .form-section { + padding: 1rem; + } } /* Fix: checkboxes not clipped and never overlap text */ .scrollWindow .form-check { - padding-left: 0 !important; /* remove bootstrap offset */ + padding: .6rem 1.1rem !important; margin: 0 !important; } @@ -129,6 +299,6 @@ footer a:hover { .scrollWindow .form-check-label { display: block; - padding-left: 1.6rem; /* space for the checkbox */ + padding-left: 1.9rem; line-height: 1.4; -} \ No newline at end of file +} diff --git a/datenschutz.php b/datenschutz.php new file mode 100644 index 0000000..1d5fcec --- /dev/null +++ b/datenschutz.php @@ -0,0 +1,136 @@ + + + + + + + + + Datenschutzerklärung – Silias Zefix Export + + + + +
+
+
+
+
+

Datenschutzerklärung

+

Stand: 22. Juli 2026

+ +

1. Verantwortlicher

+

+ Silias KLG
+ Toggenburgstrasse 31
+ 8245 Feuerthalen, Schweiz
+ E-Mail: info@silias.ch +

+ +

2. Zweck und Umfang der Datenbearbeitung

+

+ Der Dienst erstellt auf Ihren Wunsch einen Export aus dem Zentralen Firmenindex ZEFIX und sendet + den zugehörigen Download-Link an die von Ihnen angegebene E-Mail-Adresse. Dafür bearbeiten wir die + E-Mail-Adresse, die ausgewählten Suchparameter, den Zeitpunkt des Auftrags sowie technisch notwendige + Sicherheits- und Protokolldaten. +

+

+ Die Bearbeitung dient der Bereitstellung des angeforderten Exports, der Betriebssicherheit sowie der + Erkennung und Verhinderung automatisierter oder missbräuchlicher Anfragen. Soweit die DSGVO anwendbar + ist, stützen wir diese Bearbeitung auf die Durchführung Ihrer Anfrage sowie unser berechtigtes Interesse + an einem sicheren und zuverlässigen Betrieb. +

+ +

3. Cloudflare Turnstile

+

+ Zum Schutz des Exportformulars verwenden wir Cloudflare Turnstile im verwalteten Modus. Dabei werden + technisch notwendige Signale wie IP-Adresse, TLS-Fingerprint, User-Agent, Website-Herkunft und + Sicherheitsmerkmale des Browsers an Cloudflare übermittelt. Die Daten werden zur Unterscheidung zwischen + Menschen und automatisierten Zugriffen verwendet. Wir verwenden Turnstile nicht zu Werbe- oder + Marketingzwecken und haben die Pre-Clearance-Funktion deaktiviert. +

+

+ Anbieter ist Cloudflare, Inc., 101 Townsend Street, San Francisco, CA 94107, USA. Cloudflare kann Daten + auch ausserhalb der Schweiz bearbeiten. Informationen zu Zweck, Rollenverteilung und internationalen + Übermittlungen finden Sie im + Turnstile Privacy Addendum, + in der Datenschutzerklärung von Cloudflare + und im Data Processing Addendum. +

+ +

4. Cookies und ähnliche Technologien

+

+ Diese Website verwendet keine Analyse- oder Marketing-Cookies. Turnstile kann technisch notwendige + Cookies oder lokalen Browserspeicher zur Sicherheitsprüfung einsetzen. Diese Sicherheitsfunktionen sind + für die Nutzung des öffentlich zugänglichen Exportformulars erforderlich. Die Cloudflare-Funktion + «Pre-Clearance», die ein cf_clearance-Cookie auf unserer Domain setzen würde, ist deaktiviert. +

+ +

5. Empfänger und Datenquellen

+

+ Daten erhalten nur Dienstleister, soweit dies für Hosting, E-Mail-Versand, Missbrauchsschutz oder Betrieb + erforderlich ist. Die ausgewählten Suchkriterien werden serverseitig an die ZEFIX-Schnittstelle des + Bundesamts für Justiz übermittelt. Ihre E-Mail-Adresse wird nicht an ZEFIX übermittelt. Eine Weitergabe + zu Werbezwecken findet nicht statt. +

+ +

6. Aufbewahrungsdauer

+
    +
  • Offene Aufträge einschliesslich E-Mail-Adresse: bis zur Verarbeitung, spätestens Stunden.
  • +
  • Fertige Exportdateien: Stunden nach ihrer Erstellung.
  • +
  • Pseudonymisierte Rate-Limit-Einträge: höchstens acht Tage.
  • +
  • Technische Serverprotokolle: nur so lange, wie dies für Sicherheit, Fehleranalyse und Betrieb erforderlich ist.
  • +
+

Nach Ablauf der jeweiligen Frist werden die Daten automatisch gelöscht oder überschrieben.

+ +

7. Datensicherheit

+

+ Die Übertragung erfolgt verschlüsselt über HTTPS. Auftragsdaten und Exportdateien werden ausserhalb des + öffentlichen Webverzeichnisses gespeichert. Download-Links enthalten ein zufälliges Zugriffstoken und + sind zeitlich begrenzt. Bitte leiten Sie einen Download-Link nicht an unbefugte Personen weiter. +

+ +

8. Ihre Rechte

+

+ Im Rahmen des anwendbaren Datenschutzrechts können Sie Auskunft, Berichtigung, Löschung oder + Einschränkung der Bearbeitung verlangen sowie einer Bearbeitung widersprechen. Zur Ausübung Ihrer Rechte + kontaktieren Sie uns unter info@silias.ch. Sie können sich ausserdem + an den Eidgenössischen Datenschutz- und Öffentlichkeitsbeauftragten oder eine andere zuständige + Datenschutzaufsichtsbehörde wenden. +

+ +

9. Änderungen

+

+ Wir können diese Datenschutzerklärung anpassen, wenn sich der Dienst oder die rechtlichen Anforderungen + ändern. Es gilt die jeweils auf dieser Seite veröffentlichte Fassung. +

+ + Zurück zur Startseite +
+
+
+
+
+ +
+
+ © Silias KLG +
+
+ + diff --git a/deleteFiles.php b/deleteFiles.php index 375fc37..b3655bf 100644 --- a/deleteFiles.php +++ b/deleteFiles.php @@ -1,13 +1,23 @@ \ No newline at end of file +fwrite(STDOUT, $deleted . " pending task(s) deleted.\n"); diff --git a/download.php b/download.php new file mode 100644 index 0000000..b7f0739 --- /dev/null +++ b/download.php @@ -0,0 +1,47 @@ +isSMTP(); // Set mailer to use SMTP - $mail->Host = 'mxe98c.netcup.net; mxe98c.netcup.net'; // Specify main and backup SMTP servers - $mail->SMTPAuth = true; // Enable SMTP authentication - $mail->Username = 'info@silias.ch'; // SMTP username - $mail->Password = $smtppassword; // SMTP password - $mail->SMTPSecure = 'ssl'; // Enable TLS encryption, `ssl` also accepted - $mail->Port = 465; // TCP port to connect to + $mail->isSMTP(); + $mail->Host = app_env('SMTP_HOST', 'mxe98c.netcup.net'); + $mail->SMTPAuth = true; + $mail->Username = app_env('SMTP_USERNAME', 'info@silias.ch'); + $mail->Password = $smtpPassword; + $mail->SMTPSecure = app_env('SMTP_SECURE', 'ssl'); + $mail->Port = app_env_int('SMTP_PORT', 465, 1, 65535); + $mail->CharSet = 'UTF-8'; $mail->isHTML(true); $mail->setFrom('info@silias.ch', 'Silias Zefix Export'); $mail->addAddress($emailAddress); - $mail->addBCC('info@silias.ch'); + $bccAddress = app_env('EXPORT_BCC_EMAIL'); + if ($bccAddress !== '' && filter_var($bccAddress, FILTER_VALIDATE_EMAIL)) { + $mail->addBCC($bccAddress); + } $mail->addReplyTo('info@silias.ch', 'Silias KLG'); $mail->Subject = 'Ihr Export von Zefix ist bereit'; - $mail->Body = 'Nutzen Sie den folgenden Link um ihre Daten herunterzuladen.
https://zefix.silias.ch/'.$filename.''; + $downloadUrl = app_public_base_url() . '/download.php?token=' . rawurlencode($downloadToken); + $escapedUrl = htmlspecialchars($downloadUrl, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $retentionHours = app_env_int('DOWNLOAD_RETENTION_HOURS', 48, 1, 720); + $mail->Body = 'Nutzen Sie den folgenden Link, um Ihre Daten herunterzuladen:
' . $escapedUrl . '

Der Link ist ' . $retentionHours . ' Stunden gültig.'; + $mail->AltBody = "Nutzen Sie den folgenden Link, um Ihre Daten herunterzuladen:\n" . $downloadUrl . "\n\nDer Link ist " . $retentionHours . ' Stunden gültig.'; - - if($mail->send()){ - $status = "success"; - $response = "Email is sent!"; - } - else{ - $status = "failed"; - $response = "Something is wrong:
" . $mail->ErrorInfo; + if ($smtpPassword === '') { + error_log('SMTP password is not configured.'); + return false; } - echo "Email status:".$status; - echo "Email Response:".$response; + if (!$mail->send()) { + error_log('Export email failed: ' . $mail->ErrorInfo); + return false; + } + + return true; } -?> - - diff --git a/gemeinden.php b/gemeinden.php index ec42b5d..abd5335 100644 --- a/gemeinden.php +++ b/gemeinden.php @@ -1,4 +1,8 @@ +$communityCsv = app_cached_string( + 'communities', + app_env_int('REFERENCE_CACHE_SECONDS', 86400, 300, 604800), + static fn(): string => communityCSV((string)$username, (string)$password) +); +echo 'communityCsvData = ' . json_encode($communityCsv, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . ';'; diff --git a/index.php b/index.php index e7d8760..ad9fb61 100644 --- a/index.php +++ b/index.php @@ -1,5 +1,9 @@ @@ -8,9 +12,12 @@ include "zefixAPI.php"; - - + + + + + Silias Zefix Export @@ -41,14 +48,28 @@ include "zefixAPI.php"; } .gemeindeeintrag { margin-bottom: .25rem; } + + .bot-trap { + position: absolute !important; + left: -10000px !important; + width: 1px !important; + height: 1px !important; + overflow: hidden !important; + } -