Add Turnstile protection and harden export workflow

This commit is contained in:
2026-07-22 08:54:31 +02:00
parent 67c7b5ccff
commit 4b9a5d7e78
17 changed files with 1572 additions and 276 deletions
+1
View File
@@ -0,0 +1 @@
*.sh text eol=lf
+6
View File
@@ -0,0 +1,6 @@
/env_vars.php
/tasks/
/download/
/var/
/*.log
/.phpunit.cache/
+103 -1
View File
@@ -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=<mindestens 32 zufällige Bytes>
```
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.
+451
View File
@@ -0,0 +1,451 @@
<?php
declare(strict_types=1);
/**
* Shared configuration, storage and abuse-protection helpers.
*
* Secrets are read from environment variables. The legacy env_vars.php file is
* loaded when present so existing installations keep working during migration.
*/
$legacyEnvFile = __DIR__ . '/env_vars.php';
if (is_file($legacyEnvFile)) {
require_once $legacyEnvFile;
}
function app_env(string $name, string $default = ''): string
{
$value = getenv($name);
return $value === false ? $default : trim((string)$value);
}
function app_env_int(string $name, int $default, int $min, int $max): int
{
$value = filter_var(app_env($name), FILTER_VALIDATE_INT);
if ($value === false) {
return $default;
}
return max($min, min($max, (int)$value));
}
function app_private_dir(): string
{
$configured = app_env('ZEFIX_PRIVATE_DIR');
return $configured !== ''
? rtrim($configured, '/\\')
: dirname(__DIR__) . DIRECTORY_SEPARATOR . 'zefix-private';
}
function app_ensure_dir(string $directory): void
{
if (!is_dir($directory) && !mkdir($directory, 0700, true) && !is_dir($directory)) {
throw new RuntimeException('Private storage could not be created.');
}
}
function app_task_dir(): string
{
$directory = app_private_dir() . DIRECTORY_SEPARATOR . 'tasks';
app_ensure_dir($directory);
return $directory;
}
function app_download_dir(): string
{
$directory = app_private_dir() . DIRECTORY_SEPARATOR . 'downloads';
app_ensure_dir($directory);
return $directory;
}
function app_rate_limit_dir(): string
{
$directory = app_private_dir() . DIRECTORY_SEPARATOR . 'rate-limits';
app_ensure_dir($directory);
return $directory;
}
function app_cache_dir(): string
{
$directory = app_private_dir() . DIRECTORY_SEPARATOR . 'cache';
app_ensure_dir($directory);
return $directory;
}
function app_send_security_headers(): void
{
if (headers_sent()) {
return;
}
header("Content-Security-Policy: default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com; connect-src 'self' https://challenges.cloudflare.com; upgrade-insecure-requests");
header('Referrer-Policy: strict-origin-when-cross-origin');
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()');
header('Cross-Origin-Opener-Policy: same-origin');
}
function app_turnstile_site_key(): string
{
return '0x4AAAAAAD7CJrWpNiK0-A7h';
}
function app_turnstile_secret_key(): string
{
return app_env('TURNSTILE_SECRET');
}
function app_turnstile_is_configured(): bool
{
return app_turnstile_site_key() !== '' && app_turnstile_secret_key() !== '';
}
function app_security_secret(): string
{
$secret = app_env('APP_SECRET');
if ($secret === '') {
$secret = app_turnstile_secret_key();
}
return $secret;
}
function app_base64url_encode(string $value): string
{
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
}
function app_base64url_decode(string $value): string|false
{
$padding = strlen($value) % 4;
if ($padding !== 0) {
$value .= str_repeat('=', 4 - $padding);
}
return base64_decode(strtr($value, '-_', '+/'), true);
}
function app_create_form_started_token(?int $timestamp = null): string
{
$secret = app_security_secret();
if ($secret === '') {
return '';
}
$timestamp ??= time();
$payload = (string)$timestamp;
$signature = hash_hmac('sha256', $payload, $secret, true);
return app_base64url_encode($payload . '.' . $signature);
}
function app_validate_form_started_token(string $token): bool
{
$secret = app_security_secret();
$decoded = app_base64url_decode($token);
if ($secret === '' || $decoded === false || strlen($decoded) < 34) {
return false;
}
$separator = strpos($decoded, '.');
if ($separator === false) {
return false;
}
$payload = substr($decoded, 0, $separator);
$signature = substr($decoded, $separator + 1);
if (!ctype_digit($payload) || strlen($signature) !== 32) {
return false;
}
$expected = hash_hmac('sha256', $payload, $secret, true);
if (!hash_equals($expected, $signature)) {
return false;
}
$age = time() - (int)$payload;
$minimumSeconds = app_env_int('FORM_MIN_SECONDS', 2, 0, 60);
$maximumSeconds = app_env_int('FORM_MAX_SECONDS', 7200, 60, 86400);
return $age >= $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;
}
+27
View File
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit;
}
require_once __DIR__ . '/app.php';
$downloadRetention = app_env_int('DOWNLOAD_RETENTION_HOURS', 48, 1, 720) * 3600;
$staleTaskRetention = app_env_int('STALE_TASK_RETENTION_HOURS', 168, 24, 2160) * 3600;
$migrated = app_migrate_legacy_tasks();
$downloads = app_cleanup_directory(app_download_dir(), $downloadRetention, 'csv');
$legacyDownloads = app_cleanup_directory(__DIR__ . DIRECTORY_SEPARATOR . 'download', $downloadRetention, 'csv');
$tasks = app_cleanup_directory(app_task_dir(), $staleTaskRetention, 'json');
$rateLimits = app_cleanup_directory(app_rate_limit_dir(), 8 * 86400, 'json');
fwrite(STDOUT, sprintf(
"Cleanup complete: %d tasks migrated; %d downloads, %d legacy downloads, %d stale tasks and %d rate-limit files deleted.\n",
$migrated,
$downloads,
$legacyDownloads,
$tasks,
$rateLimits
));
+2 -1
View File
@@ -12,6 +12,7 @@ cd "$BASE_DIR" || { echo "cd failed: $BASE_DIR" >> "$LOG_FILE"; exit 1; }
start_ts=$(date +%s) start_ts=$(date +%s)
$PHP_BIN "$BASE_DIR/cleanup.php" >> "$LOG_FILE" 2>&1
$PHP_BIN "$BASE_DIR/taskExecuter.php" >> "$LOG_FILE" 2>&1 $PHP_BIN "$BASE_DIR/taskExecuter.php" >> "$LOG_FILE" 2>&1
status=$? status=$?
@@ -23,4 +24,4 @@ echo "Duration: ${duration}s" >> "$LOG_FILE"
echo "CRON END $(date '+%Y-%m-%d %H:%M:%S')" >> "$LOG_FILE" echo "CRON END $(date '+%Y-%m-%d %H:%M:%S')" >> "$LOG_FILE"
echo "" >> "$LOG_FILE" echo "" >> "$LOG_FILE"
exit $status exit $status
+176 -6
View File
@@ -3,23 +3,104 @@
:root { :root {
--silias-radius: 14px; --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 { html, body {
height: 100%; min-height: 100%;
} }
body { body {
/* Bootstrap uses system font stack, keep it */ /* Bootstrap uses system font stack, keep it */
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -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 */ /* Navbar */
.site-header {
background: rgba(255, 255, 255, .94);
backdrop-filter: blur(12px);
}
.navbar-brand { .navbar-brand {
letter-spacing: 0.2px; 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 polish */
.card { .card {
border-radius: var(--silias-radius); border-radius: var(--silias-radius);
@@ -28,6 +109,61 @@ body {
box-shadow: 0 10px 25px rgba(0,0,0,.06) !important; 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 */
.form-label { .form-label {
font-weight: 600; font-weight: 600;
@@ -44,7 +180,8 @@ body {
.form-control:focus, .form-control:focus,
.form-select: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 */ /* Buttons: DO NOT globally style "button" tag */
@@ -56,17 +193,31 @@ body {
padding-bottom: .85rem; 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) */ /* Scroll panels (if you keep class from index.php) */
.scrollWindow { .scrollWindow {
border-radius: 1rem; border-radius: 1rem;
scrollbar-color: #b8bfdc transparent;
scrollbar-width: thin;
} }
/* Checkbox rows */ /* Checkbox rows */
.form-check { .form-check {
padding: .25rem 0; padding: .3rem 0 .3rem 1.75rem;
} }
.form-check-input { .form-check-input {
cursor: pointer; cursor: pointer;
margin-left: -1.75rem;
} }
.form-check-label { .form-check-label {
cursor: pointer; cursor: pointer;
@@ -81,6 +232,11 @@ body {
footer { footer {
margin-top: 0; margin-top: 0;
} }
.site-footer {
color: var(--silias-muted);
background: #fff;
}
footer a { footer a {
text-decoration: none; text-decoration: none;
} }
@@ -115,10 +271,24 @@ footer a:hover {
.card-body { .card-body {
padding: 1.25rem !important; 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 */ /* Fix: checkboxes not clipped and never overlap text */
.scrollWindow .form-check { .scrollWindow .form-check {
padding-left: 0 !important; /* remove bootstrap offset */ padding: .6rem 1.1rem !important;
margin: 0 !important; margin: 0 !important;
} }
@@ -129,6 +299,6 @@ footer a:hover {
.scrollWindow .form-check-label { .scrollWindow .form-check-label {
display: block; display: block;
padding-left: 1.6rem; /* space for the checkbox */ padding-left: 1.9rem;
line-height: 1.4; line-height: 1.4;
} }
+136
View File
@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/app.php';
app_send_security_headers();
$downloadRetentionHours = app_env_int('DOWNLOAD_RETENTION_HOURS', 48, 1, 720);
$staleTaskRetentionHours = app_env_int('STALE_TASK_RETENTION_HOURS', 168, 24, 2160);
?>
<!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>Datenschutzerklärung Silias Zefix Export</title>
</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-lg-9">
<article class="card shadow-sm">
<div class="card-body p-4 p-md-5">
<h1 class="h3 mb-4">Datenschutzerklärung</h1>
<p class="text-muted">Stand: 22. Juli 2026</p>
<h2 class="h5 mt-4">1. Verantwortlicher</h2>
<p>
Silias KLG<br>
Toggenburgstrasse 31<br>
8245 Feuerthalen, Schweiz<br>
E-Mail: <a href="mailto:info@silias.ch">info@silias.ch</a>
</p>
<h2 class="h5 mt-4">2. Zweck und Umfang der Datenbearbeitung</h2>
<p>
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.
</p>
<p>
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.
</p>
<h2 class="h5 mt-4">3. Cloudflare Turnstile</h2>
<p>
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.
</p>
<p>
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
<a href="https://www.cloudflare.com/turnstile-privacy-policy/" target="_blank" rel="noopener">Turnstile Privacy Addendum</a>,
in der <a href="https://www.cloudflare.com/policies/privacy/" target="_blank" rel="noopener">Datenschutzerklärung von Cloudflare</a>
und im <a href="https://www.cloudflare.com/cloudflare-customer-dpa/" target="_blank" rel="noopener">Data Processing Addendum</a>.
</p>
<h2 class="h5 mt-4">4. Cookies und ähnliche Technologien</h2>
<p>
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 <code>cf_clearance</code>-Cookie auf unserer Domain setzen würde, ist deaktiviert.
</p>
<h2 class="h5 mt-4">5. Empfänger und Datenquellen</h2>
<p>
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.
</p>
<h2 class="h5 mt-4">6. Aufbewahrungsdauer</h2>
<ul>
<li>Offene Aufträge einschliesslich E-Mail-Adresse: bis zur Verarbeitung, spätestens <?php echo $staleTaskRetentionHours; ?> Stunden.</li>
<li>Fertige Exportdateien: <?php echo $downloadRetentionHours; ?> Stunden nach ihrer Erstellung.</li>
<li>Pseudonymisierte Rate-Limit-Einträge: höchstens acht Tage.</li>
<li>Technische Serverprotokolle: nur so lange, wie dies für Sicherheit, Fehleranalyse und Betrieb erforderlich ist.</li>
</ul>
<p>Nach Ablauf der jeweiligen Frist werden die Daten automatisch gelöscht oder überschrieben.</p>
<h2 class="h5 mt-4">7. Datensicherheit</h2>
<p>
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.
</p>
<h2 class="h5 mt-4">8. Ihre Rechte</h2>
<p>
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 <a href="mailto:info@silias.ch">info@silias.ch</a>. Sie können sich ausserdem
an den Eidgenössischen Datenschutz- und Öffentlichkeitsbeauftragten oder eine andere zuständige
Datenschutzaufsichtsbehörde wenden.
</p>
<h2 class="h5 mt-4">9. Änderungen</h2>
<p>
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.
</p>
<a class="btn btn-primary mt-3" href="index.php">Zurück zur Startseite</a>
</div>
</article>
</div>
</div>
</main>
<footer class="site-footer border-top">
<div class="container py-3 text-center text-muted small">
© <?php echo date('Y'); ?> Silias KLG
</div>
</footer>
</body>
</html>
+19 -9
View File
@@ -1,13 +1,23 @@
<?php <?php
$taskDir = 'tasks'; declare(strict_types=1);
include 'env_vars.php';
if($_POST["taskDeletePassword"] == getenv("taskDeletePassword")){ if (PHP_SAPI !== 'cli') {
$files = scandir($taskDir); http_response_code(404);
$files = array_diff($files, array('.', '..')); exit;
foreach ($files as $file) { }
echo $file;
unlink($taskDir.'/'.$file); require_once __DIR__ . '/app.php';
if (($argv[1] ?? '') !== '--confirm') {
fwrite(STDERR, "This command deletes every pending export task. Run: php deleteFiles.php --confirm\n");
exit(2);
}
$deleted = 0;
foreach (glob(app_task_dir() . DIRECTORY_SEPARATOR . '*.json') ?: [] as $filename) {
if (is_file($filename) && unlink($filename)) {
$deleted++;
} }
} }
?> fwrite(STDOUT, $deleted . " pending task(s) deleted.\n");
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/app.php';
app_send_security_headers();
header('Cache-Control: private, no-store, max-age=0');
header('Pragma: no-cache');
header('X-Robots-Tag: noindex, nofollow, noarchive');
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'GET') {
header('Allow: GET');
http_response_code(405);
exit('Methode nicht erlaubt.');
}
$tokenValue = $_GET['token'] ?? '';
$token = is_scalar($tokenValue) ? strtolower(trim((string)$tokenValue)) : '';
if (!preg_match('/^[a-f0-9]{32}$/', $token)) {
http_response_code(404);
exit('Download nicht gefunden.');
}
$filename = app_download_path($token);
$retentionSeconds = app_env_int('DOWNLOAD_RETENTION_HOURS', 48, 1, 720) * 3600;
$modified = is_file($filename) ? filemtime($filename) : false;
if ($modified === false || $modified < time() - $retentionSeconds) {
if (is_file($filename)) {
@unlink($filename);
}
http_response_code(404);
exit('Der Download ist nicht vorhanden oder bereits abgelaufen.');
}
$size = filesize($filename);
header('Content-Type: text/csv; charset=UTF-8');
header('Content-Disposition: attachment; filename="zefix-export.csv"');
if ($size !== false) {
header('Content-Length: ' . $size);
}
$handle = fopen($filename, 'rb');
if ($handle === false) {
http_response_code(500);
exit('Der Download konnte nicht geöffnet werden.');
}
fpassthru($handle);
fclose($handle);
+29 -23
View File
@@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
require_once __DIR__ . '/app.php';
require_once "PHPMailer.php"; require_once "PHPMailer.php";
require_once "SMTP.php"; require_once "SMTP.php";
require_once "Exception.php"; require_once "Exception.php";
@@ -7,39 +9,43 @@ require_once "Exception.php";
use PHPMailer\PHPMailer\PHPMailer; use PHPMailer\PHPMailer\PHPMailer;
function sendEmail($emailAddress, $filename, $smtppassword) function sendEmail(string $emailAddress, string $downloadToken, string $smtpPassword): bool
{ {
$mail = new PHPMailer(); $mail = new PHPMailer();
$mail->isSMTP(); // Set mailer to use SMTP $mail->isSMTP();
$mail->Host = 'mxe98c.netcup.net; mxe98c.netcup.net'; // Specify main and backup SMTP servers $mail->Host = app_env('SMTP_HOST', 'mxe98c.netcup.net');
$mail->SMTPAuth = true; // Enable SMTP authentication $mail->SMTPAuth = true;
$mail->Username = 'info@silias.ch'; // SMTP username $mail->Username = app_env('SMTP_USERNAME', 'info@silias.ch');
$mail->Password = $smtppassword; // SMTP password $mail->Password = $smtpPassword;
$mail->SMTPSecure = 'ssl'; // Enable TLS encryption, `ssl` also accepted $mail->SMTPSecure = app_env('SMTP_SECURE', 'ssl');
$mail->Port = 465; // TCP port to connect to $mail->Port = app_env_int('SMTP_PORT', 465, 1, 65535);
$mail->CharSet = 'UTF-8';
$mail->isHTML(true); $mail->isHTML(true);
$mail->setFrom('info@silias.ch', 'Silias Zefix Export'); $mail->setFrom('info@silias.ch', 'Silias Zefix Export');
$mail->addAddress($emailAddress); $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->addReplyTo('info@silias.ch', 'Silias KLG');
$mail->Subject = 'Ihr Export von Zefix ist bereit'; $mail->Subject = 'Ihr Export von Zefix ist bereit';
$mail->Body = 'Nutzen Sie den folgenden Link um ihre Daten herunterzuladen.<br><a href="https://zefix.silias.ch/'.$filename.'">https://zefix.silias.ch/'.$filename.'</a>'; $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:<br><a href="' . $escapedUrl . '">' . $escapedUrl . '</a><br><br>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 ($smtpPassword === '') {
if($mail->send()){ error_log('SMTP password is not configured.');
$status = "success"; return false;
$response = "Email is sent!";
}
else{
$status = "failed";
$response = "Something is wrong: <br>" . $mail->ErrorInfo;
} }
echo "Email status:".$status; if (!$mail->send()) {
echo "Email Response:".$response; error_log('Export email failed: ' . $mail->ErrorInfo);
return false;
}
return true;
} }
?>
+6 -2
View File
@@ -1,4 +1,8 @@
<?php <?php
echo "communityCsvData = `". communityCSV($username, $password) ."`;"; $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) . ';';
+161 -69
View File
@@ -1,5 +1,9 @@
<?php <?php
require_once __DIR__ . '/app.php';
app_send_security_headers();
include "zefixAPI.php"; include "zefixAPI.php";
$turnstileSiteKey = app_turnstile_is_configured() ? app_turnstile_site_key() : '';
$formStartedToken = app_create_form_started_token();
?> ?>
<!doctype html> <!doctype html>
<html lang="de"> <html lang="de">
@@ -8,9 +12,12 @@ include "zefixAPI.php";
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Bootstrap first, then your overrides -->
<link rel="stylesheet" href="css/bootstrap.css"> <link rel="stylesheet" href="css/bootstrap.css">
<link rel="stylesheet" href="css/custom.css?v=1.3"> <link rel="stylesheet" href="css/custom.css?v=2.2">
<?php if ($turnstileSiteKey !== ''): ?>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<?php endif; ?>
<title>Silias Zefix Export</title> <title>Silias Zefix Export</title>
@@ -41,14 +48,28 @@ include "zefixAPI.php";
} }
.gemeindeeintrag { margin-bottom: .25rem; } .gemeindeeintrag { margin-bottom: .25rem; }
.bot-trap {
position: absolute !important;
left: -10000px !important;
width: 1px !important;
height: 1px !important;
overflow: hidden !important;
}
</style> </style>
</head> </head>
<body class="bg-light"> <body class="bg-light">
<nav class="navbar navbar-light bg-white border-bottom"> <nav class="navbar navbar-light site-header border-bottom py-2">
<div class="container"> <div class="container">
<span class="navbar-brand mb-0 h1">Silias • ZEFIX Export</span> <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> </div>
</nav> </nav>
@@ -56,53 +77,73 @@ include "zefixAPI.php";
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-lg-10 col-xl-9"> <div class="col-lg-10 col-xl-9">
<div class="card shadow-sm"> <div class="mb-4 mb-md-5">
<div class="hero-eyebrow mb-2">Zentraler Firmenindex</div>
<h1 class="display-6 fw-bold hero-title mb-3">Firmendaten gezielt auswählen und als CSV exportieren</h1>
<p class="lead hero-copy mb-0">
Filtern Sie Unternehmen nach Firmenname, Ort und Rechtsform. Sobald der Export bereit ist,
erhalten Sie einen sicheren Download-Link per E-Mail.
</p>
</div>
<div class="card export-shell shadow-sm">
<div class="card-body p-4 p-md-5"> <div class="card-body p-4 p-md-5">
<div class="d-flex flex-column flex-md-row align-items-md-start justify-content-between gap-2 mb-3">
<div>
<h1 class="h3 mb-1">Silias Zefix Export</h1>
<p class="text-muted mb-0">
Exportiere Firmen als Excel (csv) aus dem Zentralen Firmenindex (ZEFIX).
</p>
</div>
<span class="badge bg-primary-subtle text-primary border border-primary-subtle align-self-md-center">
Export via E-Mail Link
</span>
</div>
<hr class="my-4">
<form action="submit.php" method="post" class="needs-validation" novalidate> <form action="submit.php" method="post" class="needs-validation" novalidate>
<!-- Firmenname --> <input type="hidden" name="form_started" value="<?php echo htmlspecialchars($formStartedToken, ENT_QUOTES); ?>">
<div class="mb-4"> <div class="bot-trap" aria-hidden="true" hidden>
<label for="firma" class="form-label">Firmenname (optional)</label> <label for="website">Website</label>
<div class="form-text mb-2">* kann als Platzhalter verwendet werden</div> <input type="text" id="website" name="website" value="" tabindex="-1" autocomplete="off">
<input type="text" id="firma" name="firma" class="form-control" placeholder="z.B. *solar*">
</div> </div>
<!-- Kanton --> <div class="form-section mb-3">
<div class="mb-4"> <div class="section-heading">
<label class="form-label">Kanton</label> <span class="section-number" aria-hidden="true">1</span>
<div class="alert alert-warning py-2 mb-2" role="alert" style="font-size: .95rem;"> <div>
Die Kantonsauswahl wird vom Export ignoriert und dient nur zum Filtern der Ortschaften. <h2>Firmenname</h2>
Für Export nach Kanton: gewünschte Kantone wählen und dann bei Ortschaften auf <p>Optional: Schränken Sie den Export auf passende Firmennamen ein.</p>
<b>Alle Ergebnisse auswählen</b> klicken. </div>
</div> </div>
<div id="kantonauswahl" class="scrollWindow" onclick="filterFunction()"></div> <label for="firma" class="form-label">Firmenname oder Suchmuster</label>
<input type="text" id="firma" name="firma" class="form-control" placeholder="Zum Beispiel *solar*" maxlength="200">
<div class="form-text mt-2">Das Sternchen (*) kann als Platzhalter verwendet werden.</div>
</div> </div>
<!-- Sitz / Ort --> <div class="form-section mb-3">
<div class="mb-4"> <div class="section-heading">
<label class="form-label">Sitz (Ort)</label> <span class="section-number" aria-hidden="true">2</span>
<div>
<h2>Ort auswählen</h2>
<p>Filtern Sie zuerst nach Kanton oder Ortsname und übernehmen Sie danach die gewünschten Orte.</p>
</div>
</div>
<div class="alert alert-primary border-0 py-2 mb-3" role="note">
<strong>Hinweis:</strong> Die Kantone filtern nur die Ortsliste. Klicken Sie danach auf
<strong>«Gefilterte Orte auswählen»</strong>, damit die Orte in den Export übernommen werden.
</div>
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="form-label mb-0">Kanton</label>
<span class="selection-meta" id="cantonCount">0 ausgewählt</span>
</div>
<div id="kantonauswahl" class="scrollWindow" onclick="filterFunction()"></div>
</div>
<div>
<div class="d-flex justify-content-between align-items-center mb-2">
<label for="sitzInput" class="form-label mb-0">Sitz (Ort)</label>
<span class="selection-meta" id="seatCount">0 ausgewählt</span>
</div>
<div class="card border-0 bg-white"> <div class="card border-0 bg-white">
<div class="card-body p-0"> <div class="card-body p-0">
<div class="d-flex flex-column flex-lg-row gap-2 mb-2"> <div class="d-flex flex-column flex-lg-row gap-2 mb-2">
<div class="flex-grow-1"> <div class="flex-grow-1">
<input type="text" <input type="text"
placeholder="Suchen (Ort)" placeholder="Ort suchen"
id="sitzInput" id="sitzInput"
onkeyup="filterFunction()" onkeyup="filterFunction()"
onchange="filterFunction()" onchange="filterFunction()"
@@ -112,7 +153,7 @@ include "zefixAPI.php";
<div class="d-flex flex-wrap gap-2"> <div class="d-flex flex-wrap gap-2">
<button type="button" class="btn btn-outline-primary button-small" onclick="gefundeneGemeindenAuswählen()"> <button type="button" class="btn btn-outline-primary button-small" onclick="gefundeneGemeindenAuswählen()">
Alle Ergebnisse auswählen Gefilterte Orte auswählen
</button> </button>
<button type="button" class="btn btn-outline-secondary button-small" onclick="ausgewählteGemeindenLöschen()"> <button type="button" class="btn btn-outline-secondary button-small" onclick="ausgewählteGemeindenLöschen()">
Auswahl löschen Auswahl löschen
@@ -121,7 +162,7 @@ include "zefixAPI.php";
Alle anzeigen Alle anzeigen
</button> </button>
<button type="button" class="btn btn-outline-secondary button-small" onclick="nurAusgewählteGemeindenAnzeigen()"> <button type="button" class="btn btn-outline-secondary button-small" onclick="nurAusgewählteGemeindenAnzeigen()">
Nur ausgewählte Nur ausgewählte anzeigen
</button> </button>
</div> </div>
</div> </div>
@@ -130,25 +171,43 @@ include "zefixAPI.php";
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Rechtsform --> <div class="form-section mb-3">
<div class="mb-4"> <div class="section-heading">
<label class="form-label">Rechtsform</label> <span class="section-number" aria-hidden="true">3</span>
<div>
<h2>Rechtsform auswählen</h2>
<p>Wählen Sie mindestens eine Rechtsform für den Export.</p>
</div>
</div>
<div class="mb-2"> <div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-2">
<div>
<button type="button" class="btn btn-outline-primary button-small" onclick="alleRechtsformenAuswählen()"> <button type="button" class="btn btn-outline-primary button-small" onclick="alleRechtsformenAuswählen()">
Alle auswählen Alle auswählen
</button> </button>
<button type="button" class="btn btn-outline-secondary button-small" onclick="rechtsformenLöschen()"> <button type="button" class="btn btn-outline-secondary button-small" onclick="rechtsformenLöschen()">
Auswahl löschen Auswahl löschen
</button> </button>
</div>
<span class="selection-meta" id="legalFormCount">0 ausgewählt</span>
</div> </div>
<div id="rechtsformenauswahl" class="scrollWindow"></div> <div id="rechtsformenauswahl" class="scrollWindow"></div>
</div> </div>
<!-- Optionen --> <div class="form-section mb-3">
<div class="mb-4"> <div class="section-heading">
<span class="section-number" aria-hidden="true">4</span>
<div>
<h2>Export anfordern</h2>
<p>Geben Sie die Zieladresse an und starten Sie den geschützten Export.</p>
</div>
</div>
<div class="mb-4">
<div class="form-label mb-2">Weitere Optionen</div>
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" id="exakteSuche" name="exakteSuche" disabled> <input class="form-check-input" type="checkbox" id="exakteSuche" name="exakteSuche" disabled>
<label class="form-check-label" for="exakteSuche">Exakte Suche (noch in Entwicklung)</label> <label class="form-check-label" for="exakteSuche">Exakte Suche (noch in Entwicklung)</label>
@@ -168,23 +227,43 @@ include "zefixAPI.php";
<input class="form-check-input" type="checkbox" id="phonetischeSuche" name="phonetischeSuche" disabled> <input class="form-check-input" type="checkbox" id="phonetischeSuche" name="phonetischeSuche" disabled>
<label class="form-check-label" for="phonetischeSuche">Phonetische Suche (noch in Entwicklung)</label> <label class="form-check-label" for="phonetischeSuche">Phonetische Suche (noch in Entwicklung)</label>
</div> </div>
</div> </div>
<!-- Email -->
<div class="mb-4"> <div class="mb-4">
<label for="email" class="form-label">Ihre E-Mail Adresse</label> <label for="email" class="form-label">Ihre E-Mail-Adresse</label>
<div class="form-text mb-2"> <div class="form-text mb-2">
Das Aufbereiten der Daten kann länger dauern. Sie erhalten einen Download-Link per E-Mail sobald die Daten bereit sind. Die Aufbereitung kann einige Zeit dauern. Sie erhalten den Download-Link per E-Mail, sobald die Daten bereit sind.
</div> </div>
<input type="email" id="email" name="email" class="form-control" required placeholder="name@firma.ch"> <input type="email" id="email" name="email" class="form-control" required placeholder="name@firma.ch">
<div class="invalid-feedback">Bitte eine gültige E-Mail Adresse eingeben.</div> <div class="invalid-feedback">Bitte geben Sie eine gültige E-Mail-Adresse ein.</div>
</div> </div>
<?php if ($turnstileSiteKey !== ''): ?>
<div class="privacy-note mb-3">
<div class="cf-turnstile"
data-sitekey="<?php echo htmlspecialchars($turnstileSiteKey, ENT_QUOTES); ?>"
data-action="turnstile-spin-v2"
data-theme="auto"
data-language="de"
data-feedback-enabled="false"></div>
<div class="form-text mt-2">
Der Missbrauchsschutz verarbeitet technisch notwendige Browser- und Verbindungsdaten.
Details finden Sie in der <a href="datenschutz.php">Datenschutzerklärung</a>.
</div>
</div>
<?php else: ?>
<div class="alert alert-warning" role="alert">
Der Export ist vorübergehend deaktiviert, da der Missbrauchsschutz noch nicht konfiguriert ist.
</div>
<?php endif; ?>
<div class="d-grid"> <div class="d-grid">
<button type="submit" class="btn btn-primary btn-lg">Export starten</button> <button type="submit" class="btn btn-primary btn-lg"<?php echo $turnstileSiteKey === '' ? ' disabled' : ''; ?>>Export starten</button>
</div> </div>
<div id="selectionValidationMessage" class="text-danger mt-2" style="display:none;"> <div id="selectionValidationMessage" class="text-danger mt-2" style="display:none;">
Wählen Sie mindestens einen Ort und eine Rechtsform Wählen Sie mindestens einen Ort und eine Rechtsform aus.
</div>
</div> </div>
</form> </form>
@@ -233,9 +312,11 @@ include "zefixAPI.php";
input.type = "checkbox"; input.type = "checkbox";
input.name = checkboxName; input.name = checkboxName;
input.value = checkboxValue; input.value = checkboxValue;
input.id = checkboxName.replace(/[^a-z0-9]/gi, '-') + '-' + String(checkboxValue).replace(/[^a-z0-9]/gi, '-');
const label = document.createElement('label'); const label = document.createElement('label');
label.className = "form-check-label"; label.className = "form-check-label";
label.htmlFor = input.id;
label.textContent = labelText; label.textContent = labelText;
wrapper.appendChild(input); wrapper.appendChild(input);
@@ -305,9 +386,11 @@ include "zefixAPI.php";
input.type = "checkbox"; input.type = "checkbox";
input.name = "sitze[]"; input.name = "sitze[]";
input.value = bfsId; input.value = bfsId;
input.id = "sitz-" + bfsId;
const label = document.createElement('label'); const label = document.createElement('label');
label.className = "form-check-label"; label.className = "form-check-label";
label.htmlFor = input.id;
label.textContent = " " + gemeindeName; label.textContent = " " + gemeindeName;
wrapper.appendChild(input); wrapper.appendChild(input);
@@ -319,6 +402,22 @@ include "zefixAPI.php";
const sitzInput = document.getElementById('sitzInput'); const sitzInput = document.getElementById('sitzInput');
function updateSelectionCounts() {
const cantonTotal = document.querySelectorAll('input[name="kantone[]"]:checked').length;
const seatTotal = document.querySelectorAll('input[name="sitze[]"]:checked').length;
const legalFormTotal = document.querySelectorAll('input[name="rechtsformen[]"]:checked').length;
document.getElementById('cantonCount').textContent = cantonTotal + ' ausgewählt';
document.getElementById('seatCount').textContent = seatTotal + ' ausgewählt';
document.getElementById('legalFormCount').textContent = legalFormTotal + ' ausgewählt';
}
document.addEventListener('change', function(event) {
if (event.target && event.target.matches('input[type="checkbox"]')) {
updateSelectionCounts();
}
});
function kantoneAusgewählt() { function kantoneAusgewählt() {
const checkboxes = document.querySelectorAll('input[name="kantone[]"]'); const checkboxes = document.querySelectorAll('input[name="kantone[]"]');
let countChecked = 0; let countChecked = 0;
@@ -374,6 +473,7 @@ include "zefixAPI.php";
cb.checked = false; cb.checked = false;
}); });
filterFunction(); filterFunction();
updateSelectionCounts();
} }
function gefundeneGemeindenAuswählen() { function gefundeneGemeindenAuswählen() {
@@ -383,19 +483,24 @@ include "zefixAPI.php";
if (entry && entry.style.display !== "none") cb.checked = true; if (entry && entry.style.display !== "none") cb.checked = true;
}); });
filterFunction(); filterFunction();
updateSelectionCounts();
} }
function alleRechtsformenAuswählen() { function alleRechtsformenAuswählen() {
document.querySelectorAll('input[name="rechtsformen[]"]').forEach(function(cb) { document.querySelectorAll('input[name="rechtsformen[]"]').forEach(function(cb) {
cb.checked = true; cb.checked = true;
}); });
updateSelectionCounts();
} }
function rechtsformenLöschen() { function rechtsformenLöschen() {
document.querySelectorAll('input[name="rechtsformen[]"]').forEach(function(cb) { document.querySelectorAll('input[name="rechtsformen[]"]').forEach(function(cb) {
cb.checked = false; cb.checked = false;
}); });
updateSelectionCounts();
} }
updateSelectionCounts();
</script> </script>
</div> </div>
@@ -405,7 +510,7 @@ include "zefixAPI.php";
</div> </div>
</main> </main>
<footer class="border-top bg-white"> <footer class="site-footer border-top">
<div class="container py-4"> <div class="container py-4">
<div class="row g-4"> <div class="row g-4">
<div class="col-md-4"> <div class="col-md-4">
@@ -416,7 +521,7 @@ include "zefixAPI.php";
8245 Feuerthalen<br> 8245 Feuerthalen<br>
<a href="https://www.silias.ch" target="_blank" rel="noopener">www.silias.ch</a><br> <a href="https://www.silias.ch" target="_blank" rel="noopener">www.silias.ch</a><br>
<a href="mailto:info@silias.ch">info@silias.ch</a><br> <a href="mailto:info@silias.ch">info@silias.ch</a><br>
<a href="https://gitea.silias.ch/Silias-Public/Zefix_search">Projekt Repository</a> <a href="https://gitea.silias.ch/Silias-Public/Zefix_search">Projekt-Repository</a>
</div> </div>
</div> </div>
@@ -435,23 +540,10 @@ include "zefixAPI.php";
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<h2 class="h6 mb-2">Warteliste</h2> <h2 class="h6 mb-2">Datenschutz</h2>
<div class="text-muted" style="font-size:.95rem;"> <div class="text-muted" style="font-size:.95rem;">
<script> Wir verwenden Ihre E-Mail-Adresse ausschliesslich zur Bereitstellung des angeforderten Exports.
const deleteFiles = function () { <br><a href="datenschutz.php">Datenschutzerklärung</a>
let taskDeletePassword = prompt("Passwort eingeben:");
if (taskDeletePassword === null) return;
let formdata = new FormData();
formdata.append('taskDeletePassword', taskDeletePassword);
fetch("deleteFiles.php", { method: "POST", body: formdata })
.then(() => location.reload());
}
</script>
Momentan sind
<b><?php $taskDir = 'tasks'; echo count(scandir($taskDir)) - 2 ?></b>
Aufträge in der Warteliste.<br>
<a href="#" onclick="deleteFiles(); return false;">alle löschen</a>
</div> </div>
</div> </div>
</div> </div>
+6 -2
View File
@@ -1,4 +1,8 @@
<?php <?php
echo "legalFormsCsvData = `". legalFormCSV($username, $password) ."`;"; $legalFormsCsv = app_cached_string(
?> 'legal-forms',
app_env_int('REFERENCE_CACHE_SECONDS', 86400, 300, 604800),
static fn(): string => legalFormCSV((string)$username, (string)$password)
);
echo 'legalFormsCsvData = ' . json_encode($legalFormsCsv, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . ';';
+208 -69
View File
@@ -1,10 +1,13 @@
<?php <?php
include 'zefixAPI.php'; declare(strict_types=1);
$taskDir = 'tasks'; require_once __DIR__ . '/app.php';
app_send_security_headers();
header('Cache-Control: no-store');
function renderPage(string $title, string $messageHtml, bool $isError = false): void function renderPage(string $title, string $messageHtml, bool $isError = false, int $httpStatus = 200): void
{ {
http_response_code($httpStatus);
$logoPath = "img/silias-logo.png"; $logoPath = "img/silias-logo.png";
$statusClass = $isError $statusClass = $isError
@@ -19,17 +22,12 @@ echo '<!doctype html>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="css/bootstrap.css"> <link rel="stylesheet" href="css/bootstrap.css">
<link rel="stylesheet" href="css/custom.css?v=1.6"> <link rel="stylesheet" href="css/custom.css?v=2.2">
<title>' . htmlspecialchars($title) . '</title> <title>' . htmlspecialchars($title) . '</title>
<style> <style>
.brand-card { border-radius: 1rem; } .brand-card { border-radius: 1rem; }
.brand-logo {
height: 42px;
width: auto;
}
.brand-box { .brand-box {
border: none; border: none;
border-radius: 1rem; border-radius: 1rem;
@@ -46,23 +44,18 @@ echo '<!doctype html>
max-width: 420px; max-width: 420px;
} }
.logo-wrap{
padding: .55rem .85rem; /* das ist der gewünschte “Rand” um das Logo */
background: #fff;
border: 1px solid #eef1f4;
border-radius: .75rem;
box-shadow: 0 6px 18px rgba(0,0,0,.04);
display: inline-flex;
align-items: center;
}
</style> </style>
</head> </head>
<body class="bg-light"> <body class="bg-light">
<nav class="navbar navbar-light bg-white border-bottom"> <nav class="navbar navbar-light site-header border-bottom py-2">
<div class="container"> <div class="container">
<a class="navbar-brand mb-0 h1 text-decoration-none" href="index.php">Silias • ZEFIX Export</a> <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> </div>
</nav> </nav>
@@ -93,10 +86,10 @@ echo '<!doctype html>
<div class="d-flex align-items-start mb-3"> <div class="d-flex align-items-start mb-3">
<div class="flex-shrink-0 logo-wrap"> <div class="flex-shrink-0">
<img src="' . htmlspecialchars($logoPath) . '" alt="Silias Logo" <span class="brand-logo-crop d-block">
class="brand-logo" <img src="' . htmlspecialchars($logoPath) . '" alt="Silias">
onerror="this.style.display=\'none\';"> </span>
</div> </div>
<div class="ms-4"> <div class="ms-4">
@@ -133,73 +126,219 @@ echo '<!doctype html>
</div> </div>
</main> </main>
<footer class="border-top bg-white"> <footer class="border-top bg-white">
<div class="container py-3 text-center text-muted small"> <div class="container py-3 text-center text-muted small">
© ' . date('Y') . ' Silias KLG © ' . date('Y') . ' Silias KLG · <a href="datenschutz.php">Datenschutzerklärung</a>
</div> </div>
</footer> </footer>
</body> </body>
</html>'; </html>';
} }
/* ---------------- VALIDATION ---------------- */ /* ---------------- REQUEST AND ABUSE PROTECTION ---------------- */
$email = isset($_POST['email']) ? trim($_POST['email']) : ''; if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
header('Allow: POST');
if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) { renderPage('Methode nicht erlaubt', 'Bitte starten Sie den Export über das Formular auf der Startseite.', true, 405);
renderPage("Ungültige E-Mail", "Bitte geben Sie eine gültige E-Mail-Adresse an.", true);
exit; exit;
} }
/* ---------------- TASK BUILD ---------------- */ // Bots commonly fill fields that are deliberately hidden from real visitors.
$honeypot = isset($_POST['website']) && is_scalar($_POST['website']) ? trim((string)$_POST['website']) : '';
$data = []; if ($honeypot !== '') {
$data["maxEntries"] = 50; renderPage(
$data["offset"] = 0; 'Auftrag eingegangen',
$data["languageKey"] = "de"; 'Wir haben Ihren Auftrag erhalten. Sie erhalten von uns eine E-Mail, sobald die Daten zum Download bereit sind.',
false
if (!empty($_POST['firma'])) { );
$data["name"] = $_POST['firma']; exit;
} }
$data["deletedFirms"] = isset($_POST['geloeschteRechtseinheiten']); if (!app_turnstile_is_configured()) {
$data["searchType"] = "exact"; renderPage('Export nicht verfügbar', 'Der Missbrauchsschutz ist noch nicht vollständig konfiguriert.', true, 503);
exit;
}
$rechtsformen = $_POST['rechtsformen'] ?? []; $clientIp = app_client_ip();
$sitze = $_POST['sitze'] ?? []; 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;
}
$requests_to_do = []; 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;
}
foreach ($rechtsformen as $rf) { $turnstileToken = isset($_POST['cf-turnstile-response']) && is_scalar($_POST['cf-turnstile-response'])
$data["legalForms"] = [(int)$rf]; ? (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;
}
foreach ($sitze as $s) { $formStarted = isset($_POST['form_started']) && is_scalar($_POST['form_started']) ? (string)$_POST['form_started'] : '';
$data["legalSeats"] = [(int)$s]; if (!app_validate_form_started_token($formStarted)) {
$requests_to_do[] = $data; 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;
} }
} }
$taskdata = [ $fingerprint = hash('sha256', json_encode([
'requests' => $requests_to_do, 'email' => $email,
'email' => $email 'company' => $companyName,
]; 'deleted' => $baseRequest['deletedFirms'],
'seats' => $seats,
'legalForms' => $legalForms,
], JSON_THROW_ON_ERROR));
if (!is_dir($taskDir)) { try {
mkdir($taskDir, 0755, true); $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.');
}
$taskfilename = $taskDir . '/' . time() . '-' . bin2hex(random_bytes(4)) . '.json'; 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;
}
if (file_put_contents($taskfilename, json_encode($taskdata))) { 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( renderPage(
"Auftrag eingegangen", '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 lange dauern.", '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 false
); );
} catch (Throwable $exception) {
} else { 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);
renderPage("Fehler", "Ein Fehler ist aufgetreten.", true); }
}
+158 -83
View File
@@ -1,116 +1,191 @@
<?php <?php
include "zefixAPI.php"; declare(strict_types=1);
include "emailSender.php";
$maxExecutionTime = 120; if (PHP_SAPI !== 'cli') {
$taskDir = 'tasks'; http_response_code(404);
$downloadDir = 'download'; exit;
$minTaskOldness = 10;
$latesEndTime = time() + $maxExecutionTime;
$smtppassword = getenv("smtppassword");
if(!is_dir($downloadDir)){
mkdir($downloadDir, 0755, true);
} }
function doRequest($data, $filename, $username, $password) require_once __DIR__ . '/app.php';
require_once __DIR__ . '/zefixAPI.php';
require_once __DIR__ . '/emailSender.php';
$maxExecutionTime = app_env_int('TASK_EXECUTOR_MAX_SECONDS', 120, 30, 900);
$minimumTaskAge = app_env_int('TASK_MIN_AGE_SECONDS', 10, 0, 300);
$latestEndTime = time() + $maxExecutionTime;
$smtpPassword = app_env('smtppassword');
app_migrate_legacy_tasks();
$downloadRetention = app_env_int('DOWNLOAD_RETENTION_HOURS', 48, 1, 720) * 3600;
$staleTaskRetention = app_env_int('STALE_TASK_RETENTION_HOURS', 168, 24, 2160) * 3600;
app_cleanup_directory(app_download_dir(), $downloadRetention, 'csv');
app_cleanup_directory(app_task_dir(), $staleTaskRetention, 'json');
app_cleanup_directory(__DIR__ . DIRECTORY_SEPARATOR . 'download', $downloadRetention, 'csv');
$executorLock = fopen(app_private_dir() . DIRECTORY_SEPARATOR . 'executor.lock', 'c+');
if ($executorLock === false || !flock($executorLock, LOCK_EX | LOCK_NB)) {
fwrite(STDOUT, "Another executor is already running.\n");
exit(0);
}
/**
* @return int|false|null Next offset, false when complete, null on a temporary error.
*/
function doRequest(array $data, string $filename, string $username, string $password): int|false|null
{ {
$response = sendAPICompanySearchRequest($username, $password, $data); $response = sendAPICompanySearchRequest($username, $password, $data);
$responseObject = json_decode($response, true); if (!is_string($response) || $response === '') {
return null;
if(array_key_exists("error", $responseObject)) {
return false;
} }
$companyArray = $responseObject['list']; $responseObject = json_decode($response, true);
$companyData = array(); if (!is_array($responseObject) || array_key_exists('error', $responseObject) || !is_array($responseObject['list'] ?? null)) {
error_log('ZEFIX search returned an invalid or error response.');
return null;
}
$companyData = [];
foreach ($responseObject['list'] as $company) {
if (!is_array($company) || !isset($company['uid'])) {
continue;
}
$detailsResponse = sendAPICompanyInfoRequest($username, $password, (string)$company['uid']);
$details = is_string($detailsResponse) ? json_decode($detailsResponse, true) : null;
$companyFullData = is_array($details) && is_array($details[0] ?? null) ? $details[0] : null;
if ($companyFullData === null) {
continue;
}
foreach ($companyArray as $company) {
$companyFullData = json_decode(sendAPICompanyInfoRequest($username, $password, $company['uid']), true)[0];
$companyData[] = [ $companyData[] = [
$companyFullData['name'], $companyFullData['name'] ?? '',
$companyFullData['address']['careOf'], $companyFullData['address']['careOf'] ?? '',
$companyFullData['address']['street'], $companyFullData['address']['street'] ?? '',
$companyFullData['address']['houseNumber'], $companyFullData['address']['houseNumber'] ?? '',
$companyFullData['address']['swissZipCode'], $companyFullData['address']['swissZipCode'] ?? '',
$companyFullData['address']['city'], $companyFullData['address']['city'] ?? '',
$companyFullData['uid'], $companyFullData['uid'] ?? '',
$companyFullData['legalSeat'], $companyFullData['legalSeat'] ?? '',
$companyFullData['legalForm']['name']['de'], $companyFullData['legalForm']['name']['de'] ?? '',
$companyFullData['status'], $companyFullData['status'] ?? '',
$companyFullData['sogcDate'], $companyFullData['sogcDate'] ?? '',
$companyFullData['deletionDate'] $companyFullData['deletionDate'] ?? '',
]; ];
} }
if (!file_exists($filename)) { $isNewFile = !is_file($filename);
$file = fopen($filename, 'w'); $file = fopen($filename, $isNewFile ? 'x' : 'a');
if($file) { if ($file === false) {
fputcsv($file, ['name', 'careOf', 'street', 'houseNumber', 'swissZipCode', 'city', 'uid', 'legalSeat', 'legalForm', 'status', 'sogcDate', 'deletionDate'], ',', '"', "\\"); error_log('Export file could not be opened.');
} return null;
} else {
$file = fopen($filename, 'a');
} }
if ($file) { try {
foreach ($companyData as $row){ if ($isNewFile) {
fputcsv($file, $row, ',', '"', "\\"); fputcsv($file, ['name', 'careOf', 'street', 'houseNumber', 'swissZipCode', 'city', 'uid', 'legalSeat', 'legalForm', 'status', 'sogcDate', 'deletionDate'], ',', '"', '\\');
@chmod($filename, 0600);
} }
foreach ($companyData as $row) {
fputcsv($file, $row, ',', '"', '\\');
}
} finally {
fclose($file); fclose($file);
} }
if($responseObject['hasMoreResults']) { return ($responseObject['hasMoreResults'] ?? false)
return $responseObject['maxOffset']; ? (int)($responseObject['maxOffset'] ?? 0)
} else { : false;
}
function saveTask(string $filename, array $task): bool
{
$temporary = $filename . '.tmp';
$written = file_put_contents($temporary, json_encode($task, JSON_THROW_ON_ERROR), LOCK_EX);
if ($written === false || !rename($temporary, $filename)) {
@unlink($temporary);
return false; return false;
} }
@chmod($filename, 0600);
return true;
} }
while($latesEndTime - time() > 30){ try {
if(is_dir($taskDir)) { while ($latestEndTime - time() > 30) {
$taskfiles = scandir($taskDir); $taskFiles = glob(app_task_dir() . DIRECTORY_SEPARATOR . '*.json') ?: [];
$taskfiles = array_diff($taskfiles, array('.', '..')); sort($taskFiles, SORT_STRING);
sort($taskfiles);
if(count($taskfiles) > 0 && intval(explode("-", $taskfiles[0])[0]) + $minTaskOldness < time()) { if ($taskFiles === []) {
$taskString = file_get_contents($taskDir.'/'.$taskfiles[0]); fwrite(STDOUT, "Nothing to do.\n");
$task = json_decode($taskString, true); break;
}
if(count($task['requests']) > 0) { $taskFilename = $taskFiles[0];
$csvFile = str_replace(".json", ".csv", $downloadDir.'/'.$taskfiles[0]); $modified = filemtime($taskFilename);
if ($modified !== false && $modified + $minimumTaskAge > time()) {
sleep(min(5, max(1, ($modified + $minimumTaskAge) - time())));
continue;
}
$nextOffset = doRequest($task['requests'][0], $csvFile, $username, $password); $task = json_decode((string)file_get_contents($taskFilename), true);
if (!is_array($task) || !is_array($task['requests'] ?? null) || !filter_var($task['email'] ?? '', FILTER_VALIDATE_EMAIL)) {
error_log('Invalid task quarantined: ' . basename($taskFilename));
@rename($taskFilename, $taskFilename . '.invalid');
continue;
}
if($nextOffset){ if (!preg_match('/^[a-f0-9]{32}$/', (string)($task['downloadToken'] ?? ''))) {
$task['requests'][0]['offset'] = $nextOffset; $task['downloadToken'] = bin2hex(random_bytes(16));
} else {
array_shift($task['requests']);
}
$taskString = json_encode($task); // Preserve partially generated exports from the previous public-download layout.
$taskfile = fopen($taskDir.'/'.$taskfiles[0], 'w'); $legacyCsv = __DIR__ . DIRECTORY_SEPARATOR . 'download' . DIRECTORY_SEPARATOR . pathinfo($taskFilename, PATHINFO_FILENAME) . '.csv';
if (is_file($legacyCsv)) {
if($taskfile){ @rename($legacyCsv, app_download_path($task['downloadToken']));
fwrite($taskfile, $taskString);
fclose($taskfile);
}
} else {
$csvFile = str_replace(".json", ".csv", $downloadDir.'/'.$taskfiles[0]);
unlink($taskDir.'/'.$taskfiles[0]);
echo "Task File deleted";
echo "<br>";
sendEmail($task['email'], $csvFile, $smtppassword);
} }
if (!saveTask($taskFilename, $task)) {
error_log('Could not migrate legacy task metadata.');
break;
}
}
$csvFile = app_download_path((string)$task['downloadToken']);
if ($task['requests'] !== []) {
$nextOffset = doRequest($task['requests'][0], $csvFile, (string)$username, (string)$password);
if ($nextOffset === null) {
fwrite(STDOUT, "Temporary API error; task retained for retry.\n");
break;
}
if ($nextOffset !== false) {
$task['requests'][0]['offset'] = $nextOffset;
} else {
array_shift($task['requests']);
}
if (!saveTask($taskFilename, $task)) {
error_log('Could not save task progress.');
break;
}
continue;
}
if (!is_file($csvFile)) {
$emptyFile = fopen($csvFile, 'x');
if ($emptyFile !== false) {
fputcsv($emptyFile, ['name', 'careOf', 'street', 'houseNumber', 'swissZipCode', 'city', 'uid', 'legalSeat', 'legalForm', 'status', 'sogcDate', 'deletionDate'], ',', '"', '\\');
fclose($emptyFile);
@chmod($csvFile, 0600);
}
}
if (sendEmail((string)$task['email'], (string)$task['downloadToken'], $smtpPassword)) {
unlink($taskFilename);
fwrite(STDOUT, 'Completed ' . basename($taskFilename) . ".\n");
} else { } else {
echo "nothing to do after: ".strval(time() - ($latesEndTime - $maxExecutionTime)); fwrite(STDOUT, "Email delivery failed; task retained for retry.\n");
echo "sleeping 10 seconds"; break;
echo "<br>";
sleep(10);
} }
} }
} finally {
flock($executorLock, LOCK_UN);
fclose($executorLock);
} }
?>
+36 -11
View File
@@ -1,5 +1,5 @@
<?php <?php
include 'env_vars.php'; require_once __DIR__ . '/app.php';
$username = getenv("username"); $username = getenv("username");
$password = getenv("password"); $password = getenv("password");
@@ -22,13 +22,16 @@ function sendAPICompanyInfoRequest(string $username, string $password, string $u
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, false); curl_setopt($ch, CURLOPT_POST, false);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // Disable SSL verification curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
curl_setopt($ch, CURLOPT_TIMEOUT, 20);
// Execute cURL session and get the response // Execute cURL session and get the response
$response = curl_exec($ch); $response = curl_exec($ch);
// Check for cURL errors // Check for cURL errors
if (curl_errno($ch)) { if (curl_errno($ch)) {
echo 'Curl error: ' . curl_error($ch); error_log('ZEFIX company info request failed: ' . curl_error($ch));
} }
// Close cURL session // Close cURL session
curl_close($ch); curl_close($ch);
@@ -61,13 +64,16 @@ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data)); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // Disable SSL verification curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
// Execute cURL session and get the response // Execute cURL session and get the response
$response = curl_exec($ch); $response = curl_exec($ch);
// Check for cURL errors // Check for cURL errors
if (curl_errno($ch)) { if (curl_errno($ch)) {
echo 'Curl error: ' . curl_error($ch); error_log('ZEFIX company search request failed: ' . curl_error($ch));
} }
// Close cURL session // Close cURL session
curl_close($ch); curl_close($ch);
@@ -94,13 +100,16 @@ function sendAPICommunityRequest(string $username, string $password): string|boo
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, false); curl_setopt($ch, CURLOPT_POST, false);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // Disable SSL verification curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
curl_setopt($ch, CURLOPT_TIMEOUT, 20);
// Execute cURL session and get the response // Execute cURL session and get the response
$response = curl_exec($ch); $response = curl_exec($ch);
// Check for cURL errors // Check for cURL errors
if (curl_errno($ch)) { if (curl_errno($ch)) {
echo 'Curl error: ' . curl_error($ch); error_log('ZEFIX community request failed: ' . curl_error($ch));
} }
// Close cURL session // Close cURL session
curl_close($ch); curl_close($ch);
@@ -110,8 +119,15 @@ function sendAPICommunityRequest(string $username, string $password): string|boo
function communityCSV(string $username, string $password): string { function communityCSV(string $username, string $password): string {
$response = sendAPICommunityRequest($username, $password); $response = sendAPICommunityRequest($username, $password);
if (!is_string($response)) {
return '';
}
$communityArray = json_decode($response, true); $communityArray = json_decode($response, true);
if (!is_array($communityArray)) {
return '';
}
$csvOutput = "id,bfsId,Kanton,Gemeindename,registryOfCommerceId,replacedById\n"; $csvOutput = "id,bfsId,Kanton,Gemeindename,registryOfCommerceId,replacedById\n";
foreach ($communityArray as $item) { foreach ($communityArray as $item) {
@@ -150,13 +166,16 @@ function sendAPILegalFormRequest(string $username, string $password): string|boo
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, false); curl_setopt($ch, CURLOPT_POST, false);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // Disable SSL verification curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
curl_setopt($ch, CURLOPT_TIMEOUT, 20);
// Execute cURL session and get the response // Execute cURL session and get the response
$response = curl_exec($ch); $response = curl_exec($ch);
// Check for cURL errors // Check for cURL errors
if (curl_errno($ch)) { if (curl_errno($ch)) {
echo 'Curl error: ' . curl_error($ch); error_log('ZEFIX legal form request failed: ' . curl_error($ch));
} }
// Close cURL session // Close cURL session
curl_close($ch); curl_close($ch);
@@ -166,8 +185,14 @@ function sendAPILegalFormRequest(string $username, string $password): string|boo
function legalFormCSV(string $username, string $password): string { function legalFormCSV(string $username, string $password): string {
$response = sendAPILegalFormRequest($username, $password); $response = sendAPILegalFormRequest($username, $password);
if (!is_string($response)) {
return '';
}
$legalformArray = json_decode($response, true); $legalformArray = json_decode($response, true);
$csvOutput = 'id,name\n'; if (!is_array($legalformArray)) {
return '';
}
$csvOutput = "id,name\n";
// Create CSV rows // Create CSV rows
foreach ($legalformArray as $item) { foreach ($legalformArray as $item) {
@@ -176,4 +201,4 @@ function legalFormCSV(string $username, string $password): string {
return $csvOutput; return $csvOutput;
} }
?> ?>