'api', 'method' => strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')), 'path' => (string) parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH), ]); header('X-Request-Id: ' . RequestContext::requestHeaderValue()); self::registerShutdownHandler(); self::tryStartAudit(); self::startSystemAuditReporter(); self::setCorsHeaders(); if (strtoupper($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') { http_response_code(204); die(); } self::enforceRateLimit(); if ($requireAuth) { $authenticated = ApiAuth::authenticate(); if (!$authenticated) { ApiResponse::unauthorized(); } } } private static function setCorsHeaders(): void { $rawOrigin = trim((string) ($_SERVER['HTTP_ORIGIN'] ?? '')); if ($rawOrigin === '') { return; } $origin = self::normalizeOrigin($rawOrigin); if ($origin === null) { return; } $allowedOrigins = array_fill_keys(self::settingsApiPolicy()->getApiCorsAllowedOrigins(), true); if (!isset($allowedOrigins[$origin])) { return; } header('Vary: Origin'); header('Access-Control-Allow-Origin: ' . $origin); header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS'); header('Access-Control-Allow-Headers: Authorization, Content-Type, Accept'); header('Access-Control-Max-Age: 86400'); } // Guarantees audit records are written even on uncaught fatal errors. private static function registerShutdownHandler(): void { if (self::$shutdownRegistered) { return; } self::$shutdownRegistered = true; register_shutdown_function(static function (): void { $error = error_get_last(); if (!is_array($error)) { $statusCode = (int) http_response_code(); if ($statusCode <= 0) { $statusCode = 200; } self::tryFinishAudit($statusCode); self::finishSystemAuditReporter($statusCode); return; } $type = (int) $error['type']; $fatalTypes = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR]; if (!in_array($type, $fatalTypes, true)) { $statusCode = (int) http_response_code(); if ($statusCode <= 0) { $statusCode = 200; } self::tryFinishAudit($statusCode); self::finishSystemAuditReporter($statusCode); return; } $statusCode = (int) http_response_code(); if ($statusCode < 400) { $statusCode = 500; } self::tryFinishAudit($statusCode, 'fatal_error'); self::finishSystemAuditReporter($statusCode, 'fatal_error'); }); } private static function normalizeOrigin(string $origin): ?string { $origin = rtrim(trim($origin), '/'); if ($origin === '') { return null; } $parsed = parse_url($origin); if (!is_array($parsed)) { return null; } $scheme = strtolower((string) ($parsed['scheme'] ?? '')); $host = strtolower((string) ($parsed['host'] ?? '')); $port = isset($parsed['port']) ? (int) $parsed['port'] : null; if (!in_array($scheme, ['http', 'https'], true) || $host === '') { return null; } if ( isset($parsed['path']) || isset($parsed['query']) || isset($parsed['fragment']) || isset($parsed['user']) || isset($parsed['pass']) ) { return null; } $defaultPort = ($scheme === 'https') ? 443 : 80; $portSuffix = ($port !== null && $port !== $defaultPort) ? ':' . $port : ''; return $scheme . '://' . $host . $portSuffix; } private static function enforceRateLimit(): void { $ip = trim((string) ($_SERVER['REMOTE_ADDR'] ?? '')); if ($ip === '') { $ip = 'unknown'; } $ipResult = self::rateLimiter()->hit( self::API_RATE_SCOPE_IP, $ip, self::API_RATE_LIMIT_IP, self::API_RATE_WINDOW_SECONDS, self::API_RATE_BLOCK_SECONDS ); if (!($ipResult['allowed'] ?? true)) { ApiResponse::tooManyRequests(max(1, (int) ($ipResult['retry_after'] ?? self::API_RATE_BLOCK_SECONDS))); } $selector = self::extractBearerSelector(); if ($selector === '') { return; } $tokenResult = self::rateLimiter()->hit( self::API_RATE_SCOPE_TOKEN, strtolower($selector) . '|' . $ip, self::API_RATE_LIMIT_TOKEN, self::API_RATE_WINDOW_SECONDS, self::API_RATE_BLOCK_SECONDS ); if (!($tokenResult['allowed'] ?? true)) { ApiResponse::tooManyRequests(max(1, (int) ($tokenResult['retry_after'] ?? self::API_RATE_BLOCK_SECONDS))); } } // API tokens are formatted as "selector:verifier". The selector (24-char hex) is used // as the rate-limit key so a single token can't bypass the per-IP bucket by rotating IPs. private static function extractBearerSelector(): string { $token = ApiAuth::extractBearerToken(); if ($token === '') { return ''; } $parts = explode(':', $token, 2); $selector = trim((string) ($parts[0] ?? '')); if ($selector === '' || !preg_match('/^[a-f0-9]{24}$/i', $selector)) { return ''; } return $selector; } private static function settingsApiPolicy(): SettingsApiPolicyGateway { return self::resolveDependency( self::$settingsApiPolicyGatewayResolver, SettingsApiPolicyGateway::class, 'ApiBootstrap' ); } private static function rateLimiter(): RateLimiterService { return self::resolveDependency(self::$rateLimiterServiceResolver, RateLimiterService::class, 'ApiBootstrap'); } private static function tryStartAudit(): void { try { if (is_callable(self::$apiAuditServiceResolver)) { $service = (self::$apiAuditServiceResolver)(); if (is_object($service) && method_exists($service, 'startRequestContext')) { $service->startRequestContext(); } } } catch (\Throwable) { // fail-open } } private static function tryFinishAudit(int $statusCode, ?string $errorCode = null): void { try { if (is_callable(self::$apiAuditServiceResolver)) { $service = (self::$apiAuditServiceResolver)(); if (is_object($service) && method_exists($service, 'finish')) { $service->finish($statusCode, $errorCode); } } } catch (\Throwable) { // fail-open } } private static function startSystemAuditReporter(): void { try { if (is_callable(self::$apiSystemAuditReporterResolver)) { $service = (self::$apiSystemAuditReporterResolver)(); if (is_object($service) && method_exists($service, 'start')) { $service->start(); } } } catch (\Throwable) { // fail-open } } private static function finishSystemAuditReporter(int $statusCode, ?string $errorCode = null): void { try { if (is_callable(self::$apiSystemAuditReporterResolver)) { $service = (self::$apiSystemAuditReporterResolver)(); if (is_object($service) && method_exists($service, 'finish')) { $service->finish($statusCode, $errorCode); } } } catch (\Throwable) { // fail-open } } private static function resolveDependency(mixed $resolver, string $expectedClass, string $consumer): mixed { if (!is_callable($resolver)) { throw new \RuntimeException($consumer . ' is not configured for dependency: ' . $expectedClass); } $service = $resolver(); if (!$service instanceof $expectedClass) { throw new \RuntimeException($consumer . ' resolver returned invalid dependency: ' . $expectedClass); } return $service; } }