headers = []; foreach ($headers as $k => $v) $this->headers[strtolower($k)] = $v; } public function method(): string { return $this->method; } public function path(): string { return $this->path; } public function header(string $name): ?string { return $this->headers[strtolower($name)] ?? null; } public function body(): string { return $this->body; } public function json(): array { $d = json_decode($this->body, true); return is_array($d) ? $d : []; } public function query(string $k): ?string { return isset($this->query[$k]) ? (string)$this->query[$k] : null; } public function post(string $k): ?string { return isset($this->post[$k]) ? (string)$this->post[$k] : null; } /** * The URL prefix the app is mounted under, derived from SCRIPT_NAME: * '/index.php' => '' (web root), '/move_detect/index.php' => '/move_detect'. * * SCRIPT_NAME is set by the web server, never by the caller. Deriving the mount point * from client-controlled input (a query param, a header) would let anyone relocate the * app and route around the prefix. */ public static function baseFrom(string $scriptName): string { if ($scriptName === '' || $scriptName[0] !== '/') return ''; $dir = str_replace('\\', '/', dirname($scriptName)); return $dir === '/' || $dir === '.' ? '' : rtrim($dir, '/'); } /** * The route path for a REQUEST_URI: query string dropped, mount prefix removed. * Routes are declared as '/login', '/api/events', ... so without this every route 404s * whenever the app is mounted anywhere but the web root. */ public static function pathFor(string $requestUri, string $base): string { // Collapse leading slashes FIRST: parse_url('//api/events') reads 'api' as a // scheme-relative host and returns '/events', silently dropping the first segment. $requestUri = '/' . ltrim($requestUri, '/'); $path = parse_url($requestUri, PHP_URL_PATH) ?: '/'; if ($base !== '' && ($path === $base || str_starts_with($path, $base . '/'))) { // Match $base exactly or as a whole segment. A bare str_starts_with($path, $base) // would also strip '/move_detected/x' and route it as 'ed/x'. $path = substr($path, strlen($base)); } return $path === '' ? '/' : $path; } public static function fromGlobals(): self { $headers = function_exists('getallheaders') ? (getallheaders() ?: []) : []; $base = self::baseFrom((string)($_SERVER['SCRIPT_NAME'] ?? '')); return new self( $_SERVER['REQUEST_METHOD'] ?? 'GET', self::pathFor((string)($_SERVER['REQUEST_URI'] ?? '/'), $base), $headers, file_get_contents('php://input') ?: '', $_GET, $_POST ); } }