= $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; }