Route.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613
  1. <?php
  2. /**
  3. * Routes are used to determine the controller and action for a requested URI.
  4. * Every route generates a regular expression which is used to match a URI
  5. * and a route. Routes may also contain keys which can be used to set the
  6. * controller, action, and parameters.
  7. *
  8. * Each <key> will be translated to a regular expression using a default
  9. * regular expression pattern. You can override the default pattern by providing
  10. * a pattern for the key:
  11. *
  12. * // This route will only match when <id> is a digit
  13. * Route::set('user', 'user/<action>/<id>', array('id' => '\d+'));
  14. *
  15. * // This route will match when <path> is anything
  16. * Route::set('file', '<path>', array('path' => '.*'));
  17. *
  18. * It is also possible to create optional segments by using parentheses in
  19. * the URI definition:
  20. *
  21. * // This is the standard default route, and no keys are required
  22. * Route::set('default', '(<controller>(/<action>(/<id>)))');
  23. *
  24. * // This route only requires the <file> key
  25. * Route::set('file', '(<path>/)<file>(.<format>)', array('path' => '.*', 'format' => '\w+'));
  26. *
  27. * Routes also provide a way to generate URIs (called "reverse routing"), which
  28. * makes them an extremely powerful and flexible way to generate internal links.
  29. *
  30. * @package Kohana
  31. * @category Base
  32. * @author Kohana Team
  33. * @copyright (c) Kohana Team
  34. * @license https://koseven.ga/LICENSE.md
  35. */
  36. class Kohana_Route {
  37. // Matches a URI group and captures the contents
  38. const REGEX_GROUP = '\(((?:(?>[^()]+)|(?R))*)\)';
  39. // Defines the pattern of a <segment>
  40. const REGEX_KEY = '<([a-zA-Z0-9_]++)>';
  41. // What can be part of a <segment> value
  42. const REGEX_SEGMENT = '[^/.,;?\n]++';
  43. // What must be escaped in the route regex
  44. const REGEX_ESCAPE = '[.\\+*?[^\\]${}=!|]';
  45. /**
  46. * @var string default protocol for all routes
  47. *
  48. * @example 'http://'
  49. */
  50. public static $default_protocol = 'http://';
  51. /**
  52. * @var array list of valid localhost entries
  53. */
  54. public static $localhosts = [FALSE, '', 'local', 'localhost'];
  55. /**
  56. * @var string default action for all routes
  57. */
  58. public static $default_action = 'index';
  59. /**
  60. * @var bool Indicates whether routes are cached
  61. */
  62. public static $cache = FALSE;
  63. /**
  64. * @var array
  65. */
  66. protected static $_routes = [];
  67. /**
  68. * Stores a named route and returns it. The "action" will always be set to
  69. * "index" if it is not defined.
  70. *
  71. * Route::set('default', '(<controller>(/<action>(/<id>)))')
  72. * ->defaults(array(
  73. * 'controller' => 'welcome',
  74. * ));
  75. *
  76. * @param string $name route name
  77. * @param string $uri URI pattern
  78. * @param array $regex regex patterns for route keys
  79. * @return Route
  80. */
  81. public static function set($name, $uri = NULL, $regex = NULL)
  82. {
  83. return Route::$_routes[$name] = new Route($uri, $regex);
  84. }
  85. /**
  86. * Retrieves a named route.
  87. *
  88. * $route = Route::get('default');
  89. *
  90. * @param string $name route name
  91. * @return Route
  92. * @throws Kohana_Exception
  93. */
  94. public static function get($name)
  95. {
  96. if ( ! isset(Route::$_routes[$name]))
  97. {
  98. throw new Kohana_Exception('The requested route does not exist: :route',
  99. [':route' => $name]);
  100. }
  101. return Route::$_routes[$name];
  102. }
  103. /**
  104. * Retrieves all named routes.
  105. *
  106. * $routes = Route::all();
  107. *
  108. * @return array routes by name
  109. */
  110. public static function all()
  111. {
  112. return Route::$_routes;
  113. }
  114. /**
  115. * Get the name of a route.
  116. *
  117. * $name = Route::name($route)
  118. *
  119. * @param Route $route instance
  120. * @return string
  121. */
  122. public static function name(Route $route)
  123. {
  124. return array_search($route, Route::$_routes);
  125. }
  126. /**
  127. * Saves or loads the route cache. If your routes will remain the same for
  128. * a long period of time, use this to reload the routes from the cache
  129. * rather than redefining them on every page load.
  130. *
  131. * if ( ! Route::cache())
  132. * {
  133. * // Set routes here
  134. * Route::cache(TRUE);
  135. * }
  136. *
  137. * @param boolean $save cache the current routes
  138. * @param boolean $append append, rather than replace, cached routes when loading
  139. * @return void when saving routes
  140. * @return boolean when loading routes
  141. * @uses Kohana::cache
  142. */
  143. public static function cache($save = FALSE, $append = FALSE)
  144. {
  145. if ($save === TRUE)
  146. {
  147. try
  148. {
  149. // Cache all defined routes
  150. Kohana::cache('Route::cache()', Route::$_routes);
  151. }
  152. catch (Exception $e)
  153. {
  154. // We most likely have a lambda in a route, which cannot be cached
  155. throw new Kohana_Exception('One or more routes could not be cached (:message)', [
  156. ':message' => $e->getMessage(),
  157. ], 0, $e);
  158. }
  159. }
  160. else
  161. {
  162. if ($routes = Kohana::cache('Route::cache()'))
  163. {
  164. if ($append)
  165. {
  166. // Append cached routes
  167. Route::$_routes += $routes;
  168. }
  169. else
  170. {
  171. // Replace existing routes
  172. Route::$_routes = $routes;
  173. }
  174. // Routes were cached
  175. return Route::$cache = TRUE;
  176. }
  177. else
  178. {
  179. // Routes were not cached
  180. return Route::$cache = FALSE;
  181. }
  182. }
  183. }
  184. /**
  185. * Create a URL from a route name. This is a shortcut for:
  186. *
  187. * echo URL::site(Route::get($name)->uri($params), $protocol);
  188. *
  189. * @param string $name route name
  190. * @param array $params URI parameters
  191. * @param mixed $protocol protocol string or boolean, adds protocol and domain
  192. * @return string
  193. * @since 3.0.7
  194. * @uses URL::site
  195. */
  196. public static function url($name, array $params = NULL, $protocol = NULL)
  197. {
  198. $route = Route::get($name);
  199. // Create a URI with the route and convert it to a URL
  200. if ($route->is_external())
  201. return $route->uri($params);
  202. else
  203. return URL::site($route->uri($params), $protocol);
  204. }
  205. /**
  206. * Returns the compiled regular expression for the route. This translates
  207. * keys and optional groups to a proper PCRE regular expression.
  208. *
  209. * $compiled = Route::compile(
  210. * '<controller>(/<action>(/<id>))',
  211. * array(
  212. * 'controller' => '[a-z]+',
  213. * 'id' => '\d+',
  214. * )
  215. * );
  216. *
  217. * @return string
  218. * @uses Route::REGEX_ESCAPE
  219. * @uses Route::REGEX_SEGMENT
  220. */
  221. public static function compile($uri, array $regex = NULL)
  222. {
  223. // The URI should be considered literal except for keys and optional parts
  224. // Escape everything preg_quote would escape except for : ( ) < >
  225. $expression = preg_replace('#'.Route::REGEX_ESCAPE.'#', '\\\\$0', $uri);
  226. if (strpos($expression, '(') !== FALSE)
  227. {
  228. // Make optional parts of the URI non-capturing and optional
  229. $expression = str_replace(['(', ')'], ['(?:', ')?'], $expression);
  230. }
  231. // Insert default regex for keys
  232. $expression = str_replace(['<', '>'], ['(?P<', '>'.Route::REGEX_SEGMENT.')'], $expression);
  233. if ($regex)
  234. {
  235. $search = $replace = [];
  236. foreach ($regex as $key => $value)
  237. {
  238. $search[] = "<$key>".Route::REGEX_SEGMENT;
  239. $replace[] = "<$key>$value";
  240. }
  241. // Replace the default regex with the user-specified regex
  242. $expression = str_replace($search, $replace, $expression);
  243. }
  244. return '#^'.$expression.'$#uD';
  245. }
  246. /**
  247. * @var array route filters
  248. */
  249. protected $_filters = [];
  250. /**
  251. * @var string route URI
  252. */
  253. protected $_uri = '';
  254. /**
  255. * @var array
  256. */
  257. protected $_regex = [];
  258. /**
  259. * @var array
  260. */
  261. protected $_defaults = ['action' => 'index', 'host' => FALSE];
  262. /**
  263. * @var string
  264. */
  265. protected $_route_regex;
  266. /**
  267. * Creates a new route. Sets the URI and regular expressions for keys.
  268. * Routes should always be created with [Route::set] or they will not
  269. * be properly stored.
  270. *
  271. * $route = new Route($uri, $regex);
  272. *
  273. * The $uri parameter should be a string for basic regex matching.
  274. *
  275. *
  276. * @param string $uri route URI pattern
  277. * @param array $regex key patterns
  278. * @return void
  279. * @uses Route::_compile
  280. */
  281. public function __construct($uri = NULL, $regex = NULL)
  282. {
  283. if ($uri === NULL)
  284. {
  285. // Assume the route is from cache
  286. return;
  287. }
  288. if ( ! empty($uri))
  289. {
  290. $this->_uri = $uri;
  291. }
  292. if ( ! empty($regex))
  293. {
  294. $this->_regex = $regex;
  295. }
  296. // Store the compiled regex locally
  297. $this->_route_regex = Route::compile($uri, $regex);
  298. }
  299. /**
  300. * Provides default values for keys when they are not present. The default
  301. * action will always be "index" unless it is overloaded here.
  302. *
  303. * $route->defaults(array(
  304. * 'controller' => 'welcome',
  305. * 'action' => 'index'
  306. * ));
  307. *
  308. * If no parameter is passed, this method will act as a getter.
  309. *
  310. * @param array $defaults key values
  311. * @return $this or array
  312. */
  313. public function defaults(array $defaults = NULL)
  314. {
  315. if ($defaults === NULL)
  316. {
  317. return $this->_defaults;
  318. }
  319. $this->_defaults = $defaults;
  320. return $this;
  321. }
  322. /**
  323. * Filters to be run before route parameters are returned:
  324. *
  325. * $route->filter(
  326. * function(Route $route, array $params, Request $request)
  327. * {
  328. * if ($request->method() !== HTTP_Request::POST)
  329. * {
  330. * return FALSE; // This route only matches POST requests
  331. * }
  332. * if (isset($params['controller']) AND $params['controller'] == 'Welcome')
  333. * {
  334. * $params['controller'] = 'Home';
  335. * return $params;
  336. * }
  337. * }
  338. * );
  339. *
  340. * To prevent a route from matching, return `FALSE`. To replace the route
  341. * parameters, return an array.
  342. *
  343. * [!!] Default parameters are added before filters are called!
  344. *
  345. * @throws Kohana_Exception
  346. * @param callable $callback Filter callback
  347. * @return $this
  348. */
  349. public function filter($callback)
  350. {
  351. if ( ! is_callable($callback))
  352. {
  353. throw new Kohana_Exception('Invalid Route::callback specified');
  354. }
  355. $this->_filters[] = $callback;
  356. return $this;
  357. }
  358. /**
  359. * Tests if the route matches a given Request. A successful match will return
  360. * all of the routed parameters as an array. A failed match will return
  361. * boolean FALSE.
  362. *
  363. * // Params: controller = users, action = edit, id = 10
  364. * $params = $route->matches(Request::factory('users/edit/10'));
  365. *
  366. * This method should almost always be used within an if/else block:
  367. *
  368. * if ($params = $route->matches($request))
  369. * {
  370. * // Parse the parameters
  371. * }
  372. *
  373. * @param Request $request Request object to match
  374. * @return array on success
  375. * @return FALSE on failure
  376. */
  377. public function matches(Request $request)
  378. {
  379. // Get the URI from the Request
  380. $uri = trim($request->uri(), '/');
  381. if ( ! preg_match($this->_route_regex, $uri, $matches))
  382. return FALSE;
  383. $params = [];
  384. foreach ($matches as $key => $value)
  385. {
  386. if (is_int($key))
  387. {
  388. // Skip all unnamed keys
  389. continue;
  390. }
  391. // Set the value for all matched keys
  392. $params[$key] = $value;
  393. }
  394. foreach ($this->_defaults as $key => $value)
  395. {
  396. if ( ! isset($params[$key]) OR $params[$key] === '')
  397. {
  398. // Set default values for any key that was not matched
  399. $params[$key] = $value;
  400. }
  401. }
  402. if ( ! empty($params['controller']))
  403. {
  404. // PSR-0: Replace underscores with spaces, run ucwords, then replace underscore
  405. $params['controller'] = str_replace(' ', '_', ucwords(str_replace('_', ' ', $params['controller'])));
  406. }
  407. if ( ! empty($params['directory']))
  408. {
  409. // PSR-0: Replace underscores with spaces, run ucwords, then replace underscore
  410. $params['directory'] = str_replace(' ', '_', ucwords(str_replace('_', ' ', $params['directory'])));
  411. }
  412. if ($this->_filters)
  413. {
  414. foreach ($this->_filters as $callback)
  415. {
  416. // Execute the filter giving it the route, params, and request
  417. $return = call_user_func($callback, $this, $params, $request);
  418. if ($return === FALSE)
  419. {
  420. // Filter has aborted the match
  421. return FALSE;
  422. }
  423. elseif (is_array($return))
  424. {
  425. // Filter has modified the parameters
  426. $params = $return;
  427. }
  428. }
  429. }
  430. return $params;
  431. }
  432. /**
  433. * Returns whether this route is an external route
  434. * to a remote controller.
  435. *
  436. * @return boolean
  437. */
  438. public function is_external()
  439. {
  440. return ! in_array(Arr::get($this->_defaults, 'host', FALSE), Route::$localhosts);
  441. }
  442. /**
  443. * Generates a URI for the current route based on the parameters given.
  444. *
  445. * // Using the "default" route: "users/profile/10"
  446. * $route->uri(array(
  447. * 'controller' => 'users',
  448. * 'action' => 'profile',
  449. * 'id' => '10'
  450. * ));
  451. *
  452. * @param array $params URI parameters
  453. * @return string
  454. * @throws Kohana_Exception
  455. * @uses Route::REGEX_GROUP
  456. * @uses Route::REGEX_KEY
  457. */
  458. public function uri(array $params = NULL)
  459. {
  460. if ($params)
  461. {
  462. foreach($params as $key => $value) {
  463. $params[$key] = rawurlencode($value ?? '');
  464. }
  465. // decode slashes back, see Apache docs about AllowEncodedSlashes and AcceptPathInfo
  466. $params = str_replace(['%2F', '%5C'], ['/', '\\'], $params);
  467. }
  468. $defaults = $this->_defaults;
  469. /**
  470. * Recursively compiles a portion of a URI specification by replacing
  471. * the specified parameters and any optional parameters that are needed.
  472. *
  473. * @param string $portion Part of the URI specification
  474. * @param boolean $required Whether or not parameters are required (initially)
  475. * @return array Tuple of the compiled portion and whether or not it contained specified parameters
  476. */
  477. $compile = function ($portion, $required) use (&$compile, $defaults, $params)
  478. {
  479. $missing = [];
  480. $pattern = '#(?:'.Route::REGEX_KEY.'|'.Route::REGEX_GROUP.')#';
  481. $result = preg_replace_callback($pattern, function ($matches) use (&$compile, $defaults, &$missing, $params, &$required)
  482. {
  483. if ($matches[0][0] === '<')
  484. {
  485. // Parameter, unwrapped
  486. $param = $matches[1];
  487. if (isset($params[$param]))
  488. {
  489. // This portion is required when a specified
  490. // parameter does not match the default
  491. $required = ($required OR ! isset($defaults[$param]) OR $params[$param] !== $defaults[$param]);
  492. // Add specified parameter to this result
  493. return $params[$param];
  494. }
  495. // Add default parameter to this result
  496. if (isset($defaults[$param]))
  497. return $defaults[$param];
  498. // This portion is missing a parameter
  499. $missing[] = $param;
  500. }
  501. else
  502. {
  503. // Group, unwrapped
  504. $result = $compile($matches[2], FALSE);
  505. if ($result[1])
  506. {
  507. // This portion is required when it contains a group
  508. // that is required
  509. $required = TRUE;
  510. // Add required groups to this result
  511. return $result[0];
  512. }
  513. // Do not add optional groups to this result
  514. }
  515. }, $portion);
  516. if ($required AND $missing)
  517. {
  518. throw new Kohana_Exception(
  519. 'Required route parameter not passed: :param',
  520. [':param' => reset($missing)]
  521. );
  522. }
  523. return [$result, $required];
  524. };
  525. list($uri) = $compile($this->_uri, TRUE);
  526. // Trim all extra slashes from the URI
  527. $uri = preg_replace('#//+#', '/', rtrim($uri, '/'));
  528. if ($this->is_external())
  529. {
  530. // Need to add the host to the URI
  531. $host = $this->_defaults['host'];
  532. if (strpos($host, '://') === FALSE)
  533. {
  534. // Use the default defined protocol
  535. $host = Route::$default_protocol.$host;
  536. }
  537. // Clean up the host and prepend it to the URI
  538. $uri = rtrim($host, '/').'/'.$uri;
  539. }
  540. return $uri;
  541. }
  542. }