1<?php
2/**
3 * Copyright since 2007 PrestaShop SA and Contributors
4 * PrestaShop is an International Registered Trademark & Property of PrestaShop SA
5 *
6 * NOTICE OF LICENSE
7 *
8 * This source file is subject to the Open Software License (OSL 3.0)
9 * that is bundled with this package in the file LICENSE.md.
10 * It is also available through the world-wide-web at this URL:
11 * https://opensource.org/licenses/OSL-3.0
12 * If you did not receive a copy of the license and are unable to
13 * obtain it through the world-wide-web, please send an email
14 * to license@prestashop.com so we can send you a copy immediately.
15 *
16 * DISCLAIMER
17 *
18 * Do not edit or add to this file if you wish to upgrade PrestaShop to newer
19 * versions in the future. If you wish to customize PrestaShop for your
20 * needs please refer to https://devdocs.prestashop.com/ for more information.
21 *
22 * @author    PrestaShop SA and Contributors <contact@prestashop.com>
23 * @copyright Since 2007 PrestaShop SA and Contributors
24 * @license   https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
25 */
26use Symfony\Component\HttpFoundation\Request as SymfonyRequest;
27
28/**
29 * @since 1.5.0
30 */
31class DispatcherCore
32{
33    /**
34     * List of available front controllers types.
35     */
36    const FC_FRONT = 1;
37    const FC_ADMIN = 2;
38    const FC_MODULE = 3;
39
40    const REWRITE_PATTERN = '[_a-zA-Z0-9\x{0600}-\x{06FF}\pL\pS-]*?';
41
42    /**
43     * @var Dispatcher
44     */
45    public static $instance = null;
46
47    /**
48     * @var SymfonyRequest
49     */
50    private $request;
51
52    /**
53     * @var array List of default routes
54     */
55    public $default_routes = [
56        'category_rule' => [
57            'controller' => 'category',
58            'rule' => '{id}-{rewrite}',
59            'keywords' => [
60                'id' => ['regexp' => '[0-9]+', 'param' => 'id_category'],
61                'rewrite' => ['regexp' => self::REWRITE_PATTERN],
62                'meta_keywords' => ['regexp' => '[_a-zA-Z0-9-\pL]*'],
63                'meta_title' => ['regexp' => '[_a-zA-Z0-9-\pL]*'],
64            ],
65        ],
66        'supplier_rule' => [
67            'controller' => 'supplier',
68            'rule' => 'supplier/{id}-{rewrite}',
69            'keywords' => [
70                'id' => ['regexp' => '[0-9]+', 'param' => 'id_supplier'],
71                'rewrite' => ['regexp' => self::REWRITE_PATTERN],
72                'meta_keywords' => ['regexp' => '[_a-zA-Z0-9-\pL]*'],
73                'meta_title' => ['regexp' => '[_a-zA-Z0-9-\pL]*'],
74            ],
75        ],
76        'manufacturer_rule' => [
77            'controller' => 'manufacturer',
78            'rule' => 'brand/{id}-{rewrite}',
79            'keywords' => [
80                'id' => ['regexp' => '[0-9]+', 'param' => 'id_manufacturer'],
81                'rewrite' => ['regexp' => self::REWRITE_PATTERN],
82                'meta_keywords' => ['regexp' => '[_a-zA-Z0-9-\pL]*'],
83                'meta_title' => ['regexp' => '[_a-zA-Z0-9-\pL]*'],
84            ],
85        ],
86        'cms_rule' => [
87            'controller' => 'cms',
88            'rule' => 'content/{id}-{rewrite}',
89            'keywords' => [
90                'id' => ['regexp' => '[0-9]+', 'param' => 'id_cms'],
91                'rewrite' => ['regexp' => self::REWRITE_PATTERN],
92                'meta_keywords' => ['regexp' => '[_a-zA-Z0-9-\pL]*'],
93                'meta_title' => ['regexp' => '[_a-zA-Z0-9-\pL]*'],
94            ],
95        ],
96        'cms_category_rule' => [
97            'controller' => 'cms',
98            'rule' => 'content/category/{id}-{rewrite}',
99            'keywords' => [
100                'id' => ['regexp' => '[0-9]+', 'param' => 'id_cms_category'],
101                'rewrite' => ['regexp' => self::REWRITE_PATTERN],
102                'meta_keywords' => ['regexp' => '[_a-zA-Z0-9-\pL]*'],
103                'meta_title' => ['regexp' => '[_a-zA-Z0-9-\pL]*'],
104            ],
105        ],
106        'module' => [
107            'controller' => null,
108            'rule' => 'module/{module}{/:controller}',
109            'keywords' => [
110                'module' => ['regexp' => '[_a-zA-Z0-9_-]+', 'param' => 'module'],
111                'controller' => ['regexp' => '[_a-zA-Z0-9_-]+', 'param' => 'controller'],
112            ],
113            'params' => [
114                'fc' => 'module',
115            ],
116        ],
117        'product_rule' => [
118            'controller' => 'product',
119            'rule' => '{category:/}{id}{-:id_product_attribute}-{rewrite}{-:ean13}.html',
120            'keywords' => [
121                'id' => ['regexp' => '[0-9]+', 'param' => 'id_product'],
122                'id_product_attribute' => ['regexp' => '[0-9]+', 'param' => 'id_product_attribute'],
123                'rewrite' => ['regexp' => self::REWRITE_PATTERN, 'param' => 'rewrite'],
124                'ean13' => ['regexp' => '[0-9\pL]*'],
125                'category' => ['regexp' => '[_a-zA-Z0-9-\pL]*'],
126                'categories' => ['regexp' => '[/_a-zA-Z0-9-\pL]*'],
127                'reference' => ['regexp' => '[_a-zA-Z0-9-\pL]*'],
128                'meta_keywords' => ['regexp' => '[_a-zA-Z0-9-\pL]*'],
129                'meta_title' => ['regexp' => '[_a-zA-Z0-9-\pL]*'],
130                'manufacturer' => ['regexp' => '[_a-zA-Z0-9-\pL]*'],
131                'supplier' => ['regexp' => '[_a-zA-Z0-9-\pL]*'],
132                'price' => ['regexp' => '[0-9\.,]*'],
133                'tags' => ['regexp' => '[a-zA-Z0-9-\pL]*'],
134            ],
135        ],
136        /* Must be after the product and category rules in order to avoid conflict */
137        'layered_rule' => [
138            'controller' => 'category',
139            'rule' => '{id}-{rewrite}{/:selected_filters}',
140            'keywords' => [
141                'id' => ['regexp' => '[0-9]+', 'param' => 'id_category'],
142                /* Selected filters is used by the module blocklayered */
143                'selected_filters' => ['regexp' => '.*', 'param' => 'selected_filters'],
144                'rewrite' => ['regexp' => self::REWRITE_PATTERN],
145                'meta_keywords' => ['regexp' => '[_a-zA-Z0-9-\pL]*'],
146                'meta_title' => ['regexp' => '[_a-zA-Z0-9-\pL]*'],
147            ],
148        ],
149    ];
150
151    /**
152     * @var bool If true, use routes to build URL (mod rewrite must be activated)
153     */
154    protected $use_routes = false;
155
156    protected $multilang_activated = false;
157
158    /**
159     * @var array List of loaded routes
160     */
161    protected $routes = [];
162
163    /**
164     * @var string Current controller name
165     */
166    protected $controller;
167
168    /**
169     * @var string Current request uri
170     */
171    protected $request_uri;
172
173    /**
174     * @var array Store empty route (a route with an empty rule)
175     */
176    protected $empty_route;
177
178    /**
179     * @var string Set default controller, which will be used if http parameter 'controller' is empty
180     */
181    protected $default_controller;
182    protected $use_default_controller = false;
183
184    /**
185     * @var string Controller to use if found controller doesn't exist
186     */
187    protected $controller_not_found = 'pagenotfound';
188
189    /**
190     * @var string Front controller to use
191     */
192    protected $front_controller = self::FC_FRONT;
193
194    /**
195     * Get current instance of dispatcher (singleton).
196     *
197     * @return Dispatcher
198     *
199     * @throws PrestaShopException
200     */
201    public static function getInstance(SymfonyRequest $request = null)
202    {
203        if (!self::$instance) {
204            if (null === $request) {
205                $request = SymfonyRequest::createFromGlobals();
206            }
207            self::$instance = new Dispatcher($request);
208        }
209
210        return self::$instance;
211    }
212
213    /**
214     * Needs to be instantiated from getInstance() method.
215     *
216     * @param SymfonyRequest|null $request
217     *
218     * @throws PrestaShopException
219     */
220    protected function __construct(SymfonyRequest $request = null)
221    {
222        $this->setRequest($request);
223
224        $this->use_routes = (bool) Configuration::get('PS_REWRITING_SETTINGS');
225
226        // Select right front controller
227        if (defined('_PS_ADMIN_DIR_')) {
228            $this->front_controller = self::FC_ADMIN;
229            $this->controller_not_found = 'adminnotfound';
230        } elseif (Tools::getValue('fc') == 'module') {
231            $this->front_controller = self::FC_MODULE;
232            $this->controller_not_found = 'pagenotfound';
233        } else {
234            $this->front_controller = self::FC_FRONT;
235            $this->controller_not_found = 'pagenotfound';
236        }
237
238        $this->setRequestUri();
239
240        // Switch language if needed (only on front)
241        if (in_array($this->front_controller, [self::FC_FRONT, self::FC_MODULE])) {
242            Tools::switchLanguage();
243        }
244
245        if (Language::isMultiLanguageActivated()) {
246            $this->multilang_activated = true;
247        }
248
249        $this->loadRoutes();
250    }
251
252    /**
253     * Either sets a given request or a new one.
254     *
255     * @param SymfonyRequest|null $request
256     */
257    private function setRequest(SymfonyRequest $request = null)
258    {
259        if (null === $request) {
260            $request = SymfonyRequest::createFromGlobals();
261        }
262
263        $this->request = $request;
264    }
265
266    /**
267     * Returns the request property.
268     *
269     * @return SymfonyRequest
270     */
271    private function getRequest()
272    {
273        return $this->request;
274    }
275
276    /**
277     * Sets and returns the default controller.
278     *
279     * @param int $frontControllerType The front controller type
280     * @param Employee|null $employee The current employee
281     *
282     * @return string
283     */
284    private function getDefaultController($frontControllerType, Employee $employee = null)
285    {
286        switch ($frontControllerType) {
287            case self::FC_ADMIN:
288                // Default
289                $defaultController = 'AdminDashboard';
290                // If there is an employee with a default tab set
291                if (null !== $employee) {
292                    $tabClassName = $employee->getDefaultTabClassName();
293                    if (null !== $tabClassName) {
294                        $tabProfileAccess = Profile::getProfileAccess($employee->id_profile, Tab::getIdFromClassName($tabClassName));
295                        if (is_array($tabProfileAccess) && isset($tabProfileAccess['view']) && $tabProfileAccess['view'] === '1') {
296                            $defaultController = $tabClassName;
297                        }
298                    }
299                }
300
301                break;
302            case self::FC_MODULE:
303                $defaultController = 'default';
304
305                break;
306            default:
307                $defaultController = 'index';
308        }
309
310        $this->setDefaultController($defaultController);
311
312        return $defaultController;
313    }
314
315    /**
316     * Sets the default controller.
317     *
318     * @param string $defaultController
319     */
320    private function setDefaultController($defaultController)
321    {
322        $this->default_controller = $defaultController;
323    }
324
325    /**
326     * Sets use_default_controller to true, sets and returns the default controller.
327     *
328     * @return string
329     */
330    public function useDefaultController()
331    {
332        $this->use_default_controller = true;
333
334        // If it was already set just return it
335        if (null !== $this->default_controller) {
336            return $this->default_controller;
337        }
338
339        $employee = Context::getContext()->employee;
340
341        return $this->getDefaultController($this->front_controller, $employee);
342    }
343
344    /**
345     * Find the controller and instantiate it.
346     */
347    public function dispatch()
348    {
349        $controller_class = '';
350
351        // Get current controller
352        $this->getController();
353        if (!$this->controller) {
354            $this->controller = $this->useDefaultController();
355        }
356        // Execute hook dispatcher before
357        Hook::exec('actionDispatcherBefore', ['controller_type' => $this->front_controller]);
358
359        // Dispatch with right front controller
360        switch ($this->front_controller) {
361            // Dispatch front office controller
362            case self::FC_FRONT:
363                $controllers = Dispatcher::getControllers([
364                    _PS_FRONT_CONTROLLER_DIR_,
365                    _PS_OVERRIDE_DIR_ . 'controllers/front/',
366                ]);
367                $controllers['index'] = 'IndexController';
368                if (isset($controllers['auth'])) {
369                    $controllers['authentication'] = $controllers['auth'];
370                }
371                if (isset($controllers['contact'])) {
372                    $controllers['contactform'] = $controllers['contact'];
373                }
374
375                if (!isset($controllers[strtolower($this->controller)])) {
376                    $this->controller = $this->controller_not_found;
377                }
378                $controller_class = $controllers[strtolower($this->controller)];
379                $params_hook_action_dispatcher = [
380                    'controller_type' => self::FC_FRONT,
381                    'controller_class' => $controller_class,
382                    'is_module' => 0,
383                ];
384
385                break;
386
387            // Dispatch module controller for front office
388            case self::FC_MODULE:
389                $module_name = Validate::isModuleName(Tools::getValue('module')) ? Tools::getValue('module') : '';
390                $module = Module::getInstanceByName($module_name);
391                $controller_class = 'PageNotFoundController';
392                if (Validate::isLoadedObject($module) && $module->active) {
393                    $controllers = Dispatcher::getControllers(_PS_MODULE_DIR_ . "$module_name/controllers/front/");
394                    if (isset($controllers[strtolower($this->controller)])) {
395                        include_once _PS_MODULE_DIR_ . "$module_name/controllers/front/{$this->controller}.php";
396                        if (file_exists(
397                            _PS_OVERRIDE_DIR_ . "modules/$module_name/controllers/front/{$this->controller}.php"
398                        )) {
399                            include_once _PS_OVERRIDE_DIR_ . "modules/$module_name/controllers/front/{$this->controller}.php";
400                            $controller_class = $module_name . $this->controller . 'ModuleFrontControllerOverride';
401                        } else {
402                            $controller_class = $module_name . $this->controller . 'ModuleFrontController';
403                        }
404                    }
405                }
406                $params_hook_action_dispatcher = [
407                    'controller_type' => self::FC_FRONT,
408                    'controller_class' => $controller_class,
409                    'is_module' => 1,
410                ];
411
412                break;
413
414            // Dispatch back office controller + module back office controller
415            case self::FC_ADMIN:
416                if ($this->use_default_controller
417                    && !Tools::getValue('token')
418                    && Validate::isLoadedObject(Context::getContext()->employee)
419                    && Context::getContext()->employee->isLoggedBack()
420                ) {
421                    Tools::redirectAdmin(
422                        "index.php?controller={$this->controller}&token=" . Tools::getAdminTokenLite($this->controller)
423                    );
424                }
425
426                $tab = Tab::getInstanceFromClassName($this->controller, Configuration::get('PS_LANG_DEFAULT'));
427                $retrocompatibility_admin_tab = null;
428
429                if ($tab->module) {
430                    if (file_exists(_PS_MODULE_DIR_ . "{$tab->module}/{$tab->class_name}.php")) {
431                        $retrocompatibility_admin_tab = _PS_MODULE_DIR_ . "{$tab->module}/{$tab->class_name}.php";
432                    } else {
433                        $controllers = Dispatcher::getControllers(_PS_MODULE_DIR_ . $tab->module . '/controllers/admin/');
434                        if (!isset($controllers[strtolower($this->controller)])) {
435                            $this->controller = $this->controller_not_found;
436                            $controller_class = 'AdminNotFoundController';
437                        } else {
438                            $controller_name = $controllers[strtolower($this->controller)];
439                            // Controllers in modules can be named AdminXXX.php or AdminXXXController.php
440                            include_once _PS_MODULE_DIR_ . "{$tab->module}/controllers/admin/$controller_name.php";
441                            if (file_exists(
442                                _PS_OVERRIDE_DIR_ . "modules/{$tab->module}/controllers/admin/$controller_name.php"
443                            )) {
444                                include_once _PS_OVERRIDE_DIR_ . "modules/{$tab->module}/controllers/admin/$controller_name.php";
445                                $controller_class = $controller_name . (
446                                    strpos($controller_name, 'Controller') ? 'Override' : 'ControllerOverride'
447                                );
448                            } else {
449                                $controller_class = $controller_name . (
450                                    strpos($controller_name, 'Controller') ? '' : 'Controller'
451                                );
452                            }
453                        }
454                    }
455                    $params_hook_action_dispatcher = [
456                        'controller_type' => self::FC_ADMIN,
457                        'controller_class' => $controller_class,
458                        'is_module' => 1,
459                    ];
460                } else {
461                    $controllers = Dispatcher::getControllers(
462                        [
463                            _PS_ADMIN_DIR_ . '/tabs/',
464                            _PS_ADMIN_CONTROLLER_DIR_,
465                            _PS_OVERRIDE_DIR_ . 'controllers/admin/',
466                        ]
467                    );
468                    if (!isset($controllers[strtolower($this->controller)])) {
469                        // If this is a parent tab, load the first child
470                        if (Validate::isLoadedObject($tab)
471                            && $tab->id_parent == 0
472                            && ($tabs = Tab::getTabs(Context::getContext()->language->id, $tab->id))
473                            && isset($tabs[0])
474                        ) {
475                            Tools::redirectAdmin(Context::getContext()->link->getAdminLink($tabs[0]['class_name']));
476                        }
477                        $this->controller = $this->controller_not_found;
478                    }
479
480                    $controller_class = $controllers[strtolower($this->controller)];
481                    $params_hook_action_dispatcher = [
482                        'controller_type' => self::FC_ADMIN,
483                        'controller_class' => $controller_class,
484                        'is_module' => 0,
485                    ];
486
487                    if (file_exists(_PS_ADMIN_DIR_ . '/tabs/' . $controller_class . '.php')) {
488                        $retrocompatibility_admin_tab = _PS_ADMIN_DIR_ . '/tabs/' . $controller_class . '.php';
489                    }
490                }
491
492                // @retrocompatibility with admin/tabs/ old system
493                if ($retrocompatibility_admin_tab) {
494                    include_once $retrocompatibility_admin_tab;
495                    include_once _PS_ADMIN_DIR_ . '/functions.php';
496                    runAdminTab($this->controller, !empty($_REQUEST['ajaxMode']));
497
498                    return;
499                }
500
501                break;
502
503            default:
504                throw new PrestaShopException('Bad front controller chosen');
505        }
506
507        // Instantiate controller
508        try {
509            // Loading controller
510            $controller = Controller::getController($controller_class);
511
512            // Execute hook dispatcher
513            if (isset($params_hook_action_dispatcher)) {
514                Hook::exec('actionDispatcher', $params_hook_action_dispatcher);
515            }
516
517            // Running controller
518            $controller->run();
519
520            // Execute hook dispatcher after
521            if (isset($params_hook_action_dispatcher)) {
522                Hook::exec('actionDispatcherAfter', $params_hook_action_dispatcher);
523            }
524        } catch (PrestaShopException $e) {
525            $e->displayMessage();
526        }
527    }
528
529    /**
530     * Sets request uri and if necessary $_GET['isolang'].
531     */
532    protected function setRequestUri()
533    {
534        $shop = Context::getContext()->shop;
535        if (!Validate::isLoadedObject($shop)) {
536            $shop = null;
537        }
538
539        $this->request_uri = $this->buildRequestUri(
540            $this->getRequest()->getRequestUri(),
541            Language::isMultiLanguageActivated(),
542            $shop
543        );
544    }
545
546    /**
547     * Builds request URI and if necessary sets $_GET['isolang'].
548     *
549     * @param string $requestUri To retrieve the request URI from it
550     * @param bool $isMultiLanguageActivated
551     * @param Shop $shop
552     *
553     * @return string
554     */
555    private function buildRequestUri($requestUri, $isMultiLanguageActivated, Shop $shop = null)
556    {
557        // Decode raw request URI
558        $requestUri = rawurldecode($requestUri);
559
560        // Remove the shop base URI part from the request URI
561        if (null !== $shop) {
562            $requestUri = preg_replace(
563                '#^' . preg_quote($shop->getBaseURI(), '#') . '#i',
564                '/',
565                $requestUri
566            );
567        }
568
569        // If there are several languages, set $_GET['isolang'] and remove the language part from the request URI
570        if (
571            $this->use_routes &&
572            $isMultiLanguageActivated &&
573            preg_match('#^/([a-z]{2})(?:/.*)?$#', $requestUri, $matches)
574        ) {
575            $_GET['isolang'] = $matches[1];
576            $requestUri = substr($requestUri, 3);
577        }
578
579        return $requestUri;
580    }
581
582    /**
583     * Load default routes group by languages.
584     *
585     * @param int $id_shop
586     */
587    protected function loadRoutes($id_shop = null)
588    {
589        $context = Context::getContext();
590
591        if (isset($context->shop) && $id_shop === null) {
592            $id_shop = (int) $context->shop->id;
593        }
594
595        // Load custom routes from modules
596        $modules_routes = Hook::exec('moduleRoutes', ['id_shop' => $id_shop], null, true, false);
597        if (is_array($modules_routes) && count($modules_routes)) {
598            foreach ($modules_routes as $module_route) {
599                if (is_array($module_route) && count($module_route)) {
600                    foreach ($module_route as $route => $route_details) {
601                        if (array_key_exists('controller', $route_details)
602                            && array_key_exists('rule', $route_details)
603                            && array_key_exists('keywords', $route_details)
604                            && array_key_exists('params', $route_details)
605                        ) {
606                            if (!isset($this->default_routes[$route])) {
607                                $this->default_routes[$route] = [];
608                            }
609                            $this->default_routes[$route] = array_merge($this->default_routes[$route], $route_details);
610                        }
611                    }
612                }
613            }
614        }
615
616        $language_ids = Language::getIDs();
617
618        if (isset($context->language) && !in_array($context->language->id, $language_ids)) {
619            $language_ids[] = (int) $context->language->id;
620        }
621
622        // Set default routes
623        foreach ($this->default_routes as $id => $route) {
624            $route = $this->computeRoute(
625                $route['rule'],
626                $route['controller'],
627                $route['keywords'],
628                isset($route['params']) ? $route['params'] : []
629            );
630            foreach ($language_ids as $id_lang) {
631                // the default routes are the same, whatever the language
632                $this->routes[$id_shop][$id_lang][$id] = $route;
633            }
634        }
635
636        // Load the custom routes prior the defaults to avoid infinite loops
637        if ($this->use_routes) {
638            // Load routes from meta table
639            $sql = 'SELECT m.page, ml.url_rewrite, ml.id_lang
640					FROM `' . _DB_PREFIX_ . 'meta` m
641					LEFT JOIN `' . _DB_PREFIX_ . 'meta_lang` ml ON (m.id_meta = ml.id_meta' . Shop::addSqlRestrictionOnLang('ml', (int) $id_shop) . ')
642					ORDER BY LENGTH(ml.url_rewrite) DESC';
643            if ($results = Db::getInstance()->executeS($sql)) {
644                foreach ($results as $row) {
645                    if ($row['url_rewrite']) {
646                        $this->addRoute(
647                            $row['page'],
648                            $row['url_rewrite'],
649                            $row['page'],
650                            $row['id_lang'],
651                            [],
652                            [],
653                            $id_shop
654                        );
655                    }
656                }
657            }
658
659            // Set default empty route if no empty route (that's weird I know)
660            if (!$this->empty_route) {
661                $this->empty_route = [
662                    'routeID' => 'index',
663                    'rule' => '',
664                    'controller' => 'index',
665                ];
666            }
667
668            // Load custom routes
669            foreach ($this->default_routes as $route_id => $route_data) {
670                if ($custom_route = Configuration::get('PS_ROUTE_' . $route_id, null, null, $id_shop)) {
671                    if (isset($context->language) && !in_array($context->language->id, $language_ids)) {
672                        $language_ids[] = (int) $context->language->id;
673                    }
674
675                    $route = $this->computeRoute(
676                        $custom_route,
677                        $route_data['controller'],
678                        $route_data['keywords'],
679                        isset($route_data['params']) ? $route_data['params'] : []
680                    );
681                    foreach ($language_ids as $id_lang) {
682                        // those routes are the same, whatever the language
683                        $this->routes[$id_shop][$id_lang][$route_id] = $route;
684                    }
685                }
686            }
687        }
688    }
689
690    /**
691     * Create the route array, by computing the final regex & keywords.
692     *
693     * @param string $rule Url rule
694     * @param string $controller Controller to call if request uri match the rule
695     * @param array $keywords keywords associated with the route
696     * @param array $params optional params of the route
697     *
698     * @return array
699     */
700    public function computeRoute($rule, $controller, array $keywords = [], array $params = [])
701    {
702        $regexp = preg_quote($rule, '#');
703        if ($keywords) {
704            $transform_keywords = [];
705            preg_match_all(
706                '#\\\{(([^{}]*)\\\:)?(' .
707                implode('|', array_keys($keywords)) . ')(\\\:([^{}]*))?\\\}#',
708                $regexp,
709                $m
710            );
711            for ($i = 0, $total = count($m[0]); $i < $total; ++$i) {
712                $prepend = $m[2][$i];
713                $keyword = $m[3][$i];
714                $append = $m[5][$i];
715                $transform_keywords[$keyword] = [
716                    'required' => isset($keywords[$keyword]['param']),
717                    'prepend' => stripslashes($prepend),
718                    'append' => stripslashes($append),
719                ];
720
721                $prepend_regexp = $append_regexp = '';
722                if ($prepend || $append) {
723                    $prepend_regexp = '(' . $prepend;
724                    $append_regexp = $append . ')?';
725                }
726
727                if (isset($keywords[$keyword]['param'])) {
728                    $regexp = str_replace(
729                        $m[0][$i],
730                        $prepend_regexp .
731                        '(?P<' . $keywords[$keyword]['param'] . '>' . $keywords[$keyword]['regexp'] . ')' .
732                        $append_regexp,
733                        $regexp
734                    );
735                } else {
736                    $regexp = str_replace(
737                        $m[0][$i],
738                        $prepend_regexp .
739                        '(' . $keywords[$keyword]['regexp'] . ')' .
740                        $append_regexp,
741                        $regexp
742                    );
743                }
744            }
745            $keywords = $transform_keywords;
746        }
747
748        $regexp = '#^/' . $regexp . '$#u';
749
750        return [
751            'rule' => $rule,
752            'regexp' => $regexp,
753            'controller' => $controller,
754            'keywords' => $keywords,
755            'params' => $params,
756        ];
757    }
758
759    /**
760     * @param string $route_id Name of the route (need to be uniq,a second route with same name will override the first)
761     * @param string $rule Url rule
762     * @param string $controller Controller to call if request uri match the rule
763     * @param int $id_lang
764     * @param array $keywords
765     * @param array $params
766     * @param int $id_shop
767     */
768    public function addRoute(
769        $route_id,
770        $rule,
771        $controller,
772        $id_lang = null,
773        array $keywords = [],
774        array $params = [],
775        $id_shop = null
776    ) {
777        $context = Context::getContext();
778
779        if (isset($context->language) && $id_lang === null) {
780            $id_lang = (int) $context->language->id;
781        }
782
783        if (isset($context->shop) && $id_shop === null) {
784            $id_shop = (int) $context->shop->id;
785        }
786
787        $route = $this->computeRoute($rule, $controller, $keywords, $params);
788
789        if (!isset($this->routes[$id_shop])) {
790            $this->routes[$id_shop] = [];
791        }
792        if (!isset($this->routes[$id_shop][$id_lang])) {
793            $this->routes[$id_shop][$id_lang] = [];
794        }
795
796        $this->routes[$id_shop][$id_lang][$route_id] = $route;
797    }
798
799    /**
800     * Check if a route exists.
801     *
802     * @param string $route_id
803     * @param int $id_lang
804     * @param int $id_shop
805     *
806     * @return bool
807     */
808    public function hasRoute($route_id, $id_lang = null, $id_shop = null)
809    {
810        if (isset(Context::getContext()->language) && $id_lang === null) {
811            $id_lang = (int) Context::getContext()->language->id;
812        }
813        if (isset(Context::getContext()->shop) && $id_shop === null) {
814            $id_shop = (int) Context::getContext()->shop->id;
815        }
816
817        return isset($this->routes[$id_shop][$id_lang][$route_id]);
818    }
819
820    /**
821     * Check if a keyword is written in a route rule.
822     *
823     * @param string $route_id
824     * @param int $id_lang
825     * @param string $keyword
826     * @param int $id_shop
827     *
828     * @return bool
829     */
830    public function hasKeyword($route_id, $id_lang, $keyword, $id_shop = null)
831    {
832        if ($id_shop === null) {
833            $id_shop = (int) Context::getContext()->shop->id;
834        }
835
836        if (!isset($this->routes[$id_shop])) {
837            $this->loadRoutes($id_shop);
838        }
839
840        if (!isset($this->routes[$id_shop]) || !isset($this->routes[$id_shop][$id_lang])
841            || !isset($this->routes[$id_shop][$id_lang][$route_id])) {
842            return false;
843        }
844
845        return preg_match('#\{([^{}]*:)?' . preg_quote($keyword, '#') .
846            '(:[^{}]*)?\}#', $this->routes[$id_shop][$id_lang][$route_id]['rule']);
847    }
848
849    /**
850     * Check if a route rule contain all required keywords of default route definition.
851     *
852     * @param string $route_id
853     * @param string $rule Rule to verify
854     * @param array $errors List of missing keywords
855     *
856     * @return bool
857     */
858    public function validateRoute($route_id, $rule, &$errors = [])
859    {
860        $errors = [];
861        if (!isset($this->default_routes[$route_id])) {
862            return false;
863        }
864
865        foreach ($this->default_routes[$route_id]['keywords'] as $keyword => $data) {
866            if (isset($data['param']) && !preg_match('#\{([^{}]*:)?' . $keyword . '(:[^{}]*)?\}#', $rule)) {
867                $errors[] = $keyword;
868            }
869        }
870
871        return (count($errors)) ? false : true;
872    }
873
874    /**
875     * Create an url from.
876     *
877     * @param string $route_id Name the route
878     * @param int $id_lang
879     * @param array $params
880     * @param bool $force_routes
881     * @param string $anchor Optional anchor to add at the end of this url
882     * @param null $id_shop
883     *
884     * @return string
885     *
886     * @throws PrestaShopException
887     */
888    public function createUrl(
889        $route_id,
890        $id_lang = null,
891        array $params = [],
892        $force_routes = false,
893        $anchor = '',
894        $id_shop = null
895    ) {
896        if ($id_lang === null) {
897            $id_lang = (int) Context::getContext()->language->id;
898        }
899        if ($id_shop === null) {
900            $id_shop = (int) Context::getContext()->shop->id;
901        }
902
903        if (!isset($this->routes[$id_shop])) {
904            $this->loadRoutes($id_shop);
905        }
906
907        if (!isset($this->routes[$id_shop][$id_lang][$route_id])) {
908            $query = http_build_query($params, '', '&');
909            $index_link = $this->use_routes ? '' : 'index.php';
910
911            return ($route_id == 'index') ? $index_link . (($query) ? '?' . $query : '') :
912                ((trim($route_id) == '') ? '' : 'index.php?controller=' . $route_id) . (($query) ? '&' . $query : '') . $anchor;
913        }
914        $route = $this->routes[$id_shop][$id_lang][$route_id];
915        // Check required fields
916        $query_params = isset($route['params']) ? $route['params'] : [];
917        foreach ($route['keywords'] as $key => $data) {
918            if (!$data['required']) {
919                continue;
920            }
921
922            if (!array_key_exists($key, $params)) {
923                throw new PrestaShopException('Dispatcher::createUrl() miss required parameter "' . $key . '" for route "' . $route_id . '"');
924            }
925            if (isset($this->default_routes[$route_id])) {
926                $query_params[$this->default_routes[$route_id]['keywords'][$key]['param']] = $params[$key];
927            }
928        }
929
930        // Build an url which match a route
931        if ($this->use_routes || $force_routes) {
932            $url = $route['rule'];
933            $add_param = [];
934
935            foreach ($params as $key => $value) {
936                if (!isset($route['keywords'][$key])) {
937                    if (!isset($this->default_routes[$route_id]['keywords'][$key])) {
938                        $add_param[$key] = $value;
939                    }
940                } else {
941                    if ($params[$key]) {
942                        $parameter = $params[$key];
943                        if (is_array($parameter)) {
944                            if (array_key_exists($id_lang, $parameter)) {
945                                $parameter = $parameter[$id_lang];
946                            } else {
947                                // made the choice to return the first element of the array
948                                $parameter = reset($parameter);
949                            }
950                        }
951                        $replace = $route['keywords'][$key]['prepend'] . $parameter . $route['keywords'][$key]['append'];
952                    } else {
953                        $replace = '';
954                    }
955                    $url = preg_replace('#\{([^{}]*:)?' . $key . '(:[^{}]*)?\}#', $replace, $url);
956                }
957            }
958            $url = preg_replace('#\{([^{}]*:)?[a-z0-9_]+?(:[^{}]*)?\}#', '', $url);
959            if (count($add_param)) {
960                $url .= '?' . http_build_query($add_param, '', '&');
961            }
962        } else {
963            // Build a classic url index.php?controller=foo&...
964            $add_params = [];
965            foreach ($params as $key => $value) {
966                if (!isset($route['keywords'][$key]) && !isset($this->default_routes[$route_id]['keywords'][$key])) {
967                    $add_params[$key] = $value;
968                }
969            }
970
971            if (!empty($route['controller'])) {
972                $query_params['controller'] = $route['controller'];
973            }
974            $query = http_build_query(array_merge($add_params, $query_params), '', '&');
975            if ($this->multilang_activated) {
976                $query .= (!empty($query) ? '&' : '') . 'id_lang=' . (int) $id_lang;
977            }
978            $url = 'index.php?' . $query;
979        }
980
981        return $url . $anchor;
982    }
983
984    /**
985     * Retrieve the controller from url or request uri if routes are activated.
986     *
987     * @param int $id_shop
988     *
989     * @return string
990     */
991    public function getController($id_shop = null)
992    {
993        if (defined('_PS_ADMIN_DIR_')) {
994            $_GET['controllerUri'] = Tools::getValue('controller');
995        }
996        if ($this->controller) {
997            $_GET['controller'] = $this->controller;
998
999            return $this->controller;
1000        }
1001
1002        if (isset(Context::getContext()->shop) && $id_shop === null) {
1003            $id_shop = (int) Context::getContext()->shop->id;
1004        }
1005
1006        $controller = Tools::getValue('controller');
1007
1008        if (isset($controller)
1009            && is_string($controller)
1010            && preg_match('/^([0-9a-z_-]+)\?(.*)=(.*)$/Ui', $controller, $m)
1011        ) {
1012            $controller = $m[1];
1013            if (isset($_GET['controller'])) {
1014                $_GET[$m[2]] = $m[3];
1015            } elseif (isset($_POST['controller'])) {
1016                $_POST[$m[2]] = $m[3];
1017            }
1018        }
1019
1020        if (!Validate::isControllerName($controller)) {
1021            $controller = false;
1022        }
1023
1024        // Use routes ? (for url rewriting)
1025        if ($this->use_routes && !$controller && !defined('_PS_ADMIN_DIR_')) {
1026            if (!$this->request_uri) {
1027                return strtolower($this->controller_not_found);
1028            }
1029            $controller = $this->controller_not_found;
1030            $test_request_uri = preg_replace('/(=http:\/\/)/', '=', $this->request_uri);
1031
1032            // If the request_uri matches a static file, then there is no need to check the routes, we keep
1033            // "controller_not_found" (a static file should not go through the dispatcher)
1034            if (!preg_match(
1035                '/\.(gif|jpe?g|png|css|js|ico)$/i',
1036                parse_url($test_request_uri, PHP_URL_PATH)
1037            )) {
1038                // Add empty route as last route to prevent this greedy regexp to match request uri before right time
1039                if ($this->empty_route) {
1040                    $this->addRoute(
1041                        $this->empty_route['routeID'],
1042                        $this->empty_route['rule'],
1043                        $this->empty_route['controller'],
1044                        Context::getContext()->language->id,
1045                        [],
1046                        [],
1047                        $id_shop
1048                    );
1049                }
1050
1051                list($uri) = explode('?', $this->request_uri);
1052
1053                if (isset($this->routes[$id_shop][Context::getContext()->language->id])) {
1054                    foreach ($this->routes[$id_shop][Context::getContext()->language->id] as $route) {
1055                        if (preg_match($route['regexp'], $uri, $m)) {
1056                            // Route found ! Now fill $_GET with parameters of uri
1057                            foreach ($m as $k => $v) {
1058                                if (!is_numeric($k)) {
1059                                    $_GET[$k] = $v;
1060                                }
1061                            }
1062
1063                            $controller = $route['controller'] ? $route['controller'] : $_GET['controller'];
1064                            if (!empty($route['params'])) {
1065                                foreach ($route['params'] as $k => $v) {
1066                                    $_GET[$k] = $v;
1067                                }
1068                            }
1069
1070                            // A patch for module friendly urls
1071                            if (preg_match('#module-([a-z0-9_-]+)-([a-z0-9_]+)$#i', $controller, $m)) {
1072                                $_GET['module'] = $m[1];
1073                                $_GET['fc'] = 'module';
1074                                $controller = $m[2];
1075                            }
1076
1077                            if (isset($_GET['fc']) && $_GET['fc'] == 'module') {
1078                                $this->front_controller = self::FC_MODULE;
1079                            }
1080
1081                            break;
1082                        }
1083                    }
1084                }
1085            }
1086
1087            if ($controller == 'index' || preg_match('/^\/index.php(?:\?.*)?$/', $this->request_uri)) {
1088                $controller = $this->useDefaultController();
1089            }
1090        }
1091
1092        $this->controller = str_replace('-', '', $controller);
1093        $_GET['controller'] = $this->controller;
1094
1095        return $this->controller;
1096    }
1097
1098    /**
1099     * Get list of all available FO controllers.
1100     *
1101     * @var mixed
1102     *
1103     * @return array
1104     */
1105    public static function getControllers($dirs)
1106    {
1107        if (!is_array($dirs)) {
1108            $dirs = [$dirs];
1109        }
1110
1111        $controllers = [];
1112        foreach ($dirs as $dir) {
1113            $controllers = array_merge($controllers, Dispatcher::getControllersInDirectory($dir));
1114        }
1115
1116        return $controllers;
1117    }
1118
1119    /**
1120     * Get list of all available Module Front controllers.
1121     *
1122     * @param string $type
1123     * @param string $module
1124     *
1125     * @return array
1126     */
1127    public static function getModuleControllers($type = 'all', $module = null)
1128    {
1129        $modules_controllers = [];
1130        if (null === $module) {
1131            $modules = Module::getModulesOnDisk(true);
1132        } elseif (!is_array($module)) {
1133            $modules = [Module::getInstanceByName($module)];
1134        } else {
1135            $modules = [];
1136            foreach ($module as $_mod) {
1137                $modules[] = Module::getInstanceByName($_mod);
1138            }
1139        }
1140
1141        foreach ($modules as $mod) {
1142            foreach (Dispatcher::getControllersInDirectory(_PS_MODULE_DIR_ . $mod->name . '/controllers/') as $controller) {
1143                if ($type == 'admin') {
1144                    if (strpos($controller, 'Admin') !== false) {
1145                        $modules_controllers[$mod->name][] = $controller;
1146                    }
1147                } elseif ($type == 'front') {
1148                    if (strpos($controller, 'Admin') === false) {
1149                        $modules_controllers[$mod->name][] = $controller;
1150                    }
1151                } else {
1152                    $modules_controllers[$mod->name][] = $controller;
1153                }
1154            }
1155        }
1156
1157        return $modules_controllers;
1158    }
1159
1160    /**
1161     * Get list of available controllers from the specified dir.
1162     *
1163     * @param string $dir Directory to scan (recursively)
1164     *
1165     * @return array
1166     */
1167    public static function getControllersInDirectory($dir)
1168    {
1169        if (!is_dir($dir)) {
1170            return [];
1171        }
1172
1173        $controllers = [];
1174        $controller_files = scandir($dir, SCANDIR_SORT_NONE);
1175        foreach ($controller_files as $controller_filename) {
1176            if ($controller_filename[0] != '.') {
1177                if (!strpos($controller_filename, '.php') && is_dir($dir . $controller_filename)) {
1178                    $controllers += Dispatcher::getControllersInDirectory(
1179                        $dir . $controller_filename . DIRECTORY_SEPARATOR
1180                    );
1181                } elseif ($controller_filename != 'index.php') {
1182                    $key = str_replace(['controller.php', '.php'], '', strtolower($controller_filename));
1183                    $controllers[$key] = basename($controller_filename, '.php');
1184                }
1185            }
1186        }
1187
1188        return $controllers;
1189    }
1190
1191    /**
1192     * Get the default php_self value of a controller.
1193     *
1194     * @param string $controller The controller class name
1195     *
1196     * @return string|null
1197     */
1198    public static function getControllerPhpself(string $controller)
1199    {
1200        if (!class_exists($controller)) {
1201            return;
1202        }
1203
1204        $reflectionClass = new ReflectionClass($controller);
1205        $controllerDefaultProperties = $reflectionClass->getDefaultProperties();
1206
1207        return $controllerDefaultProperties['php_self'] ?? null;
1208    }
1209
1210    /**
1211     * Get list of all php_self property values of each available controller in the specified dir.
1212     *
1213     * @param string $dir Directory to scan (recursively)
1214     * @param bool $base_name_otherwise Return the controller base name if no php_self is found
1215     *
1216     * @return array
1217     */
1218    public static function getControllersPhpselfList(string $dir, bool $base_name_otherwise = true)
1219    {
1220        $controllers = Dispatcher::getControllers($dir);
1221
1222        $controllersPhpself = [];
1223
1224        foreach ($controllers as $controllerBaseName => $controllerClassName) {
1225            $controllerPhpself = Dispatcher::getControllerPhpself($controllerClassName);
1226
1227            if ($base_name_otherwise) {
1228                $controllerPhpself = $controllerPhpself ?? $controllerBaseName;
1229            }
1230
1231            if ($controllerPhpself) {
1232                $controllersPhpself[] = $controllerPhpself;
1233            }
1234        }
1235
1236        return $controllersPhpself;
1237    }
1238}
1239