Add Turnstile protection and harden export workflow
This commit is contained in:
@@ -0,0 +1 @@
|
||||
*.sh text eol=lf
|
||||
@@ -0,0 +1,6 @@
|
||||
/env_vars.php
|
||||
/tasks/
|
||||
/download/
|
||||
/var/
|
||||
/*.log
|
||||
/.phpunit.cache/
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -12,6 +12,7 @@ cd "$BASE_DIR" || { echo "cd failed: $BASE_DIR" >> "$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
|
||||
exit $status
|
||||
|
||||
+176
-6
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+136
@@ -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
@@ -1,13 +1,23 @@
|
||||
<?php
|
||||
$taskDir = 'tasks';
|
||||
include 'env_vars.php';
|
||||
if($_POST["taskDeletePassword"] == getenv("taskDeletePassword")){
|
||||
$files = scandir($taskDir);
|
||||
$files = array_diff($files, array('.', '..'));
|
||||
foreach ($files as $file) {
|
||||
echo $file;
|
||||
unlink($taskDir.'/'.$file);
|
||||
declare(strict_types=1);
|
||||
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
@@ -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
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/app.php';
|
||||
require_once "PHPMailer.php";
|
||||
require_once "SMTP.php";
|
||||
require_once "Exception.php";
|
||||
@@ -7,39 +9,43 @@ require_once "Exception.php";
|
||||
use PHPMailer\PHPMailer\PHPMailer;
|
||||
|
||||
|
||||
function sendEmail($emailAddress, $filename, $smtppassword)
|
||||
function sendEmail(string $emailAddress, string $downloadToken, string $smtpPassword): bool
|
||||
{
|
||||
$mail = new PHPMailer();
|
||||
|
||||
$mail->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.<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($mail->send()){
|
||||
$status = "success";
|
||||
$response = "Email is sent!";
|
||||
}
|
||||
else{
|
||||
$status = "failed";
|
||||
$response = "Something is wrong: <br>" . $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;
|
||||
}
|
||||
?>
|
||||
|
||||
|
||||
|
||||
+6
-2
@@ -1,4 +1,8 @@
|
||||
|
||||
<?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) . ';';
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/app.php';
|
||||
app_send_security_headers();
|
||||
include "zefixAPI.php";
|
||||
$turnstileSiteKey = app_turnstile_is_configured() ? app_turnstile_site_key() : '';
|
||||
$formStartedToken = app_create_form_started_token();
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
@@ -8,9 +12,12 @@ include "zefixAPI.php";
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<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/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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
</nav>
|
||||
|
||||
@@ -56,53 +77,73 @@ include "zefixAPI.php";
|
||||
<div class="row justify-content-center">
|
||||
<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="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>
|
||||
|
||||
<!-- Firmenname -->
|
||||
<div class="mb-4">
|
||||
<label for="firma" class="form-label">Firmenname (optional)</label>
|
||||
<div class="form-text mb-2">* kann als Platzhalter verwendet werden</div>
|
||||
<input type="text" id="firma" name="firma" class="form-control" placeholder="z.B. *solar*">
|
||||
<input type="hidden" name="form_started" value="<?php echo htmlspecialchars($formStartedToken, ENT_QUOTES); ?>">
|
||||
<div class="bot-trap" aria-hidden="true" hidden>
|
||||
<label for="website">Website</label>
|
||||
<input type="text" id="website" name="website" value="" tabindex="-1" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<!-- Kanton -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label">Kanton</label>
|
||||
<div class="alert alert-warning py-2 mb-2" role="alert" style="font-size: .95rem;">
|
||||
Die Kantonsauswahl wird vom Export ignoriert und dient nur zum Filtern der Ortschaften.
|
||||
Für Export nach Kanton: gewünschte Kantone wählen und dann bei Ortschaften auf
|
||||
<b>Alle Ergebnisse auswählen</b> klicken.
|
||||
<div class="form-section mb-3">
|
||||
<div class="section-heading">
|
||||
<span class="section-number" aria-hidden="true">1</span>
|
||||
<div>
|
||||
<h2>Firmenname</h2>
|
||||
<p>Optional: Schränken Sie den Export auf passende Firmennamen ein.</p>
|
||||
</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>
|
||||
|
||||
<!-- Sitz / Ort -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label">Sitz (Ort)</label>
|
||||
<div class="form-section mb-3">
|
||||
<div class="section-heading">
|
||||
<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-body p-0">
|
||||
<div class="d-flex flex-column flex-lg-row gap-2 mb-2">
|
||||
<div class="flex-grow-1">
|
||||
<input type="text"
|
||||
placeholder="Suchen (Ort)"
|
||||
placeholder="Ort suchen"
|
||||
id="sitzInput"
|
||||
onkeyup="filterFunction()"
|
||||
onchange="filterFunction()"
|
||||
@@ -112,7 +153,7 @@ include "zefixAPI.php";
|
||||
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<button type="button" class="btn btn-outline-primary button-small" onclick="gefundeneGemeindenAuswählen()">
|
||||
Alle Ergebnisse auswählen
|
||||
Gefilterte Orte auswählen
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary button-small" onclick="ausgewählteGemeindenLöschen()">
|
||||
Auswahl löschen
|
||||
@@ -121,7 +162,7 @@ include "zefixAPI.php";
|
||||
Alle anzeigen
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary button-small" onclick="nurAusgewählteGemeindenAnzeigen()">
|
||||
Nur ausgewählte
|
||||
Nur ausgewählte anzeigen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -130,25 +171,43 @@ include "zefixAPI.php";
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rechtsform -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label">Rechtsform</label>
|
||||
<div class="form-section mb-3">
|
||||
<div class="section-heading">
|
||||
<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()">
|
||||
Alle auswählen
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary button-small" onclick="rechtsformenLöschen()">
|
||||
Auswahl löschen
|
||||
</button>
|
||||
</div>
|
||||
<span class="selection-meta" id="legalFormCount">0 ausgewählt</span>
|
||||
</div>
|
||||
|
||||
<div id="rechtsformenauswahl" class="scrollWindow"></div>
|
||||
</div>
|
||||
|
||||
<!-- Optionen -->
|
||||
<div class="mb-4">
|
||||
<div class="form-section mb-3">
|
||||
<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">
|
||||
<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>
|
||||
@@ -168,23 +227,43 @@ include "zefixAPI.php";
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<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">
|
||||
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>
|
||||
<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>
|
||||
|
||||
<?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">
|
||||
<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 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>
|
||||
|
||||
</form>
|
||||
@@ -233,9 +312,11 @@ include "zefixAPI.php";
|
||||
input.type = "checkbox";
|
||||
input.name = checkboxName;
|
||||
input.value = checkboxValue;
|
||||
input.id = checkboxName.replace(/[^a-z0-9]/gi, '-') + '-' + String(checkboxValue).replace(/[^a-z0-9]/gi, '-');
|
||||
|
||||
const label = document.createElement('label');
|
||||
label.className = "form-check-label";
|
||||
label.htmlFor = input.id;
|
||||
label.textContent = labelText;
|
||||
|
||||
wrapper.appendChild(input);
|
||||
@@ -305,9 +386,11 @@ include "zefixAPI.php";
|
||||
input.type = "checkbox";
|
||||
input.name = "sitze[]";
|
||||
input.value = bfsId;
|
||||
input.id = "sitz-" + bfsId;
|
||||
|
||||
const label = document.createElement('label');
|
||||
label.className = "form-check-label";
|
||||
label.htmlFor = input.id;
|
||||
label.textContent = " " + gemeindeName;
|
||||
|
||||
wrapper.appendChild(input);
|
||||
@@ -319,6 +402,22 @@ include "zefixAPI.php";
|
||||
|
||||
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() {
|
||||
const checkboxes = document.querySelectorAll('input[name="kantone[]"]');
|
||||
let countChecked = 0;
|
||||
@@ -374,6 +473,7 @@ include "zefixAPI.php";
|
||||
cb.checked = false;
|
||||
});
|
||||
filterFunction();
|
||||
updateSelectionCounts();
|
||||
}
|
||||
|
||||
function gefundeneGemeindenAuswählen() {
|
||||
@@ -383,19 +483,24 @@ include "zefixAPI.php";
|
||||
if (entry && entry.style.display !== "none") cb.checked = true;
|
||||
});
|
||||
filterFunction();
|
||||
updateSelectionCounts();
|
||||
}
|
||||
|
||||
function alleRechtsformenAuswählen() {
|
||||
document.querySelectorAll('input[name="rechtsformen[]"]').forEach(function(cb) {
|
||||
cb.checked = true;
|
||||
});
|
||||
updateSelectionCounts();
|
||||
}
|
||||
|
||||
function rechtsformenLöschen() {
|
||||
document.querySelectorAll('input[name="rechtsformen[]"]').forEach(function(cb) {
|
||||
cb.checked = false;
|
||||
});
|
||||
updateSelectionCounts();
|
||||
}
|
||||
|
||||
updateSelectionCounts();
|
||||
</script>
|
||||
|
||||
</div>
|
||||
@@ -405,7 +510,7 @@ include "zefixAPI.php";
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="border-top bg-white">
|
||||
<footer class="site-footer border-top">
|
||||
<div class="container py-4">
|
||||
<div class="row g-4">
|
||||
<div class="col-md-4">
|
||||
@@ -416,7 +521,7 @@ include "zefixAPI.php";
|
||||
8245 Feuerthalen<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="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>
|
||||
|
||||
@@ -435,23 +540,10 @@ include "zefixAPI.php";
|
||||
</div>
|
||||
|
||||
<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;">
|
||||
<script>
|
||||
const deleteFiles = function () {
|
||||
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>
|
||||
Wir verwenden Ihre E-Mail-Adresse ausschliesslich zur Bereitstellung des angeforderten Exports.
|
||||
<br><a href="datenschutz.php">Datenschutzerklärung</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+6
-2
@@ -1,4 +1,8 @@
|
||||
|
||||
<?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
@@ -1,10 +1,13 @@
|
||||
<?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";
|
||||
|
||||
$statusClass = $isError
|
||||
@@ -19,17 +22,12 @@ echo '<!doctype html>
|
||||
<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=1.6">
|
||||
<link rel="stylesheet" href="css/custom.css?v=2.2">
|
||||
<title>' . htmlspecialchars($title) . '</title>
|
||||
|
||||
<style>
|
||||
.brand-card { border-radius: 1rem; }
|
||||
|
||||
.brand-logo {
|
||||
height: 42px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.brand-box {
|
||||
border: none;
|
||||
border-radius: 1rem;
|
||||
@@ -46,23 +44,18 @@ echo '<!doctype html>
|
||||
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>
|
||||
</head>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
</nav>
|
||||
|
||||
@@ -93,10 +86,10 @@ echo '<!doctype html>
|
||||
|
||||
<div class="d-flex align-items-start mb-3">
|
||||
|
||||
<div class="flex-shrink-0 logo-wrap">
|
||||
<img src="' . htmlspecialchars($logoPath) . '" alt="Silias Logo"
|
||||
class="brand-logo"
|
||||
onerror="this.style.display=\'none\';">
|
||||
<div class="flex-shrink-0">
|
||||
<span class="brand-logo-crop d-block">
|
||||
<img src="' . htmlspecialchars($logoPath) . '" alt="Silias">
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="ms-4">
|
||||
@@ -133,73 +126,219 @@ echo '<!doctype html>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="border-top bg-white">
|
||||
<div class="container py-3 text-center text-muted small">
|
||||
© ' . date('Y') . ' Silias KLG
|
||||
</div>
|
||||
</footer>
|
||||
<footer class="border-top bg-white">
|
||||
<div class="container py-3 text-center text-muted small">
|
||||
© ' . date('Y') . ' Silias KLG · <a href="datenschutz.php">Datenschutzerklärung</a>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>';
|
||||
}
|
||||
|
||||
/* ---------------- VALIDATION ---------------- */
|
||||
/* ---------------- REQUEST AND ABUSE PROTECTION ---------------- */
|
||||
|
||||
$email = isset($_POST['email']) ? trim($_POST['email']) : '';
|
||||
|
||||
if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
renderPage("Ungültige E-Mail", "Bitte geben Sie eine gültige E-Mail-Adresse an.", true);
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
|
||||
header('Allow: POST');
|
||||
renderPage('Methode nicht erlaubt', 'Bitte starten Sie den Export über das Formular auf der Startseite.', true, 405);
|
||||
exit;
|
||||
}
|
||||
|
||||
/* ---------------- TASK BUILD ---------------- */
|
||||
|
||||
$data = [];
|
||||
$data["maxEntries"] = 50;
|
||||
$data["offset"] = 0;
|
||||
$data["languageKey"] = "de";
|
||||
|
||||
if (!empty($_POST['firma'])) {
|
||||
$data["name"] = $_POST['firma'];
|
||||
// Bots commonly fill fields that are deliberately hidden from real visitors.
|
||||
$honeypot = isset($_POST['website']) && is_scalar($_POST['website']) ? trim((string)$_POST['website']) : '';
|
||||
if ($honeypot !== '') {
|
||||
renderPage(
|
||||
'Auftrag eingegangen',
|
||||
'Wir haben Ihren Auftrag erhalten. Sie erhalten von uns eine E-Mail, sobald die Daten zum Download bereit sind.',
|
||||
false
|
||||
);
|
||||
exit;
|
||||
}
|
||||
|
||||
$data["deletedFirms"] = isset($_POST['geloeschteRechtseinheiten']);
|
||||
$data["searchType"] = "exact";
|
||||
if (!app_turnstile_is_configured()) {
|
||||
renderPage('Export nicht verfügbar', 'Der Missbrauchsschutz ist noch nicht vollständig konfiguriert.', true, 503);
|
||||
exit;
|
||||
}
|
||||
|
||||
$rechtsformen = $_POST['rechtsformen'] ?? [];
|
||||
$sitze = $_POST['sitze'] ?? [];
|
||||
$clientIp = app_client_ip();
|
||||
try {
|
||||
$ipLimit = app_rate_limit(
|
||||
'submit-ip',
|
||||
$clientIp,
|
||||
app_env_int('RATE_LIMIT_IP_MAX', 3, 1, 100),
|
||||
app_env_int('RATE_LIMIT_IP_WINDOW_SECONDS', 900, 60, 86400)
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
error_log('Rate limit failure: ' . $exception->getMessage());
|
||||
renderPage('Export nicht verfügbar', 'Der Export kann momentan nicht sicher verarbeitet werden. Bitte versuchen Sie es später erneut.', true, 503);
|
||||
exit;
|
||||
}
|
||||
|
||||
$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) {
|
||||
$data["legalForms"] = [(int)$rf];
|
||||
$turnstileToken = isset($_POST['cf-turnstile-response']) && is_scalar($_POST['cf-turnstile-response'])
|
||||
? (string)$_POST['cf-turnstile-response']
|
||||
: '';
|
||||
$turnstileResult = app_verify_turnstile($turnstileToken, $clientIp);
|
||||
if (!$turnstileResult['success']) {
|
||||
renderPage('Bot-Prüfung fehlgeschlagen', 'Die Sicherheitsprüfung konnte nicht bestätigt werden. Bitte laden Sie die Startseite neu und versuchen Sie es erneut.', true, 403);
|
||||
exit;
|
||||
}
|
||||
|
||||
foreach ($sitze as $s) {
|
||||
$data["legalSeats"] = [(int)$s];
|
||||
$requests_to_do[] = $data;
|
||||
$formStarted = isset($_POST['form_started']) && is_scalar($_POST['form_started']) ? (string)$_POST['form_started'] : '';
|
||||
if (!app_validate_form_started_token($formStarted)) {
|
||||
renderPage('Überprüfung fehlgeschlagen', 'Das Formular ist abgelaufen oder wurde zu schnell übermittelt. Bitte laden Sie die Startseite neu.', true, 400);
|
||||
exit;
|
||||
}
|
||||
|
||||
/* ---------------- INPUT VALIDATION ---------------- */
|
||||
|
||||
$emailValue = $_POST['email'] ?? '';
|
||||
$email = is_scalar($emailValue) ? strtolower(trim((string)$emailValue)) : '';
|
||||
if ($email === '' || strlen($email) > 254 || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
renderPage('Ungültige E-Mail', 'Bitte geben Sie eine gültige E-Mail-Adresse an.', true, 400);
|
||||
exit;
|
||||
}
|
||||
|
||||
$companyValue = $_POST['firma'] ?? '';
|
||||
$companyName = is_scalar($companyValue) ? trim((string)$companyValue) : '';
|
||||
$companyNameLength = function_exists('mb_strlen') ? mb_strlen($companyName) : strlen($companyName);
|
||||
if ($companyNameLength > 200 || preg_match('//u', $companyName) !== 1 || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', $companyName)) {
|
||||
renderPage('Ungültiger Firmenname', 'Der Firmenname enthält ungültige Zeichen oder ist zu lang.', true, 400);
|
||||
exit;
|
||||
}
|
||||
|
||||
$maximumSeats = app_env_int('MAX_SEATS_PER_JOB', 500, 1, 5000);
|
||||
$maximumLegalForms = app_env_int('MAX_LEGAL_FORMS_PER_JOB', 50, 1, 200);
|
||||
$seats = app_normalize_positive_id_list($_POST['sitze'] ?? null, $maximumSeats);
|
||||
$legalForms = app_normalize_positive_id_list($_POST['rechtsformen'] ?? null, $maximumLegalForms);
|
||||
if ($seats === null || $legalForms === null) {
|
||||
renderPage('Ungültige Auswahl', 'Bitte wählen Sie eine zulässige Anzahl Orte und Rechtsformen aus.', true, 400);
|
||||
exit;
|
||||
}
|
||||
|
||||
sort($seats, SORT_NUMERIC);
|
||||
sort($legalForms, SORT_NUMERIC);
|
||||
$requestCount = count($seats) * count($legalForms);
|
||||
$maximumRequests = app_env_int('MAX_REQUESTS_PER_JOB', 5000, 1, 50000);
|
||||
if ($requestCount > $maximumRequests) {
|
||||
renderPage(
|
||||
'Auswahl zu gross',
|
||||
'Diese Auswahl würde ' . number_format($requestCount, 0, ',', "'") . ' einzelne Abfragen erzeugen. Erlaubt sind maximal ' . number_format($maximumRequests, 0, ',', "'") . '. Bitte schränken Sie Orte oder Rechtsformen ein.',
|
||||
true,
|
||||
400
|
||||
);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$emailLimit = app_rate_limit(
|
||||
'submit-email',
|
||||
$email,
|
||||
app_env_int('RATE_LIMIT_EMAIL_MAX', 5, 1, 100),
|
||||
app_env_int('RATE_LIMIT_EMAIL_WINDOW_SECONDS', 86400, 300, 604800)
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
error_log('Rate limit failure: ' . $exception->getMessage());
|
||||
renderPage('Export nicht verfügbar', 'Der Export kann momentan nicht sicher verarbeitet werden. Bitte versuchen Sie es später erneut.', true, 503);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!$emailLimit['allowed']) {
|
||||
header('Retry-After: ' . $emailLimit['retry_after']);
|
||||
renderPage('Zu viele Anfragen', 'Für diese E-Mail-Adresse wurden heute bereits mehrere Exporte angefordert.', true, 429);
|
||||
exit;
|
||||
}
|
||||
|
||||
/* ---------------- TASK BUILD AND ATOMIC QUEUE WRITE ---------------- */
|
||||
|
||||
$baseRequest = [
|
||||
'maxEntries' => 50,
|
||||
'offset' => 0,
|
||||
'languageKey' => 'de',
|
||||
'deletedFirms' => isset($_POST['geloeschteRechtseinheiten']),
|
||||
'searchType' => 'exact',
|
||||
];
|
||||
if ($companyName !== '') {
|
||||
$baseRequest['name'] = $companyName;
|
||||
}
|
||||
|
||||
$requestsToDo = [];
|
||||
foreach ($legalForms as $legalForm) {
|
||||
foreach ($seats as $seat) {
|
||||
$request = $baseRequest;
|
||||
$request['legalForms'] = [$legalForm];
|
||||
$request['legalSeats'] = [$seat];
|
||||
$requestsToDo[] = $request;
|
||||
}
|
||||
}
|
||||
|
||||
$taskdata = [
|
||||
'requests' => $requests_to_do,
|
||||
'email' => $email
|
||||
];
|
||||
$fingerprint = hash('sha256', json_encode([
|
||||
'email' => $email,
|
||||
'company' => $companyName,
|
||||
'deleted' => $baseRequest['deletedFirms'],
|
||||
'seats' => $seats,
|
||||
'legalForms' => $legalForms,
|
||||
], JSON_THROW_ON_ERROR));
|
||||
|
||||
if (!is_dir($taskDir)) {
|
||||
mkdir($taskDir, 0755, true);
|
||||
}
|
||||
try {
|
||||
$taskDirectory = app_task_dir();
|
||||
$queueLock = fopen(app_private_dir() . DIRECTORY_SEPARATOR . 'queue.lock', 'c+');
|
||||
if ($queueLock === false || !flock($queueLock, LOCK_EX)) {
|
||||
throw new RuntimeException('Queue lock could not be acquired.');
|
||||
}
|
||||
|
||||
$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(
|
||||
"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.",
|
||||
'Auftrag eingegangen',
|
||||
'Wir haben Ihren Auftrag erhalten. Sie erhalten von uns eine E-Mail, sobald die Daten zum Download bereit sind. Dies kann je nach Datenmenge länger dauern.',
|
||||
false
|
||||
);
|
||||
|
||||
} else {
|
||||
|
||||
renderPage("Fehler", "Ein Fehler ist aufgetreten.", true);
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
error_log('Task creation failed: ' . $exception->getMessage());
|
||||
renderPage('Fehler', 'Der Auftrag konnte momentan nicht gespeichert werden. Bitte versuchen Sie es später erneut.', true, 503);
|
||||
}
|
||||
|
||||
+158
-83
@@ -1,116 +1,191 @@
|
||||
<?php
|
||||
include "zefixAPI.php";
|
||||
include "emailSender.php";
|
||||
declare(strict_types=1);
|
||||
|
||||
$maxExecutionTime = 120;
|
||||
$taskDir = 'tasks';
|
||||
$downloadDir = 'download';
|
||||
$minTaskOldness = 10;
|
||||
$latesEndTime = time() + $maxExecutionTime;
|
||||
$smtppassword = getenv("smtppassword");
|
||||
|
||||
if(!is_dir($downloadDir)){
|
||||
mkdir($downloadDir, 0755, true);
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
|
||||
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);
|
||||
$responseObject = json_decode($response, true);
|
||||
|
||||
if(array_key_exists("error", $responseObject)) {
|
||||
return false;
|
||||
if (!is_string($response) || $response === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$companyArray = $responseObject['list'];
|
||||
$companyData = array();
|
||||
$responseObject = json_decode($response, true);
|
||||
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[] = [
|
||||
$companyFullData['name'],
|
||||
$companyFullData['address']['careOf'],
|
||||
$companyFullData['address']['street'],
|
||||
$companyFullData['address']['houseNumber'],
|
||||
$companyFullData['address']['swissZipCode'],
|
||||
$companyFullData['address']['city'],
|
||||
$companyFullData['uid'],
|
||||
$companyFullData['legalSeat'],
|
||||
$companyFullData['legalForm']['name']['de'],
|
||||
$companyFullData['status'],
|
||||
$companyFullData['sogcDate'],
|
||||
$companyFullData['deletionDate']
|
||||
$companyFullData['name'] ?? '',
|
||||
$companyFullData['address']['careOf'] ?? '',
|
||||
$companyFullData['address']['street'] ?? '',
|
||||
$companyFullData['address']['houseNumber'] ?? '',
|
||||
$companyFullData['address']['swissZipCode'] ?? '',
|
||||
$companyFullData['address']['city'] ?? '',
|
||||
$companyFullData['uid'] ?? '',
|
||||
$companyFullData['legalSeat'] ?? '',
|
||||
$companyFullData['legalForm']['name']['de'] ?? '',
|
||||
$companyFullData['status'] ?? '',
|
||||
$companyFullData['sogcDate'] ?? '',
|
||||
$companyFullData['deletionDate'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
if (!file_exists($filename)) {
|
||||
$file = fopen($filename, 'w');
|
||||
if($file) {
|
||||
fputcsv($file, ['name', 'careOf', 'street', 'houseNumber', 'swissZipCode', 'city', 'uid', 'legalSeat', 'legalForm', 'status', 'sogcDate', 'deletionDate'], ',', '"', "\\");
|
||||
}
|
||||
} else {
|
||||
$file = fopen($filename, 'a');
|
||||
$isNewFile = !is_file($filename);
|
||||
$file = fopen($filename, $isNewFile ? 'x' : 'a');
|
||||
if ($file === false) {
|
||||
error_log('Export file could not be opened.');
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($file) {
|
||||
foreach ($companyData as $row){
|
||||
fputcsv($file, $row, ',', '"', "\\");
|
||||
try {
|
||||
if ($isNewFile) {
|
||||
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);
|
||||
}
|
||||
|
||||
if($responseObject['hasMoreResults']) {
|
||||
return $responseObject['maxOffset'];
|
||||
} else {
|
||||
return ($responseObject['hasMoreResults'] ?? false)
|
||||
? (int)($responseObject['maxOffset'] ?? 0)
|
||||
: 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;
|
||||
}
|
||||
@chmod($filename, 0600);
|
||||
return true;
|
||||
}
|
||||
|
||||
while($latesEndTime - time() > 30){
|
||||
if(is_dir($taskDir)) {
|
||||
$taskfiles = scandir($taskDir);
|
||||
$taskfiles = array_diff($taskfiles, array('.', '..'));
|
||||
sort($taskfiles);
|
||||
try {
|
||||
while ($latestEndTime - time() > 30) {
|
||||
$taskFiles = glob(app_task_dir() . DIRECTORY_SEPARATOR . '*.json') ?: [];
|
||||
sort($taskFiles, SORT_STRING);
|
||||
|
||||
if(count($taskfiles) > 0 && intval(explode("-", $taskfiles[0])[0]) + $minTaskOldness < time()) {
|
||||
$taskString = file_get_contents($taskDir.'/'.$taskfiles[0]);
|
||||
$task = json_decode($taskString, true);
|
||||
if ($taskFiles === []) {
|
||||
fwrite(STDOUT, "Nothing to do.\n");
|
||||
break;
|
||||
}
|
||||
|
||||
if(count($task['requests']) > 0) {
|
||||
$csvFile = str_replace(".json", ".csv", $downloadDir.'/'.$taskfiles[0]);
|
||||
$taskFilename = $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){
|
||||
$task['requests'][0]['offset'] = $nextOffset;
|
||||
} else {
|
||||
array_shift($task['requests']);
|
||||
}
|
||||
if (!preg_match('/^[a-f0-9]{32}$/', (string)($task['downloadToken'] ?? ''))) {
|
||||
$task['downloadToken'] = bin2hex(random_bytes(16));
|
||||
|
||||
$taskString = json_encode($task);
|
||||
$taskfile = fopen($taskDir.'/'.$taskfiles[0], 'w');
|
||||
|
||||
if($taskfile){
|
||||
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);
|
||||
// Preserve partially generated exports from the previous public-download layout.
|
||||
$legacyCsv = __DIR__ . DIRECTORY_SEPARATOR . 'download' . DIRECTORY_SEPARATOR . pathinfo($taskFilename, PATHINFO_FILENAME) . '.csv';
|
||||
if (is_file($legacyCsv)) {
|
||||
@rename($legacyCsv, app_download_path($task['downloadToken']));
|
||||
}
|
||||
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 {
|
||||
echo "nothing to do after: ".strval(time() - ($latesEndTime - $maxExecutionTime));
|
||||
echo "sleeping 10 seconds";
|
||||
echo "<br>";
|
||||
sleep(10);
|
||||
fwrite(STDOUT, "Email delivery failed; task retained for retry.\n");
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
flock($executorLock, LOCK_UN);
|
||||
fclose($executorLock);
|
||||
}
|
||||
|
||||
?>
|
||||
|
||||
+36
-11
@@ -1,5 +1,5 @@
|
||||
<?php
|
||||
include 'env_vars.php';
|
||||
require_once __DIR__ . '/app.php';
|
||||
$username = getenv("username");
|
||||
$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_POST, false);
|
||||
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
|
||||
$response = curl_exec($ch);
|
||||
// Check for cURL errors
|
||||
if (curl_errno($ch)) {
|
||||
echo 'Curl error: ' . curl_error($ch);
|
||||
error_log('ZEFIX company info request failed: ' . curl_error($ch));
|
||||
}
|
||||
// Close cURL session
|
||||
curl_close($ch);
|
||||
@@ -61,13 +64,16 @@ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
|
||||
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
|
||||
$response = curl_exec($ch);
|
||||
// Check for cURL errors
|
||||
if (curl_errno($ch)) {
|
||||
echo 'Curl error: ' . curl_error($ch);
|
||||
error_log('ZEFIX company search request failed: ' . curl_error($ch));
|
||||
}
|
||||
// Close cURL session
|
||||
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_POST, false);
|
||||
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
|
||||
$response = curl_exec($ch);
|
||||
// Check for cURL errors
|
||||
if (curl_errno($ch)) {
|
||||
echo 'Curl error: ' . curl_error($ch);
|
||||
error_log('ZEFIX community request failed: ' . curl_error($ch));
|
||||
}
|
||||
// Close cURL session
|
||||
curl_close($ch);
|
||||
@@ -110,8 +119,15 @@ function sendAPICommunityRequest(string $username, string $password): string|boo
|
||||
|
||||
function communityCSV(string $username, string $password): string {
|
||||
$response = sendAPICommunityRequest($username, $password);
|
||||
if (!is_string($response)) {
|
||||
return '';
|
||||
}
|
||||
$communityArray = json_decode($response, true);
|
||||
|
||||
if (!is_array($communityArray)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$csvOutput = "id,bfsId,Kanton,Gemeindename,registryOfCommerceId,replacedById\n";
|
||||
|
||||
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_POST, false);
|
||||
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
|
||||
$response = curl_exec($ch);
|
||||
// Check for cURL errors
|
||||
if (curl_errno($ch)) {
|
||||
echo 'Curl error: ' . curl_error($ch);
|
||||
error_log('ZEFIX legal form request failed: ' . curl_error($ch));
|
||||
}
|
||||
// Close cURL session
|
||||
curl_close($ch);
|
||||
@@ -166,8 +185,14 @@ function sendAPILegalFormRequest(string $username, string $password): string|boo
|
||||
|
||||
function legalFormCSV(string $username, string $password): string {
|
||||
$response = sendAPILegalFormRequest($username, $password);
|
||||
if (!is_string($response)) {
|
||||
return '';
|
||||
}
|
||||
$legalformArray = json_decode($response, true);
|
||||
$csvOutput = 'id,name\n';
|
||||
if (!is_array($legalformArray)) {
|
||||
return '';
|
||||
}
|
||||
$csvOutput = "id,name\n";
|
||||
// Create CSV rows
|
||||
|
||||
foreach ($legalformArray as $item) {
|
||||
@@ -176,4 +201,4 @@ function legalFormCSV(string $username, string $password): string {
|
||||
return $csvOutput;
|
||||
}
|
||||
|
||||
?>
|
||||
?>
|
||||
|
||||
Reference in New Issue
Block a user