1 <?php
2
3 namespace Alo\Controller;
4
5 use Alo;
6 use Alo\Exception\ControllerException as CE;
7 use ReflectionClass;
8
9 if (!defined('GEN_START')) {
10 http_response_code(404);
11 die();
12 }
13
14 /**
15 * Handles routing to the correct controller and method
16 *
17 * @author Art <a.molcanovas@gmail.com>
18 */
19 class Router {
20
21 /**
22 * Pretty self-explanatory, isn't it?
23 *
24 * @var string
25 */
26 const CONTROLLER_NAMESPACE = '\Controller\\';
27
28 /**
29 * Delimiter used in the regex checking
30 *
31 * @var string
32 */
33 const PREG_DELIMITER = '~';
34
35 /**
36 * The server name
37 *
38 * @var string
39 */
40 protected $server_name;
41
42 /**
43 * The server IP
44 *
45 * @var string
46 */
47 protected $server_addr;
48
49 /**
50 * The port in use
51 *
52 * @var int
53 */
54 protected $port;
55
56 /**
57 * The remote address
58 *
59 * @var string
60 */
61 protected $remote_addr;
62
63 /**
64 * The request scheme
65 *
66 * @var string
67 */
68 protected $request_scheme;
69
70 /**
71 * The raw path info
72 *
73 * @var string
74 */
75 protected $path;
76
77 /**
78 * Request method in use
79 *
80 * @var string
81 */
82 protected $request_method;
83
84 /**
85 * Directory name
86 *
87 * @var string
88 */
89 protected $dir;
90
91 /**
92 * Controller name
93 *
94 * @var string
95 */
96 protected $controller;
97
98 /**
99 * Method name
100 *
101 * @var string
102 */
103 protected $method;
104
105 /**
106 * Arguments to pass on to the method
107 *
108 * @var array
109 */
110 protected $method_args;
111
112 /**
113 * The error controller name
114 *
115 * @var string
116 */
117 protected $err_controller;
118
119 /**
120 * The default controller
121 *
122 * @var string
123 */
124 protected $default_controller;
125
126 /**
127 * The routes array
128 *
129 * @var array
130 */
131 protected $routes;
132
133 /**
134 * Whether we're dealing with a CLI request...
135 *
136 * @var boolean
137 */
138 protected $is_cli_request;
139
140 /**
141 * Whether we're dealing with an AJAX request
142 *
143 * @var boolean
144 */
145 protected $is_ajax_request;
146
147 /**
148 * Default params for a route
149 *
150 * @var array
151 */
152 protected static $route_defaults = [
153 'dir' => null,
154 'method' => 'index',
155 'args' => []
156 ];
157
158 /**
159 * Initialises the router
160 *
161 * @author Art <a.molcanovas@gmail.com>
162 * @return Router
163 */
164 function init() {
165 return $this->initNoCall()->tryCall();
166 }
167
168 /**
169 * Same as init(), but without attempting to call the controller
170 *
171 * @author Art <a.molcanovas@gmail.com>
172 * @return Router
173 */
174 function initNoCall() {
175 $this->is_cli_request = php_sapi_name() == 'cli' || defined('STDIN');
176 $this->is_ajax_request = \get($_SERVER['HTTP_X_REQUESTED_WITH']) == 'XMLHttpRequest';
177
178 return $this->init_server_vars()
179 ->init_path()
180 ->init_routes()
181 ->resolvePath();
182 }
183
184 /**
185 * Returns whether this is a CLI request
186 *
187 * @author Art <a.molcanovas@gmail.com>
188 * @return bool
189 */
190 function is_cli_request() {
191 return $this->is_cli_request;
192 }
193
194 /**
195 * Returns whether this is an AJAX request
196 *
197 * @author Art <a.molcanovas@gmail.com>
198 * @return bool
199 */
200 function is_ajax_request() {
201 return $this->is_ajax_request;
202 }
203
204 /**
205 * Forces the error controller
206 *
207 * @author Art <a.molcanovas@gmail.com>
208 * @param string $msg Optionally, the error message thrown by ReflectionClass
209 * or ReflectionMethod
210 * @return \Alo\Controller\Router
211 * @throws CE If the controller is already the error controller
212 * @uses self::tryCall()
213 */
214 protected function forceError($msg = null) {
215 if ($this->controller != $this->err_controller) {
216 \Log::debug('404\'d on path: ' . $this->path . '. Settings were as follows: dir: ' . $this->dir . ', class: '
217 . $this->controller . ', method: ' . $this->method . ', args: ' . json_encode($this->method_args));
218
219 $path = DIR_CONTROLLERS . strtolower($this->err_controller) . '.php';
220 if (file_exists($path)) {
221 include_once $path;
222 }
223
224 $this->controller = $this->err_controller;
225 $this->method = 'error';
226 $this->method_args = [404];
227 $this->tryCall();
228 } else {
229 throw new CE('No route available and the error controller '
230 . 'is invalid.' . ($msg ? ' Exception message returned: '
231 . $msg : ''), CE::E_INVALID_ROUTE);
232 }
233
234 return $this;
235 }
236
237 /**
238 * Returns the error controller name
239 *
240 * @author Art <a.molcanovas@gmail.com>
241 * @return string
242 */
243 function getErrController() {
244 return $this->err_controller;
245 }
246
247 /**
248 * Tries to call the appropriate class' method
249 *
250 * @author Art <a.molcanovas@gmail.com>
251 * @return Router
252 * @uses self::forceError()
253 */
254 protected function tryCall() {
255 $rc = $rm = $init = false;
256
257 try {
258 $rc = new ReflectionClass(self::CONTROLLER_NAMESPACE . $this->controller);
259
260 //Must be abstract controller's subclass
261 if (!$rc->isAbstract() &&
262 $rc->isSubclassOf('\Alo\Controller\AbstractController')
263 ) {
264 $rm = $rc->getMethod($this->method);
265
266 //And a public method
267 if ($rm->isPublic() && !$rm->isAbstract() && !$rm->isStatic()) {
268 //Excellent. Instantiate!
269 $init = true;
270 } else {
271 $this->forceError();
272 }
273 } else {
274 $this->forceError();
275 }
276 } catch (\ReflectionException $ex) {
277 $this->forceError($ex->getMessage());
278 }
279
280 if ($init) {
281 \Log::debug('Initialising controller ' . $this->controller . '->' . $this->method . '(' . implode(',', $this->method_args) . ')');
282 $controller_name = self::CONTROLLER_NAMESPACE . $this->controller;
283 Alo::$controller = new $controller_name;
284 call_user_func_array([Alo::$controller, $this->method], $this->method_args);
285 }
286
287 return $this;
288 }
289
290 /**
291 * Resolves the controller/method path
292 *
293 * @author Art <a.molcanovas@gmail.com>
294 * @return Router
295 * @todo Remove comment end debug output
296 */
297 protected function resolvePath() {
298 //Use the default controller if the path is unavailable
299 if (!$this->path) {
300 $filepath = DIR_CONTROLLERS . strtolower($this->default_controller) . '.php';
301
302 if (file_exists($filepath)) {
303 include_once $filepath;
304 }
305
306 $this->controller = $this->default_controller;
307 $this->method = self::$route_defaults['method'];
308 $this->method_args = self::$route_defaults['args'];
309 $this->dir = self::$route_defaults['dir'];
310 } else {
311 $resolved = false;
312
313 //Check if there's a route
314 foreach ($this->routes as $source => $dest) {
315 $source_replace = trim(str_replace(self::PREG_DELIMITER, '\\' . self::PREG_DELIMITER, $source), '/');
316 $regex = self::PREG_DELIMITER . '^' . $source_replace . '/?' . '$' . self::PREG_DELIMITER . 'is';
317
318 if (preg_match($regex, $this->path)) {
319 $resolved = true;
320 $explode = explode('/', $this->path);
321
322 $this->dir = $dest['dir'] ? $dest['dir'] . DIRECTORY_SEPARATOR : self::$route_defaults['dir'];
323 $this->controller = isset($dest['class']) ? $dest['class'] : $explode[0];
324
325 //Remove controller
326 array_shift($explode);
327
328 //Set method
329 if ($dest['method'] != self::$route_defaults['method']) {
330 $this->method = $dest['method'];
331 } elseif (isset($explode[0])) {
332 $this->method = $explode[0];
333 } else {
334 $this->method = self::$route_defaults['method'];
335 }
336
337 //Remove controller method
338 if (!empty($explode)) {
339 array_shift($explode);
340 }
341
342 //Set preliminary method args
343 if ($dest['args'] != self::$route_defaults['args']) {
344 $this->method_args = $dest['args'];
345 } elseif (!empty($explode)) {
346 $this->method_args = $explode;
347 } else {
348 $this->method_args = self::$route_defaults['args'];
349 }
350
351 //echo debug([
352 // 'source_replace' => $source_replace,
353 // 'path' => $this->path,
354 // 'dest' => $dest,
355 // '$this->dir' => $this->dir,
356 // '$this->controller' => $this->controller,
357 // '$this->method' => $this->method,
358 // '$this->args' => $this->method_args,
359 // 'regex' => $regex,
360 // 'replace_with' => '[\'' . implode('\',\'', $this->method_args) . '\']',
361 // 'replace_final' => json_decode(preg_replace($regex, '["' . implode('","', $this->method_args) . '"]', $this->path), true)
362 //]);
363
364 $replace = explode('/', preg_replace($regex, implode('/', $this->method_args), $this->path));
365
366 //Remove empties
367 foreach ($replace as $k => $v) {
368 if ($v == '') {
369 unset($replace[$k]);
370 }
371 }
372
373 $this->method_args = $replace;
374
375 //echo debug($this->method_args);
376
377 break;
378 }
379 }
380
381 if (!$resolved) {
382 //If not, assume the path is controller/method/arg1...
383 $path = explode('/', $this->path);
384
385 $this->dir = null;
386 $this->controller = array_shift($path);
387 $this->method = empty($path) ? self::$route_defaults['method'] : array_shift($path);
388 $this->method_args = $path;
389 }
390
391 $filepath = DIR_CONTROLLERS . str_replace('/', DIRECTORY_SEPARATOR, $this->dir) . $this->controller . '.php';
392
393 if (file_exists($filepath)) {
394 include_once $filepath;
395 }
396 }
397
398 return $this;
399 }
400
401 /**
402 * Initialises the routing variables
403 *
404 * @author Art <a.molcanovas@gmail.com>
405 * @throws CE When the config file is not found
406 * @throws CE When $error_controller_class is not present in the config file
407 * @throws CE When The default controller is not present in the config file
408 * @throws CE When $routes is not a valid array
409 * @throws CE When a route value is not an array.
410 * @return Router
411 */
412 protected function init_routes() {
413 $path = \Alo::loadConfig('router', true);
414
415 if (!file_exists($path)) {
416 throw new CE('Routing config file not found.', CE::E_CONFIG_NOT_FOUND);
417 } else {
418 require $path;
419
420 if (!isset($error_controller_class)) {
421 throw new CE('Error controller class not found in config file.', CE::E_ERR_NOT_FOUND);
422 } elseif (!isset($default_controller)) {
423 throw new CE('$default_controller undefined in config file.', CE::E_DEFAULT_UNDEFINED);
424 } elseif (!is_array($routes)) {
425 throw new CE('The routes variable must be an associative array', CE::E_MALFORMED_ROUTES);
426 } else {
427 $this->err_controller = $error_controller_class;
428 $this->default_controller = $default_controller;
429
430 foreach ($routes as $k => $v) {
431 if (is_array($v)) {
432 $this->routes[strtolower($k)] = array_merge(self::$route_defaults, $v);
433 } else {
434 throw new CE('Route ' . $k . ' is not a valid array.', CE::E_MALFORMED_ROUTES);
435 }
436 }
437
438 \Log::debug('Routes initialised');
439 }
440 }
441
442 return $this;
443 }
444
445 /**
446 * Initialises the raw path variable
447 *
448 * @author Art <a.molcanovas@gmail.com>
449 * @return Router
450 */
451 protected function init_path() {
452 if (isset($_SERVER['PATH_INFO'])) {
453 $this->path = ltrim($_SERVER['PATH_INFO'], '/');
454 } elseif (isset($_SERVER['argv'])) {
455 //Shift off the "index.php" bit
456 array_shift($_SERVER['argv']);
457 $this->path = join(DIRECTORY_SEPARATOR, $_SERVER['argv']);
458 } else {
459 $this->path = '';
460 }
461
462 $this->path = strtolower($this->path);
463
464 return $this;
465 }
466
467 /**
468 * Initialises most server variables
469 *
470 * @author Art <a.molcanovas@gmail.com>
471 * @return Router
472 */
473 protected function init_server_vars() {
474 $this->port = \get($_SERVER['SERVER_PORT']) ? (int)$_SERVER['SERVER_PORT'] : null;
475 $this->remote_addr = \get($_SERVER['REMOTE_ADDR']);
476 $this->request_scheme = \get($_SERVER['REQUEST_SCHEME']);
477 $this->request_method = \get($_SERVER['REQUEST_METHOD']);
478 $this->server_addr = \get($_SERVER['SERVER_ADDR']);
479 $this->server_name = \get($_SERVER['SERVER_NAME']);
480
481 return $this;
482 }
483
484 /**
485 * Returns the controller method name
486 *
487 * @author Art <a.molcanovas@gmail.com>
488 * @return string
489 */
490 function getMethod() {
491 return $this->method;
492 }
493
494 /**
495 * Returns the controller name
496 *
497 * @author Art <a.molcanovas@gmail.com>
498 * @return string
499 */
500 function getController() {
501 return $this->controller;
502 }
503
504 /**
505 * Returns the request port used
506 *
507 * @author Art <a.molcanovas@gmail.com>
508 * @return int
509 */
510 function getPort() {
511 return $this->port;
512 }
513
514 /**
515 * Returns the directory name
516 *
517 * @author Art <a.molcanovas@gmail.com>
518 * @return string
519 */
520 function getDir() {
521 return $this->dir;
522 }
523
524 /**
525 * Returns the request remote IP
526 *
527 * @author Art <a.molcanovas@gmail.com>
528 * @return string
529 */
530 function getRemoteAddr() {
531 return $this->remote_addr;
532 }
533
534 /**
535 * Returns the request method used
536 *
537 * @author Art <a.molcanovas@gmail.com>
538 * @return string
539 */
540 function getRequestMethod() {
541 return $this->request_method;
542 }
543
544 /**
545 * Returns the request scheme used
546 *
547 * @author Art <a.molcanovas@gmail.com>
548 * @return string
549 */
550 function getRequestScheme() {
551 return $this->request_scheme;
552 }
553
554 /**
555 * Returns the server internal IP
556 *
557 * @author Art <a.molcanovas@gmail.com>
558 * @return string
559 */
560 function getServerAddr() {
561 return $this->server_addr;
562 }
563
564 /**
565 * Returns the server name
566 *
567 * @author Art <a.molcanovas@gmail.com>
568 * @return string
569 */
570 function getServerName() {
571 return $this->server_name;
572 }
573
574 /**
575 * Returns the request path
576 *
577 * @author Art <a.molcanovas@gmail.com>
578 * @return string
579 */
580 function getPath() {
581 return $this->path;
582 }
583
584 /**
585 * Returns a string representation of the object data
586 *
587 * @author Art <a.molcanovas@gmail.com>
588 * @return string
589 */
590 function __toString() {
591 return strip_tags(\lite_debug($this));
592 }
593
594 }