$cfg */ $cfg = require $configPath; $outputDir = rtrim((string)($cfg['output_dir'] ?? __DIR__), '/\\'); $cacheDir = rtrim((string)($cfg['cache_dir'] ?? $outputDir . '/cache'), '/\\'); $catalogPath = $outputDir . '/catalog.json'; $accessLog = $outputDir . '/hcl-catalog-access.log'; $rateLimitDir = $cacheDir; // rate-limit files live alongside other cache files if (!is_dir($rateLimitDir)) { mkdir($rateLimitDir, 0755, true); } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** * Return the client IP address, with the last octet zeroed for IPv4 privacy * (IPv6 last 80 bits zeroed). Never returns an empty string. * * @return string e.g. "203.0.113.0" or "2001:db8::" */ function anonymizeIp(): string { $ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { // Zero the last octet. $parts = explode('.', $ip); $parts[3] = '0'; return implode('.', $parts); } if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { // Expand, zero last 80 bits (last 5 groups of 4 hex digits). $expanded = expandIPv6($ip); if ($expanded !== null) { $groups = explode(':', $expanded); for ($i = 3; $i < 8; $i++) { $groups[$i] = '0000'; } return implode(':', $groups); } } return $ip; } /** * Expand a compressed IPv6 address to its full 8-group form. * * @param string $ip * @return string|null null if not a valid IPv6 address. */ function expandIPv6(string $ip): ?string { $bin = inet_pton($ip); if ($bin === false) { return null; } $hex = bin2hex($bin); $groups = str_split($hex, 4); return implode(':', $groups); } /** * Derive a filesystem-safe token from an anonymized IP (SHA-256, first 16 hex). * * @param string $anonIp * @return string */ function ipHash(string $anonIp): string { return substr(hash('sha256', $anonIp), 0, 16); } /** * Determine the launcher version string from request headers. * * HC Launcher sends either: * X-HCL-Version: 2025.024 * or embeds the version in the User-Agent: * HCLauncher/2025.024 ... * * Falls back to the raw User-Agent if neither pattern matches. * * @return string */ function launcherVersion(): string { $xVersion = $_SERVER['HTTP_X_HCL_VERSION'] ?? ''; if ($xVersion !== '') { return $xVersion; } $ua = $_SERVER['HTTP_USER_AGENT'] ?? ''; if (preg_match('/HCLauncher\/(\S+)/i', $ua, $m)) { return $m[1]; } return ''; } // --------------------------------------------------------------------------- // Metrics: access log // --------------------------------------------------------------------------- $anonIp = anonymizeIp(); $version = launcherVersion(); $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? ''; $timestamp = gmdate('Y-m-d\TH:i:s\Z'); // Sanitize fields before writing to the log (strip tabs and newlines). $logVersion = str_replace(["\t", "\n", "\r"], ' ', $version); $logUserAgent = str_replace(["\t", "\n", "\r"], ' ', $userAgent); $logLine = "{$timestamp}\t{$anonIp}\t{$logVersion}\t{$logUserAgent}\n"; file_put_contents($accessLog, $logLine, FILE_APPEND | LOCK_EX); // --------------------------------------------------------------------------- // Rate limiting (60 requests per hour, file-based) // --------------------------------------------------------------------------- $ipHashStr = ipHash($anonIp); $rateLimitFile = "{$rateLimitDir}/rate_{$ipHashStr}.txt"; $rateWindow = 3600; // seconds $rateLimit = 60; // max requests per window $now = time(); /** @var int[] $requestTimes */ $requestTimes = []; if (is_file($rateLimitFile)) { $raw = file_get_contents($rateLimitFile); if ($raw !== false) { $requestTimes = array_map('intval', array_filter(explode("\n", trim($raw)))); } } // Discard timestamps outside the current window. $requestTimes = array_values(array_filter($requestTimes, fn(int $t) => ($now - $t) < $rateWindow)); if (count($requestTimes) >= $rateLimit) { $oldestInWindow = min($requestTimes); $retryAfter = $rateWindow - ($now - $oldestInWindow); http_response_code(429); header('Content-Type: text/plain'); header("Retry-After: {$retryAfter}"); exit("Too Many Requests. Please retry after {$retryAfter} seconds.\n"); } // Record this request. $requestTimes[] = $now; file_put_contents($rateLimitFile, implode("\n", $requestTimes) . "\n", LOCK_EX); // --------------------------------------------------------------------------- // Verify catalog exists // --------------------------------------------------------------------------- if (!is_file($catalogPath)) { http_response_code(503); header('Content-Type: text/plain'); header('Retry-After: 900'); exit("Catalog not yet generated. Please try again shortly.\n"); } $mtime = filemtime($catalogPath); if ($mtime === false) { http_response_code(500); header('Content-Type: text/plain'); exit("Could not stat catalog file.\n"); } // --------------------------------------------------------------------------- // Conditional GET (If-Modified-Since → 304) // --------------------------------------------------------------------------- $lastModifiedStr = gmdate('D, d M Y H:i:s', $mtime) . ' GMT'; if (isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])) { $ifModifiedSince = strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']); if ($ifModifiedSince !== false && $mtime <= $ifModifiedSince) { http_response_code(304); header("Last-Modified: {$lastModifiedStr}"); exit; } } // --------------------------------------------------------------------------- // Serve catalog.json // --------------------------------------------------------------------------- $body = file_get_contents($catalogPath); if ($body === false) { http_response_code(500); header('Content-Type: text/plain'); exit("Could not read catalog file.\n"); } header('Content-Type: application/json'); header("Last-Modified: {$lastModifiedStr}"); header('Cache-Control: public, max-age=900'); // clients may cache for 15 min header('Content-Length: ' . strlen($body)); echo $body;