|Closure|string $to * @param array $options */ protected function create(string $verb, string $from, $to, ?array $options = null): void { $overwrite = false; $prefix = $this->group === null ? '' : $this->group . '/'; $from = esc(strip_tags($prefix . $from)); // While we want to add a route within a group of '/', // it doesn't work with matching, so remove them... if ($from !== '/') { $from = trim($from, '/'); } // When redirecting to named route, $to is an array like `['zombies' => '\Zombies::index']`. if (is_array($to) && count($to) === 2) { $to = $this->processArrayCallableSyntax($from, $to); } $options = array_merge($this->currentOptions ?? [], $options ?? []); // Route priority detect if (isset($options['priority'])) { $options['priority'] = abs((int) $options['priority']); if ($options['priority'] > 0) { $this->prioritizeDetected = true; } } // Hostname limiting? if (! empty($options['hostname'])) { // @todo determine if there's a way to whitelist hosts? if (! $this->checkHostname($options['hostname'])) { return; } $overwrite = true; } // Limiting to subdomains? elseif (! empty($options['subdomain'])) { // If we don't match the current subdomain, then // we don't need to add the route. if (! $this->checkSubdomains($options['subdomain'])) { return; } $overwrite = true; } // Are we offsetting the binds? // If so, take care of them here in one // fell swoop. if (isset($options['offset']) && is_string($to)) { // Get a constant string to work with. $to = preg_replace('/(\$\d+)/', '$X', $to); for ($i = (int) $options['offset'] + 1; $i < (int) $options['offset'] + 7; ++$i) { $to = preg_replace_callback('/\$X/', static fn ($m): string => '$' . $i, $to, 1); } } // Replace our regex pattern placeholders with the actual thing // so that the Router doesn't need to know about any of this. foreach ($this->placeholders as $tag => $pattern) { $from = str_ireplace(':' . $tag, $pattern, $from); } // If is redirect, No processing if (! isset($options['redirect']) && is_string($to)) { // If no namespace found, add the default namespace if (strpos($to, '\\') === false || strpos($to, '\\') > 0) { $namespace = $options['namespace'] ?? $this->defaultNamespace; $to = trim((string) $namespace, '\\') . '\\' . $to; } // Always ensure that we escape our namespace so we're not pointing to // \CodeIgniter\Routes\Controller::method. $to = '\\' . ltrim($to, '\\'); } $name = $options['as'] ?? $from; helper('array'); // Don't overwrite any existing 'froms' so that auto-discovered routes // do not overwrite any app/Config/Routes settings. The app // routes should always be the "source of truth". // this works only because discovered routes are added just prior // to attempting to route the request. // TODO: see how to overwrite routes differently // restored change that broke Castopod routing with fediverse // in CI4 v4.2.8 https://github.com/codeigniter4/CodeIgniter4/pull/6644 if (isset($this->routes[$verb][$name]) && ! $overwrite) { return; } $this->routes[$verb][$name] = [ 'route' => [ $from => $to, ], ]; $this->routesOptions[$verb][$from] = $options; // Is this a redirect? if (isset($options['redirect']) && is_numeric($options['redirect'])) { $this->routes['*'][$name]['redirect'] = $options['redirect']; } } /** * Compares the hostname passed in against the current hostname * on this page request. * * @param string $hostname Hostname in route options */ private function checkHostname($hostname): bool { // CLI calls can't be on hostname. if ($this->httpHost === null) { return false; } return strtolower($this->httpHost) === strtolower($hostname); } /** * @param array $to * * @return string|array */ private function processArrayCallableSyntax(string $from, array $to): string | array { // [classname, method] // eg, [Home::class, 'index'] if (is_callable($to, true, $callableName)) { // If the route has placeholders, add params automatically. $params = $this->getMethodParams($from); return '\\' . $callableName . $params; } // [[classname, method], params] // eg, [[Home::class, 'index'], '$1/$2'] if ( isset($to[0], $to[1]) && is_callable($to[0], true, $callableName) && is_string($to[1]) ) { return '\\' . $callableName . '/' . $to[1]; } return $to; } /** * Compares the subdomain(s) passed in against the current subdomain * on this page request. * * @param string|string[] $subdomains */ private function checkSubdomains($subdomains): bool { // CLI calls can't be on subdomain. if ($this->httpHost === null) { return false; } if ($this->currentSubdomain === null) { $this->currentSubdomain = $this->determineCurrentSubdomain(); } if (! is_array($subdomains)) { $subdomains = [$subdomains]; } // Routes can be limited to any sub-domain. In that case, though, // it does require a sub-domain to be present. if (! empty($this->currentSubdomain) && in_array('*', $subdomains, true)) { return true; } return in_array($this->currentSubdomain, $subdomains, true); } /** * Returns the method param string like `/$1/$2` for placeholders */ private function getMethodParams(string $from): string { preg_match_all('/\(.+?\)/', $from, $matches); $count = is_countable($matches[0]) ? count($matches[0]) : 0; $params = ''; for ($i = 1; $i <= $count; ++$i) { $params .= '/$' . $i; } return $params; } /** * Examines the HTTP_HOST to get the best match for the subdomain. It * won't be perfect, but should work for our needs. * * It's especially not perfect since it's possible to register a domain * with a period (.) as part of the domain name. * * @return false|string the subdomain */ private function determineCurrentSubdomain() { // We have to ensure that a scheme exists // on the URL else parse_url will mis-interpret // 'host' as the 'path'. $url = $this->httpHost; if (strpos($url, 'http') !== 0) { $url = 'http://' . $url; } $parsedUrl = parse_url($url); $host = explode('.', $parsedUrl['host']); if ($host[0] === 'www') { unset($host[0]); } // Get rid of any domains, which will be the last unset($host[count($host) - 1]); // Account for .co.uk, .co.nz, etc. domains if (end($host) === 'co') { $host = array_slice($host, 0, -1); } // If we only have 1 part left, then we don't have a sub-domain. if (count($host) === 1) { // Set it to false so we don't make it back here again. return false; } return array_shift($host); } }