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: $languages = _cfg('languages');
102: if (count($languages) <= 1) {
103: _cfg('translationEnabled', false);
104: }
105: }
106:
107: /**
108: * Returns the requested URL path of the page being viewed.
109: * Examples:
110: * - http://example.com/foo/bar returns "foo/bar".
111: *
112: * @return string The requested URL path.
113: */
114: public static function request()
115: {
116: global $lc_baseURL;
117: global $lc_languages;
118: global $lc_lang;
119: global $lc_langInURI;
120:
121: $lc_langInURI = _getLangInURI();
122: if ($lc_langInURI === false) {
123: $lc_lang = $lang = _cfg('defaultLang');
124: } else {
125: $lc_lang = $lang = $lc_langInURI;
126: }
127:
128: if (isset($_GET[ROUTE]) && is_string($_GET[ROUTE])) {
129: # This is a request with a ?route=foo/bar query string.
130: $path = $_GET[ROUTE];
131: if (isset($_GET['lang']) && $_GET['lang']) {
132: $lang = strip_tags(urldecode($_GET['lang']));
133: $lang = rtrim($lang, '/');
134: if (array_key_exists($lang, $lc_languages)) {
135: $lc_lang = $lang;
136: }
137: }
138: } elseif (isset($_SERVER['REQUEST_URI'])) {
139: # This request is either a clean URL, or 'index.php', or nonsense.
140: # Extract the path from REQUEST_URI.
141: $requestPath = urldecode(strtok($_SERVER['REQUEST_URI'], '?'));
142: $requestPath = str_replace($lc_baseURL, '', ltrim($requestPath, '/'));
143: $requestPath = ltrim($requestPath, '/');
144:
145: if ($lang) {
146: $lc_lang = $lang;
147: $path = trim($requestPath, '/');
148: if (strpos($path, $lc_lang) === 0) {
149: $path = substr($path, strlen($lang));
150: }
151: } else {
152: $path = trim($requestPath);
153: }
154:
155: # If the path equals the script filename, either because 'index.php' was
156: # explicitly provided in the URL, or because the server added it to
157: # $_SERVER['REQUEST_URI'] even when it wasn't provided in the URL (some
158: # versions of Microsoft IIS do this), the front page should be served.
159: if ($path == basename($_SERVER['PHP_SELF'])) {
160: $path = '';
161: }
162: } else {
163: # This is the front page.
164: $path = '';
165: }
166:
167: # Under certain conditions Apache's RewriteRule directive prepends the value
168: # assigned to $_GET[ROUTE] with a slash. Moreover, we can always have a trailing
169: # slash in place, hence we need to normalize $_GET[ROUTE].
170: $path = trim($path, '/');
171:
172: if (!defined('WEB_ROOT')) {
173: $baseUrl = _baseUrlWithProtocol();
174: if ($baseUrl) {
175: # path to the web root
176: define('WEB_ROOT', $baseUrl . '/');
177: # path to the web app root
178: define('WEB_APP_ROOT', WEB_ROOT . APP_DIR . '/');
179: # path to the home page
180: define('HOME', WEB_ROOT);
181: }
182: }
183:
184: session_set('lang', $lc_lang);
185:
186: return $path;
187: }
188:
189: /**
190: * Define the custom routing path
191: *
192: * @param string $name Any unique route name to the mapped $path
193: * @param string $path URL path with optional dynamic variables such as `/post/{id}/edit`
194: * @param string $to The real path to a directory or file in /app
195: * @param string $method GET, POST, PUT or DELETE or any combination with `|` such as GET|POST
196: * @param array|null $patterns array of the regex patterns for variables in $path such s `array('id' => '\d+')`
197: * @return Router
198: */
199: public function add($name, $path, $to, $method = 'GET', $patterns = null)
200: {
201: $this->name = $name;
202:
203: $method = explode('|', strtoupper($method));
204: $methods = array_filter($method, function ($value) {
205: return in_array($value, array('GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'CONNECT', 'TRACE'));
206: });
207:
208: if (count($methods) == 0) {
209: $methods = array('GET');
210: }
211:
212: $methods[] = 'OPTIONS';
213: $methods = array_unique($methods);
214:
215: self::$routes[$this->name] = array(
216: 'path' => $path,
217: 'to' => $to,
218: 'method' => $methods,
219: 'patterns' => $patterns
220: );
221:
222: return $this;
223: }
224:
225: /**
226: * Define the custom routing path
227: *
228: * @param string $path URL path with optional dynamic variables such as `/post/{id}/edit`
229: * @param string $to The real path to a directory or file in `/app`
230: * @param string $method GET, POST, PUT or DELETE or any combination with `|` such as GET|POST
231: * @param array|null $patterns array of the regex patterns for variables in $path such s `array('id' => '\d+')`
232: * @return Router
233: */
234: public function map($path, $to, $method = 'GET', $patterns = null)
235: {
236: return $this->add($this->name, $path, $to, $method, $patterns);
237: }
238:
239: /**
240: * Matching the current route to the defined custom routes
241: *
242: * @return string|boolean The matched route or false if no matched route is found
243: */
244: public static function match()
245: {
246: if (PHP_SAPI === 'cli' && _cfg('env') != ENV_TEST) {
247: return false;
248: }
249:
250: $realPath = explode('/', route_path());
251: $routes = self::$routes;
252:
253: if (!(is_array($routes) && count($routes))) {
254: return false;
255: }
256:
257: $matchedRoute = array_filter($routes, function ($array) use ($realPath) {
258: $last = array_pop($realPath);
259: $path = '/' . implode('/', $realPath);
260: if ($array['path'] == $path && in_array($_SERVER['REQUEST_METHOD'], $array['method'])
261: && file_exists(APP_ROOT . $array['to'] . _DS_ . $last . '.php')) {
262: return true;
263: }
264:
265: return false;
266: });
267:
268: if (count($matchedRoute)) {
269: return false;
270: }
271:
272: $found = false;
273: foreach ($routes as $key => $value) {
274: $patternPath = explode('/', trim($value['path'], '/'));
275: if (count($realPath) !== count($patternPath)) {
276: continue;
277: }
278:
279: $vars = array();
280: $matchedPath = array();
281: foreach ($patternPath as $i => $segment) {
282: if ($segment === $realPath[$i]) {
283: $matchedPath[$i] = $segment;
284: } else {
285: if (preg_match('/([a-z0-9\-_\.]*)?{([a-z0-9\_]+)}([a-z0-9\-_\.]*)?/i', $segment, $matches)) {
286: $name = $matches[2];
287: $var = $realPath[$i];
288:
289: if ($matches[1]) {
290: $var = ltrim($var, $matches[1] . '{');
291: }
292:
293: if ($matches[3]) {
294: $var = rtrim($var, '}' . $matches[3]);
295: }
296:
297: if (isset($value['patterns'][$name]) && $value['patterns'][$name]) {
298: $regex = $value['patterns'][$name];
299: if (!preg_match('/^' . $regex . '$/', $var)) {
300: _header(400);
301: throw new \InvalidArgumentException(sprintf('The URL does not satisfy the argument value "%s" for "%s".', $var, $regex));
302: }
303: }
304:
305: $vars[$name] = $var;
306: $matchedPath[$i] = $realPath[$i];
307:
308: continue;
309: }
310: break;
311: }
312: }
313:
314: if (route_path() === implode('/', $matchedPath)) {
315: # Find all routes that have same route paths and are valid for the current request method
316: $matchedRoute = array_filter($routes, function ($array) use ($value) {
317: return $array['path'] == $value['path'] && in_array($_SERVER['REQUEST_METHOD'], $array['method']);
318: });
319:
320: if (count($matchedRoute)) {
321: $key = array_keys($matchedRoute)[0];
322: $value = $matchedRoute[$key];
323: $found = true;
324: break;
325: } else {
326: if (!in_array($_SERVER['REQUEST_METHOD'], $value['method'])) {
327: _header(405);
328: throw new \RuntimeException(sprintf('The URL does not allow the method "%s" for "%s".', $_SERVER['REQUEST_METHOD'], $key));
329: }
330: }
331: }
332: }
333:
334: if ($found) {
335: self::$matchedRouteName = $key;
336: $toRoute = trim($value['to'], '/');
337: $_GET[ROUTE] = $toRoute;
338: $_GET = array_merge($_GET, $vars);
339: return $toRoute;
340: }
341:
342: return false;
343: }
344:
345: /**
346: * Get the path from the given name
347: *
348: * @param string $name The route name that is unique to the mapped path
349: * @return string|null
350: */
351: public static function getPathByName($name)
352: {
353: return isset(self::$routes[$name]) ? trim(self::$routes[$name]['path'], '/') : null;
354: }
355:
356: /**
357: * Delete all defined named routes
358: *
359: * @return void
360: */
361: public static function clean()
362: {
363: self::$routes = array();
364: }
365:
366: /**
367: * Define route group
368: *
369: * @param string $prefix A prefix for the group of the routes
370: * @param callable $callback The callback function that defines each route in the group
371: */
372: public static function group($prefix, $callback)
373: {
374: $before = self::$routes;
375:
376: $callback();
377:
378: $groupRoutes = array_splice(self::$routes, count($before));
379: foreach ($groupRoutes as $name => $route) {
380: $route['path'] = '/' . ltrim($prefix, '/') . '/' . trim($route['path'], '/');
381: $groupRoutes[$name] = $route;
382: }
383:
384: self::$routes += $groupRoutes;
385: }
386:
387: /**
388: * Get the absolute path from root of the given route
389: *
390: * @param string $q
391: * @return string
392: */
393: public static function getAbsolutePathToRoot($q)
394: {
395: # Get the complete path to root
396: $_page = ROOT . $q;
397:
398: if (!(is_file($_page) && file_exists($_page))) {
399: # Get the complete path with app/
400: $_page = APP_ROOT . $q;
401: # Find the clean route
402: $_seg = explode('/', $q);
403: if (is_dir($_page)) {
404: _cfg('cleanRoute', $q);
405: } else {
406: array_pop($_seg); # remove the last element
407: _cfg('cleanRoute', implode('/', $_seg));
408: }
409: }
410:
411: # if it is a directory, it should have index.php
412: if (is_dir($_page)) {
413: foreach (array('index', 'view') as $pg) {
414: $page = $_page . '/' . $pg . '.php';
415: if (is_file($page) && file_exists($page)) {
416: $_page = $page;
417: break;
418: }
419: }
420: } else {
421: $pathInfo = pathinfo($_page);
422: if (!isset($pathInfo['extension'])) {
423: $_page .= '.php';
424: }
425: }
426:
427: return $_page;
428: }
429: }
430: