1: <?php
2: /**
3: * This file is part of the PHPLucidFrame library.
4: * Simple router for named routes that can be used with RegExp
5: * Pretty familiar to anyone who's used Symfony
6: *
7: * @package PHPLucidFrame\Core
8: * @since PHPLucidFrame v 1.10.0
9: * @copyright Copyright (c), PHPLucidFrame.
10: * @link http://phplucidframe.com
11: * @license http://www.opensource.org/licenses/mit-license.php MIT License
12: * This source file is subject to the MIT license that is bundled
13: * with this source code in the file LICENSE
14: */
15:
16: namespace LucidFrame\Core;
17:
18: /**
19: * Simple router for named routes that can be used with RegExp
20: */
21: class Router
22: {
23: /** @var array The custom routes defined */
24: static protected $routes = array();
25: /** @var string The route name matched */
26: static protected $matchedRouteName;
27: /** @var string The route name that is unique to the mapped path */
28: protected $name;
29:
30: /**
31: * Constructor
32: *
33: * @param string $name The route name
34: */
35: public function __construct($name)
36: {
37: $this->name = $name;
38: }
39:
40: /**
41: * Getter for $routes
42: */
43: public static function getRoutes()
44: {
45: return self::$routes;
46: }
47:
48: /**
49: * Getter for $matchedRouteName
50: */
51: public static function getMatchedName()
52: {
53: return self::$matchedRouteName;
54: }
55:
56: /**
57: * Getter for $name
58: */
59: public function getName()
60: {
61: return $this->name;
62: }
63:
64: /**
65: * Initialize URL routing
66: */
67: public static function init()
68: {
69: if (!isset($_SERVER['HTTP_REFERER'])) {
70: $_SERVER['HTTP_REFERER'] = '';
71: }
72:
73: if (!isset($_SERVER['SERVER_PROTOCOL']) ||
74: ($_SERVER['SERVER_PROTOCOL'] != 'HTTP/1.0' && $_SERVER['SERVER_PROTOCOL'] != 'HTTP/1.1')) {
75: $_SERVER['SERVER_PROTOCOL'] = 'HTTP/1.0';
76: }
77:
78: if (isset($_SERVER['HTTP_HOST'])) {
79: # As HTTP_HOST is user input, ensure it only contains characters allowed
80: # in hostnames. See RFC 952 (and RFC 2181).
81: # $_SERVER['HTTP_HOST'] is lowercased here per specifications.
82: $_SERVER['HTTP_HOST'] = strtolower($_SERVER['HTTP_HOST']);
83: if (!_validHost($_SERVER['HTTP_HOST'])) {
84: # HTTP_HOST is invalid, e.g. if containing slashes it may be an attack.
85: header($_SERVER['SERVER_PROTOCOL'] . ' 400 Bad Request');
86: exit;
87: }
88: } else {
89: # Some pre-HTTP/1.1 clients will not send a Host header. Ensure the key is
90: # defined for E_ALL compliance.
91: $_SERVER['HTTP_HOST'] = '';
92: }
93: # When clean URLs are enabled, emulate ?route=foo/bar using REQUEST_URI. It is
94: # not possible to append the query string using mod_rewrite without the B
95: # flag (this was added in Apache 2.2.8), because mod_rewrite unescapes the
96: # path before passing it on to PHP. This is a problem when the path contains
97: # e.g. "&" or "%" that have special meanings in URLs and must be encoded.
98: $_GET[ROUTE] = Router::request();
99: _cfg('cleanRoute', $_GET[ROUTE]);
100: }
101:
102: /**
103: * Returns the requested URL path of the page being viewed.
104: * Examples:
105: * - http://example.com/foo/bar returns "foo/bar".
106: *
107: * @return string The requested URL path.
108: */
109: public static function request()
110: {
111: global $lc_baseURL;
112: global $lc_languages;
113: global $lc_lang;
114: global $lc_langInURI;
115:
116: $lc_langInURI = _getLangInURI();
117: if ($lc_langInURI === false) {
118: $lc_lang = $lang = _cfg('defaultLang');
119: } else {
120: $lc_lang = $lang = $lc_langInURI;
121: }
122:
123: if (isset($_GET[ROUTE]) && is_string($_GET[ROUTE])) {
124: # This is a request with a ?route=foo/bar query string.
125: $path = $_GET[ROUTE];
126: if (isset($_GET['lang']) && $_GET['lang']) {
127: $lang = strip_tags(urldecode($_GET['lang']));
128: $lang = rtrim($lang, '/');
129: if (array_key_exists($lang, $lc_languages)) {
130: $lc_lang = $lang;
131: }
132: }
133: } elseif (isset($_SERVER['REQUEST_URI'])) {
134: # This request is either a clean URL, or 'index.php', or nonsense.
135: # Extract the path from REQUEST_URI.
136: $requestPath = urldecode(strtok($_SERVER['REQUEST_URI'], '?'));
137: $requestPath = str_replace($lc_baseURL, '', ltrim($requestPath, '/'));
138: $requestPath = ltrim($requestPath, '/');
139:
140: if ($lang) {
141: $lc_lang = $lang;
142: $path = trim($requestPath, '/');
143: if (strpos($path, $lc_lang) === 0) {
144: $path = substr($path, strlen($lang));
145: }
146: } else {
147: $path = trim($requestPath);
148: }
149:
150: # If the path equals the script filename, either because 'index.php' was
151: # explicitly provided in the URL, or because the server added it to
152: # $_SERVER['REQUEST_URI'] even when it wasn't provided in the URL (some
153: # versions of Microsoft IIS do this), the front page should be served.
154: if ($path == basename($_SERVER['PHP_SELF'])) {
155: $path = '';
156: }
157: } else {
158: # This is the front page.
159: $path = '';
160: }
161:
162: # Under certain conditions Apache's RewriteRule directive prepends the value
163: # assigned to $_GET[ROUTE] with a slash. Moreover, we can always have a trailing
164: # slash in place, hence we need to normalize $_GET[ROUTE].
165: $path = trim($path, '/');
166:
167: if (!defined('WEB_ROOT')) {
168: $baseUrl = _baseUrlWithProtocol();
169: if ($baseUrl) {
170: # path to the web root
171: define('WEB_ROOT', $baseUrl . '/');
172: # path to the web app root
173: define('WEB_APP_ROOT', WEB_ROOT . APP_DIR . '/');
174: # path to the home page
175: define('HOME', WEB_ROOT);
176: }
177: }
178:
179: session_set('lang', $lc_lang);
180:
181: return $path;
182: }
183:
184: /**
185: * Define the custom routing path
186: *
187: * @param string $name Any unique route name to the mapped $path
188: * @param string $path URL path with optional dynamic variables such as `/post/{id}/edit`
189: * @param string $to The real path to a directory or file in /app
190: * @param string $method GET, POST, PUT or DELETE or any combination with `|` such as GET|POST
191: * @param array|null $patterns array of the regex patterns for variables in $path such s `array('id' => '\d+')`
192: * @return Router
193: */
194: public function add($name, $path, $to, $method = 'GET', $patterns = null)
195: {
196: $this->name = $name;
197:
198: $method = explode('|', strtoupper($method));
199: $methods = array_filter($method, function ($value) {
200: return in_array($value, array('GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'CONNECT', 'TRACE'));
201: });
202:
203: if (count($methods) == 0) {
204: $methods = array('GET');
205: }
206:
207: $methods[] = 'OPTIONS';
208: $methods = array_unique($methods);
209:
210: self::$routes[$this->name] = array(
211: 'path' => $path,
212: 'to' => $to,
213: 'method' => $methods,
214: 'patterns' => $patterns
215: );
216:
217: return $this;
218: }
219:
220: /**
221: * Define the custom routing path
222: *
223: * @param string $path URL path with optional dynamic variables such as `/post/{id}/edit`
224: * @param string|\Closure $to The real path to a directory or file in `/app`
225: * @param string $method GET, POST, PUT or DELETE or any combination with `|` such as GET|POST
226: * @param array|null $patterns array of the regex patterns for variables in $path such s `array('id' => '\d+')`
227: * @return Router
228: */
229: public function map($path, $to, $method = 'GET', $patterns = null)
230: {
231: return $this->add($this->name, $path, $to, $method, $patterns);
232: }
233:
234: /**
235: * Matching the current route to the defined custom routes
236: *
237: * @return string|\Closure|boolean The matched route or false if no matched route is found
238: */
239: public static function match()
240: {
241: if (PHP_SAPI === 'cli' && _cfg('env') != ENV_TEST) {
242: return false;
243: }
244:
245: $realPath = explode('/', route_path());
246: $routes = self::$routes;
247:
248: if (!(is_array($routes) && count($routes))) {
249: return false;
250: }
251:
252: $matchedKey = null;
253: $matchedRoute = array_filter($routes, function ($array, $key) use ($realPath, &$matchedKey) {
254: if ($array['to'] instanceof \Closure) {
255: $path = '/' . implode('/', $realPath);
256: if ($array['path'] == $path && in_array($_SERVER['REQUEST_METHOD'], $array['method'])) {
257: $matchedKey = $key;
258: return true;
259: }
260: } else {
261: $last = array_pop($realPath);
262: $path = '/' . implode('/', $realPath);
263: if ($array['path'] == $path && in_array($_SERVER['REQUEST_METHOD'], $array['method'])
264: && file_exists(APP_ROOT . $array['to'] . _DS_ . $last . '.php')) {
265: $matchedKey = $key;
266: return true;
267: }
268: }
269:
270: return false;
271: }, ARRAY_FILTER_USE_BOTH);
272:
273: if (count($matchedRoute)) {
274: if (isset($matchedRoute[$matchedKey]) && $matchedRoute[$matchedKey]['to'] instanceof \Closure) {
275: return $matchedRoute[$matchedKey]['to'];
276: }
277:
278: return false;
279: }
280:
281: $found = false;
282: foreach ($routes as $key => $value) {
283: $patternPath = explode('/', trim($value['path'], '/'));
284: if (count($realPath) !== count($patternPath)) {
285: continue;
286: }
287:
288: $vars = array();
289: $matchedPath = array();
290: foreach ($patternPath as $i => $segment) {
291: if ($segment === $realPath[$i]) {
292: $matchedPath[$i] = $segment;
293: } else {
294: if (preg_match('/([a-z0-9\-_\.]*)?{([a-z0-9\_]+)}([a-z0-9\-_\.]*)?/i', $segment, $matches)) {
295: $name = $matches[2];
296: $var = $realPath[$i];
297:
298: if ($matches[1]) {
299: $var = ltrim($var, $matches[1] . '{');
300: }
301:
302: if ($matches[3]) {
303: $var = rtrim($var, '}' . $matches[3]);
304: }
305:
306: if (isset($value['patterns'][$name]) && $value['patterns'][$name]) {
307: $regex = $value['patterns'][$name];
308: if (!preg_match('/^' . $regex . '$/', $var)) {
309: _header(400);
310: throw new \InvalidArgumentException(sprintf('The URL does not satisfy the argument value "%s" for "%s".', $var, $regex));
311: }
312: }
313:
314: $vars[$name] = $var;
315: $matchedPath[$i] = $realPath[$i];
316:
317: continue;
318: }
319: break;
320: }
321: }
322:
323: if (route_path() === implode('/', $matchedPath)) {
324: # Find all routes that have same route paths and are valid for the current request method
325: $matchedRoute = array_filter($routes, function ($array) use ($value) {
326: return $array['path'] == $value['path'] && in_array($_SERVER['REQUEST_METHOD'], $array['method']);
327: });
328:
329: if (count($matchedRoute)) {
330: $key = array_keys($matchedRoute)[0];
331: $value = $matchedRoute[$key];
332: $found = true;
333: break;
334: } else {
335: if (!in_array($_SERVER['REQUEST_METHOD'], $value['method'])) {
336: _header(405);
337: throw new \RuntimeException(sprintf('The URL does not allow the method "%s" for "%s".', $_SERVER['REQUEST_METHOD'], $key));
338: }
339: }
340: }
341: }
342:
343: if ($found && !empty($key) && !empty($value)) {
344: self::$matchedRouteName = $key;
345:
346: $toRoute = $value['to'];
347: if (is_string($value['to'])) {
348: $toRoute = trim($value['to'], '/');
349: }
350:
351: $_GET[ROUTE] = $toRoute;
352: if ($value['to'] instanceof \Closure) {
353: $_GET[ROUTE . '_path'] = trim($value['path'], '/');
354: }
355:
356: if (!empty($vars)) {
357: $_GET = array_merge($_GET, $vars);
358: }
359:
360: return $toRoute;
361: }
362:
363: return false;
364: }
365:
366: /**
367: * Get the path from the given name
368: *
369: * @param string $name The route name that is unique to the mapped path
370: * @return string|null
371: */
372: public static function getPathByName($name)
373: {
374: return isset(self::$routes[$name]) ? trim(self::$routes[$name]['path'], '/') : null;
375: }
376:
377: /**
378: * Delete all defined named routes
379: *
380: * @return void
381: */
382: public static function clean()
383: {
384: self::$routes = array();
385: }
386:
387: /**
388: * Define route group
389: *
390: * @param string $prefix A prefix for the group of the routes
391: * @param callable $callback The callback function that defines each route in the group
392: */
393: public static function group($prefix, $callback)
394: {
395: $before = self::$routes;
396:
397: $callback();
398:
399: $groupRoutes = array_splice(self::$routes, count($before));
400: foreach ($groupRoutes as $name => $route) {
401: $route['path'] = '/' . ltrim($prefix, '/') . '/' . trim($route['path'], '/');
402: $groupRoutes[$name] = $route;
403: }
404:
405: self::$routes += $groupRoutes;
406: }
407:
408: /**
409: * Get the absolute path from root of the given route
410: *
411: * @param string $q
412: * @return string
413: */
414: public static function getAbsolutePathToRoot($q)
415: {
416: # Get the complete path to root
417: $_page = ROOT . $q;
418:
419: if (!(is_file($_page) && file_exists($_page))) {
420: # Get the complete path with app/
421: $_page = APP_ROOT . $q;
422: # Find the clean route
423: $_seg = explode('/', $q);
424: if (is_dir($_page)) {
425: _cfg('cleanRoute', $q);
426: } else {
427: array_pop($_seg); # remove the last element
428: _cfg('cleanRoute', implode('/', $_seg));
429: }
430: }
431:
432: # if it is a directory, it should have index.php
433: if (is_dir($_page)) {
434: foreach (array('index', 'view') as $pg) {
435: $page = $_page . '/' . $pg . '.php';
436: if (is_file($page) && file_exists($page)) {
437: $_page = $page;
438: break;
439: }
440: }
441: } else {
442: $pathInfo = pathinfo($_page);
443: if (!isset($pathInfo['extension'])) {
444: $_page .= '.php';
445: }
446: }
447:
448: return $_page;
449: }
450: }
451: