Route.php 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664
  1. <?php
  2. /**
  3. * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
  4. * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
  5. *
  6. * Licensed under The MIT License
  7. * For full copyright and license information, please see the LICENSE.txt
  8. * Redistributions of files must retain the above copyright notice.
  9. *
  10. * @copyright Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
  11. * @link http://cakephp.org CakePHP(tm) Project
  12. * @since 1.3.0
  13. * @license http://www.opensource.org/licenses/mit-license.php MIT License
  14. */
  15. namespace Cake\Routing\Route;
  16. use Cake\Network\Request;
  17. use Cake\Routing\Router;
  18. /**
  19. * A single Route used by the Router to connect requests to
  20. * parameter maps.
  21. *
  22. * Not normally created as a standalone. Use Router::connect() to create
  23. * Routes for your application.
  24. *
  25. */
  26. class Route
  27. {
  28. /**
  29. * An array of named segments in a Route.
  30. * `/:controller/:action/:id` has 3 key elements
  31. *
  32. * @var array
  33. */
  34. public $keys = [];
  35. /**
  36. * An array of additional parameters for the Route.
  37. *
  38. * @var array
  39. */
  40. public $options = [];
  41. /**
  42. * Default parameters for a Route
  43. *
  44. * @var array
  45. */
  46. public $defaults = [];
  47. /**
  48. * The routes template string.
  49. *
  50. * @var string
  51. */
  52. public $template = null;
  53. /**
  54. * Is this route a greedy route? Greedy routes have a `/*` in their
  55. * template
  56. *
  57. * @var string
  58. */
  59. protected $_greedy = false;
  60. /**
  61. * The compiled route regular expression
  62. *
  63. * @var string
  64. */
  65. protected $_compiledRoute = null;
  66. /**
  67. * The name for a route. Fetch with Route::getName();
  68. *
  69. * @var string
  70. */
  71. protected $_name = null;
  72. /**
  73. * List of connected extensions for this route.
  74. *
  75. * @var array
  76. */
  77. protected $_extensions = [];
  78. /**
  79. * Constructor for a Route
  80. *
  81. * ### Options
  82. *
  83. * - `_ext` - Defines the extensions used for this route.
  84. * - `pass` - Copies the listed parameters into params['pass'].
  85. *
  86. * @param string $template Template string with parameter placeholders
  87. * @param array|string $defaults Defaults for the route.
  88. * @param array $options Array of additional options for the Route
  89. */
  90. public function __construct($template, $defaults = [], array $options = [])
  91. {
  92. $this->template = $template;
  93. $this->defaults = (array)$defaults;
  94. $this->options = $options;
  95. if (isset($this->defaults['[method]'])) {
  96. $this->defaults['_method'] = $this->defaults['[method]'];
  97. unset($this->defaults['[method]']);
  98. }
  99. if (isset($this->options['_ext'])) {
  100. $this->_extensions = (array)$this->options['_ext'];
  101. }
  102. }
  103. /**
  104. * Get/Set the supported extensions for this route.
  105. *
  106. * @param null|string|array $extensions The extensions to set. Use null to get.
  107. * @return array|null The extensions or null.
  108. */
  109. public function extensions($extensions = null)
  110. {
  111. if ($extensions === null) {
  112. return $this->_extensions;
  113. }
  114. $this->_extensions = (array)$extensions;
  115. }
  116. /**
  117. * Check if a Route has been compiled into a regular expression.
  118. *
  119. * @return bool
  120. */
  121. public function compiled()
  122. {
  123. return !empty($this->_compiledRoute);
  124. }
  125. /**
  126. * Compiles the route's regular expression.
  127. *
  128. * Modifies defaults property so all necessary keys are set
  129. * and populates $this->names with the named routing elements.
  130. *
  131. * @return array Returns a string regular expression of the compiled route.
  132. */
  133. public function compile()
  134. {
  135. if ($this->_compiledRoute) {
  136. return $this->_compiledRoute;
  137. }
  138. $this->_writeRoute();
  139. return $this->_compiledRoute;
  140. }
  141. /**
  142. * Builds a route regular expression.
  143. *
  144. * Uses the template, defaults and options properties to compile a
  145. * regular expression that can be used to parse request strings.
  146. *
  147. * @return void
  148. */
  149. protected function _writeRoute()
  150. {
  151. if (empty($this->template) || ($this->template === '/')) {
  152. $this->_compiledRoute = '#^/*$#';
  153. $this->keys = [];
  154. return;
  155. }
  156. $route = $this->template;
  157. $names = $routeParams = [];
  158. $parsed = preg_quote($this->template, '#');
  159. preg_match_all('#:([A-Za-z0-9_-]+[A-Z0-9a-z])#', $route, $namedElements);
  160. foreach ($namedElements[1] as $i => $name) {
  161. $search = '\\' . $namedElements[0][$i];
  162. if (isset($this->options[$name])) {
  163. $option = null;
  164. if ($name !== 'plugin' && array_key_exists($name, $this->defaults)) {
  165. $option = '?';
  166. }
  167. $slashParam = '/\\' . $namedElements[0][$i];
  168. if (strpos($parsed, $slashParam) !== false) {
  169. $routeParams[$slashParam] = '(?:/(?P<' . $name . '>' . $this->options[$name] . ')' . $option . ')' . $option;
  170. } else {
  171. $routeParams[$search] = '(?:(?P<' . $name . '>' . $this->options[$name] . ')' . $option . ')' . $option;
  172. }
  173. } else {
  174. $routeParams[$search] = '(?:(?P<' . $name . '>[^/]+))';
  175. }
  176. $names[] = $name;
  177. }
  178. if (preg_match('#\/\*\*$#', $route)) {
  179. $parsed = preg_replace('#/\\\\\*\\\\\*$#', '(?:/(?P<_trailing_>.*))?', $parsed);
  180. $this->_greedy = true;
  181. }
  182. if (preg_match('#\/\*$#', $route)) {
  183. $parsed = preg_replace('#/\\\\\*$#', '(?:/(?P<_args_>.*))?', $parsed);
  184. $this->_greedy = true;
  185. }
  186. $mode = '';
  187. if (!empty($this->options['multibytePattern'])) {
  188. $mode = 'u';
  189. }
  190. krsort($routeParams);
  191. $parsed = str_replace(array_keys($routeParams), array_values($routeParams), $parsed);
  192. $this->_compiledRoute = '#^' . $parsed . '[/]*$#' . $mode;
  193. $this->keys = $names;
  194. // Remove defaults that are also keys. They can cause match failures
  195. foreach ($this->keys as $key) {
  196. unset($this->defaults[$key]);
  197. }
  198. $keys = $this->keys;
  199. sort($keys);
  200. $this->keys = array_reverse($keys);
  201. }
  202. /**
  203. * Get the standardized plugin.controller:action name for a route.
  204. *
  205. * @return string
  206. */
  207. public function getName()
  208. {
  209. if (!empty($this->_name)) {
  210. return $this->_name;
  211. }
  212. $name = '';
  213. $keys = [
  214. 'prefix' => ':',
  215. 'plugin' => '.',
  216. 'controller' => ':',
  217. 'action' => ''
  218. ];
  219. foreach ($keys as $key => $glue) {
  220. $value = null;
  221. if (strpos($this->template, ':' . $key) !== false) {
  222. $value = '_' . $key;
  223. } elseif (isset($this->defaults[$key])) {
  224. $value = $this->defaults[$key];
  225. }
  226. if ($value === null) {
  227. continue;
  228. }
  229. if (is_bool($value)) {
  230. $value = $value ? '1' : '0';
  231. }
  232. $name .= $value . $glue;
  233. }
  234. return $this->_name = strtolower($name);
  235. }
  236. /**
  237. * Checks to see if the given URL can be parsed by this route.
  238. *
  239. * If the route can be parsed an array of parameters will be returned; if not
  240. * false will be returned. String URLs are parsed if they match a routes regular expression.
  241. *
  242. * @param string $url The URL to attempt to parse.
  243. * @return array|false An array of request parameters, or false on failure.
  244. */
  245. public function parse($url)
  246. {
  247. if (empty($this->_compiledRoute)) {
  248. $this->compile();
  249. }
  250. list($url, $ext) = $this->_parseExtension($url);
  251. if (!preg_match($this->_compiledRoute, urldecode($url), $route)) {
  252. return false;
  253. }
  254. if (isset($this->defaults['_method'])) {
  255. $request = Router::getRequest(true) ?: Request::createFromGlobals();
  256. $method = $request->env('REQUEST_METHOD');
  257. if (!in_array($method, (array)$this->defaults['_method'], true)) {
  258. return false;
  259. }
  260. }
  261. array_shift($route);
  262. $count = count($this->keys);
  263. for ($i = 0; $i <= $count; $i++) {
  264. unset($route[$i]);
  265. }
  266. $route['pass'] = [];
  267. // Assign defaults, set passed args to pass
  268. foreach ($this->defaults as $key => $value) {
  269. if (isset($route[$key])) {
  270. continue;
  271. }
  272. if (is_int($key)) {
  273. $route['pass'][] = $value;
  274. continue;
  275. }
  276. $route[$key] = $value;
  277. }
  278. if (isset($route['_args_'])) {
  279. $pass = $this->_parseArgs($route['_args_'], $route);
  280. $route['pass'] = array_merge($route['pass'], $pass);
  281. unset($route['_args_']);
  282. }
  283. if (isset($route['_trailing_'])) {
  284. $route['pass'][] = $route['_trailing_'];
  285. unset($route['_trailing_']);
  286. }
  287. if (!empty($ext)) {
  288. $route['_ext'] = $ext;
  289. }
  290. // restructure 'pass' key route params
  291. if (isset($this->options['pass'])) {
  292. $j = count($this->options['pass']);
  293. while ($j--) {
  294. if (isset($route[$this->options['pass'][$j]])) {
  295. array_unshift($route['pass'], $route[$this->options['pass'][$j]]);
  296. }
  297. }
  298. }
  299. return $route;
  300. }
  301. /**
  302. * Removes the extension from $url if it contains a registered extension.
  303. * If no registered extension is found, no extension is returned and the URL is returned unmodified.
  304. *
  305. * @param string $url The url to parse.
  306. * @return array containing url, extension
  307. */
  308. protected function _parseExtension($url)
  309. {
  310. if (empty($this->_extensions)) {
  311. return [$url, null];
  312. }
  313. preg_match('/\.([0-9a-z]*)$/', $url, $match);
  314. if (empty($match[1])) {
  315. return [$url, null];
  316. }
  317. $ext = strtolower($match[1]);
  318. $len = strlen($match[1]);
  319. foreach ($this->_extensions as $name) {
  320. if (strtolower($name) === $ext) {
  321. $url = substr($url, 0, ($len + 1) * -1);
  322. return [$url, $ext];
  323. }
  324. }
  325. return [$url, null];
  326. }
  327. /**
  328. * Parse passed parameters into a list of passed args.
  329. *
  330. * Return true if a given named $param's $val matches a given $rule depending on $context.
  331. * Currently implemented rule types are controller, action and match that can be combined with each other.
  332. *
  333. * @param string $args A string with the passed params. eg. /1/foo
  334. * @param string $context The current route context, which should contain controller/action keys.
  335. * @return array Array of passed args.
  336. */
  337. protected function _parseArgs($args, $context)
  338. {
  339. $pass = [];
  340. $args = explode('/', $args);
  341. foreach ($args as $param) {
  342. if (empty($param) && $param !== '0' && $param !== 0) {
  343. continue;
  344. }
  345. $pass[] = rawurldecode($param);
  346. }
  347. return $pass;
  348. }
  349. /**
  350. * Apply persistent parameters to a URL array. Persistent parameters are a
  351. * special key used during route creation to force route parameters to
  352. * persist when omitted from a URL array.
  353. *
  354. * @param array $url The array to apply persistent parameters to.
  355. * @param array $params An array of persistent values to replace persistent ones.
  356. * @return array An array with persistent parameters applied.
  357. */
  358. protected function _persistParams(array $url, array $params)
  359. {
  360. foreach ($this->options['persist'] as $persistKey) {
  361. if (array_key_exists($persistKey, $params) && !isset($url[$persistKey])) {
  362. $url[$persistKey] = $params[$persistKey];
  363. }
  364. }
  365. return $url;
  366. }
  367. /**
  368. * Check if a URL array matches this route instance.
  369. *
  370. * If the URL matches the route parameters and settings, then
  371. * return a generated string URL. If the URL doesn't match the route parameters, false will be returned.
  372. * This method handles the reverse routing or conversion of URL arrays into string URLs.
  373. *
  374. * @param array $url An array of parameters to check matching with.
  375. * @param array $context An array of the current request context.
  376. * Contains information such as the current host, scheme, port, base
  377. * directory and other url params.
  378. * @return string|false Either a string URL for the parameters if they match or false.
  379. */
  380. public function match(array $url, array $context = [])
  381. {
  382. if (empty($this->_compiledRoute)) {
  383. $this->compile();
  384. }
  385. $defaults = $this->defaults;
  386. $context += ['params' => []];
  387. if (!empty($this->options['persist']) &&
  388. is_array($this->options['persist'])
  389. ) {
  390. $url = $this->_persistParams($url, $context['params']);
  391. }
  392. unset($context['params']);
  393. $hostOptions = array_intersect_key($url, $context);
  394. // Check for properties that will cause an
  395. // absolute url. Copy the other properties over.
  396. if (isset($hostOptions['_scheme']) ||
  397. isset($hostOptions['_port']) ||
  398. isset($hostOptions['_host'])
  399. ) {
  400. $hostOptions += $context;
  401. if ($hostOptions['_port'] == $context['_port']) {
  402. unset($hostOptions['_port']);
  403. }
  404. }
  405. // If no base is set, copy one in.
  406. if (!isset($hostOptions['_base']) && isset($context['_base'])) {
  407. $hostOptions['_base'] = $context['_base'];
  408. }
  409. $query = !empty($url['?']) ? (array)$url['?'] : [];
  410. unset($url['_host'], $url['_scheme'], $url['_port'], $url['_base'], $url['?']);
  411. // Move extension into the hostOptions so its not part of
  412. // reverse matches.
  413. if (isset($url['_ext'])) {
  414. $hostOptions['_ext'] = $url['_ext'];
  415. unset($url['_ext']);
  416. }
  417. // Check the method first as it is special.
  418. if (!$this->_matchMethod($url)) {
  419. return false;
  420. }
  421. unset($url['_method'], $url['[method]'], $defaults['_method']);
  422. // Missing defaults is a fail.
  423. if (array_diff_key($defaults, $url) !== []) {
  424. return false;
  425. }
  426. // Defaults with different values are a fail.
  427. if (array_intersect_key($url, $defaults) != $defaults) {
  428. return false;
  429. }
  430. // If this route uses pass option, and the passed elements are
  431. // not set, rekey elements.
  432. if (isset($this->options['pass'])) {
  433. foreach ($this->options['pass'] as $i => $name) {
  434. if (isset($url[$i]) && !isset($url[$name])) {
  435. $url[$name] = $url[$i];
  436. unset($url[$i]);
  437. }
  438. }
  439. }
  440. // check that all the key names are in the url
  441. $keyNames = array_flip($this->keys);
  442. if (array_intersect_key($keyNames, $url) !== $keyNames) {
  443. return false;
  444. }
  445. $pass = [];
  446. foreach ($url as $key => $value) {
  447. // keys that exist in the defaults and have different values is a match failure.
  448. $defaultExists = array_key_exists($key, $defaults);
  449. // If the key is a routed key, it's not different yet.
  450. if (array_key_exists($key, $keyNames)) {
  451. continue;
  452. }
  453. // pull out passed args
  454. $numeric = is_numeric($key);
  455. if ($numeric && isset($defaults[$key]) && $defaults[$key] == $value) {
  456. continue;
  457. }
  458. if ($numeric) {
  459. $pass[] = $value;
  460. unset($url[$key]);
  461. continue;
  462. }
  463. // keys that don't exist are different.
  464. if (!$defaultExists && ($value !== null && $value !== false && $value !== '')) {
  465. $query[$key] = $value;
  466. unset($url[$key]);
  467. }
  468. }
  469. // if not a greedy route, no extra params are allowed.
  470. if (!$this->_greedy && !empty($pass)) {
  471. return false;
  472. }
  473. // check patterns for routed params
  474. if (!empty($this->options)) {
  475. foreach ($this->options as $key => $pattern) {
  476. if (isset($url[$key]) && !preg_match('#^' . $pattern . '$#', $url[$key])) {
  477. return false;
  478. }
  479. }
  480. }
  481. $url += $hostOptions;
  482. return $this->_writeUrl($url, $pass, $query);
  483. }
  484. /**
  485. * Check whether or not the URL's HTTP method matches.
  486. *
  487. * @param array $url The array for the URL being generated.
  488. * @return bool
  489. */
  490. protected function _matchMethod($url)
  491. {
  492. if (empty($this->defaults['_method'])) {
  493. return true;
  494. }
  495. if (isset($url['[method]'])) {
  496. $url['_method'] = $url['[method]'];
  497. }
  498. if (empty($url['_method'])) {
  499. return false;
  500. }
  501. if (!in_array(strtoupper($url['_method']), (array)$this->defaults['_method'])) {
  502. return false;
  503. }
  504. return true;
  505. }
  506. /**
  507. * Converts a matching route array into a URL string.
  508. *
  509. * Composes the string URL using the template
  510. * used to create the route.
  511. *
  512. * @param array $params The params to convert to a string url
  513. * @param array $pass The additional passed arguments
  514. * @param array $query An array of parameters
  515. * @return string Composed route string.
  516. */
  517. protected function _writeUrl($params, $pass = [], $query = [])
  518. {
  519. $pass = implode('/', array_map('rawurlencode', $pass));
  520. $out = $this->template;
  521. $search = $replace = [];
  522. foreach ($this->keys as $key) {
  523. $string = null;
  524. if (isset($params[$key])) {
  525. $string = $params[$key];
  526. } elseif (strpos($out, $key) != strlen($out) - strlen($key)) {
  527. $key .= '/';
  528. }
  529. $search[] = ':' . $key;
  530. $replace[] = $string;
  531. }
  532. if (strpos($this->template, '**') !== false) {
  533. array_push($search, '**', '%2F');
  534. array_push($replace, $pass, '/');
  535. } elseif (strpos($this->template, '*') !== false) {
  536. $search[] = '*';
  537. $replace[] = $pass;
  538. }
  539. $out = str_replace($search, $replace, $out);
  540. // add base url if applicable.
  541. if (isset($params['_base'])) {
  542. $out = $params['_base'] . $out;
  543. unset($params['_base']);
  544. }
  545. $out = str_replace('//', '/', $out);
  546. if (isset($params['_scheme']) ||
  547. isset($params['_host']) ||
  548. isset($params['_port'])
  549. ) {
  550. $host = $params['_host'];
  551. // append the port if it exists.
  552. if (isset($params['_port'])) {
  553. $host .= ':' . $params['_port'];
  554. }
  555. $out = "{$params['_scheme']}://{$host}{$out}";
  556. }
  557. if (!empty($params['_ext']) || !empty($query)) {
  558. $out = rtrim($out, '/');
  559. }
  560. if (!empty($params['_ext'])) {
  561. $out .= '.' . $params['_ext'];
  562. }
  563. if (!empty($query)) {
  564. $out .= rtrim('?' . http_build_query($query), '?');
  565. }
  566. return $out;
  567. }
  568. /**
  569. * Get the static path portion for this route.
  570. *
  571. * @return string
  572. */
  573. public function staticPath()
  574. {
  575. $routeKey = strpos($this->template, ':');
  576. if ($routeKey !== false) {
  577. return substr($this->template, 0, $routeKey);
  578. }
  579. $star = strpos($this->template, '*');
  580. if ($star !== false) {
  581. $path = rtrim(substr($this->template, 0, $star), '/');
  582. return $path === '' ? '/' : $path;
  583. }
  584. return $this->template;
  585. }
  586. /**
  587. * Set state magic method to support var_export
  588. *
  589. * This method helps for applications that want to implement
  590. * router caching.
  591. *
  592. * @param array $fields Key/Value of object attributes
  593. * @return \Cake\Routing\Route\Route A new instance of the route
  594. */
  595. public static function __set_state($fields)
  596. {
  597. $class = get_called_class();
  598. $obj = new $class('');
  599. foreach ($fields as $field => $value) {
  600. $obj->$field = $value;
  601. }
  602. return $obj;
  603. }
  604. }