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