65536) { error_log('Environment file is unexpectedly large: ' . $filename); return; } $lines = file($filename, FILE_IGNORE_NEW_LINES); if ($lines === false) { error_log('Environment file could not be read: ' . $filename); return; } foreach ($lines as $lineNumber => $line) { if ($lineNumber === 0) { $line = preg_replace('/^\xEF\xBB\xBF/', '', $line) ?? $line; } $line = trim($line); if ($line === '' || $line[0] === '#') { continue; } if (strncmp($line, 'export ', 7) === 0) { $line = ltrim(substr($line, 7)); } $separator = strpos($line, '='); if ($separator === false) { error_log('Ignoring invalid environment entry on line ' . ($lineNumber + 1) . '.'); continue; } $name = trim(substr($line, 0, $separator)); if (preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $name) !== 1) { error_log('Ignoring invalid environment variable name on line ' . ($lineNumber + 1) . '.'); continue; } // Non-empty process variables and legacy configuration take precedence. $processValue = getenv($name); if ($processValue !== false && trim((string)$processValue) !== '') { continue; } $value = trim(substr($line, $separator + 1)); $valueLength = strlen($value); if ($valueLength >= 2 && $value[0] === '"' && $value[$valueLength - 1] === '"') { $value = stripcslashes(substr($value, 1, -1)); } elseif ($valueLength >= 2 && $value[0] === "'" && $value[$valueLength - 1] === "'") { $value = substr($value, 1, -1); } else { $value = preg_replace('/\s+#.*$/', '', $value) ?? $value; $value = rtrim($value); } if (strpos($value, "\0") !== false) { error_log('Ignoring environment value containing a null byte on line ' . ($lineNumber + 1) . '.'); continue; } $appFileEnvironment[$name] = $value; $_ENV[$name] = $value; } } $legacyEnvFile = __DIR__ . '/env_vars.php'; if (is_file($legacyEnvFile)) { require_once $legacyEnvFile; } app_load_env_file(dirname(__DIR__) . DIRECTORY_SEPARATOR . '.env'); function app_env(string $name, string $default = ''): string { global $appFileEnvironment; $value = getenv($name); if ($value !== false && trim((string)$value) !== '') { return trim((string)$value); } if (array_key_exists($name, $appFileEnvironment)) { return trim((string)$appFileEnvironment[$name]); } return $default; } 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; }