diff --git a/common/framework/router.php b/common/framework/router.php index bd129cb22..048e494f0 100644 --- a/common/framework/router.php +++ b/common/framework/router.php @@ -7,564 +7,564 @@ namespace Rhymix\Framework; */ class Router { - /** - * List of XE-compatible global routes. - */ - protected static $_global_routes = array( - '$document_srl' => array( - 'regexp' => '#^(?[0-9]+)$#', - 'vars' => ['document_srl' => 'int'], - 'priority' => 0, - ), - '$mid' => array( - 'regexp' => '#^(?[a-zA-Z0-9_-]+)/?$#', - 'vars' => ['mid' => 'any'], - 'priority' => 0, - ), - '$act' => array( - 'regexp' => '#^(?rss|atom)$#', - 'vars' => ['act' => 'word'], - 'priority' => 0, - ), - '$mid/$document_srl' => array( - 'regexp' => '#^(?[a-zA-Z0-9_-]+)/(?[0-9]+)$#', - 'vars' => ['mid' => 'any', 'document_srl' => 'int'], - 'priority' => 30, - ), - '$mid/category/$category' => array( - 'regexp' => '#^(?[a-zA-Z0-9_-]+)/category/(?[0-9]+)$#', - 'vars' => ['mid' => 'any', 'category' => 'int'], - 'priority' => 10, - ), - '$mid/entry/$entry' => array( - 'regexp' => '#^(?[a-zA-Z0-9_-]+)/entry/(?[^/]+)$#', - 'vars' => ['mid' => 'any', 'entry' => 'any'], - 'priority' => 0, - ), - '$mid/$act' => array( - 'regexp' => '#^(?[a-zA-Z0-9_-]+)/(?rss|atom|api)$#', - 'vars' => ['mid' => 'any', 'act' => 'word'], - 'priority' => 20, - ), - 'files/download/$file_srl/$file_key/$filename' => array( - 'regexp' => '#^files/download/(?[0-9]+)/(?[a-zA-Z0-9_-]+)/(?[^/]+)$#', - 'vars' => ['file_srl' => 'int', 'file_key' => 'any', 'filename' => 'any'], - 'extra_vars' => ['act' => 'procFileOutput'], - 'priority' => 0, - ), - ); - - /** - * List of legacy modules whose URLs should not be shortened. - */ - protected static $_except_modules = array( - 'socialxe' => true, - ); - - /** - * Internal cache for module and route information. - */ - protected static $_action_cache_prefix = array(); - protected static $_action_cache_module = array(); - protected static $_global_forwarded_cache = array(); - protected static $_internal_forwarded_cache = array(); - protected static $_route_cache = array(); - - /** - * Return the currently configured rewrite level. - * - * 0 = None - * 1 = XE-compatible rewrite rules only - * 2 = Full rewrite support - * - * @return int - */ - public static function getRewriteLevel(): int - { - $level = Config::get('url.rewrite'); - if ($level === null) - { - $level = Config::get('use_rewrite') ? 1 : 0; - } - return intval($level); - } - - /** - * Extract request arguments from the current URL. - * - * @param string $method - * @param string $url - * @param int $rewrite_level - * @return object - */ - public static function parseURL(string $method, string $url, int $rewrite_level) - { - // Get the local part of the current URL. - if (starts_with(\RX_BASEURL, $url)) - { - $url = substr($url, strlen(\RX_BASEURL)); - } - - // Prepare the return object. - $result = new \stdClass; - $result->status = 200; - $result->url = ''; - $result->module = ''; - $result->mid = ''; - $result->act = ''; - $result->forwarded = false; - $result->args = array(); - - // Separate additional arguments from the URL. - $args = array(); - $argstart = strpos($url, '?'); - if ($argstart !== false) - { - @parse_str(substr($url, $argstart + 1), $args); - $url = substr($url, 0, $argstart); - } - - // Decode the URL into plain UTF-8. - $url = $result->url = urldecode($url); - if ($url === '') - { - return $result; - } - if (function_exists('mb_check_encoding') && !mb_check_encoding($url, 'UTF-8')) - { - $result->status = 404; - return $result; - } - - // Try to detect the prefix. This might be $mid. - if ($rewrite_level >= 2 && preg_match('#^([a-zA-Z0-9_-]+)(?:/(.*))?#s', $url, $matches)) - { - // Separate the prefix and the internal part of the URL. - $prefix = $matches[1]; - $internal_url = $matches[2] ?? ''; - $prefix_type = 'mid'; - - // Find the module associated with this prefix. - $action_info = self::_getActionInfoByPrefix($prefix, $module_name = ''); - if ($action_info === false) - { - $action_info = self::_getActionInfoByModule($prefix); - if ($action_info !== false) - { - $module_name = $prefix; - $prefix_type = 'module'; - } - } - - // If a module is found, try its routes. - if ($action_info) - { - // Try the list of routes defined by the module. - foreach ($action_info->route->{$method} as $regexp => $action) - { - if (preg_match($regexp, $internal_url, $matches)) - { - $matches = array_filter($matches, 'is_string', \ARRAY_FILTER_USE_KEY); - $allargs = array_merge($args, $matches, [$prefix_type => $prefix, 'act' => $action]); - $result->module = $module_name; - $result->mid = $prefix_type === 'mid' ? $prefix : ''; - $result->act = $action; - $result->args = $allargs; - return $result; - } - } - - // Check other modules. - if ($prefix_type === 'mid') - { - $forwarded_routes = self::_getForwardedRoutes('internal'); - foreach ($forwarded_routes[$method] ?: [] as $regexp => $action) - { - if (preg_match($regexp, $internal_url, $matches)) - { - $matches = array_filter($matches, 'is_string', \ARRAY_FILTER_USE_KEY); - $allargs = array_merge($args, $matches, [$prefix_type => $prefix, 'act' => $action[1]]); - $result->module = $action[0]; - $result->mid = $prefix; - $result->act = $action[1]; - $result->forwarded = true; - $result->args = $allargs; - return $result; - } - } - } - - // Try the generic mid/act pattern. - if (preg_match('#^[a-zA-Z0-9_]+$#', $internal_url)) - { - $allargs = array_merge($args, [$prefix_type => $prefix, 'act' => $internal_url]); - $result->module = $module_name; - $result->mid = $prefix_type === 'mid' ? $prefix : ''; - $result->act = $internal_url; - $result->forwarded = true; - $result->args = $allargs; - return $result; - } - - // If the module defines a 404 error handler, call it. - if ($internal_url && isset($action_info->error_handlers[404])) - { - $allargs = array_merge($args, [$prefix_type => $prefix, 'act' => $action_info->error_handlers[404]]); - $result->module = $module_name; - $result->mid = $prefix_type === 'mid' ? $prefix : ''; - $result->act = $action_info->error_handlers[404]; - $result->forwarded = false; - $result->args = $allargs; - return $result; - } - } - } - - // Try registered global routes. - if ($rewrite_level >= 2) - { - $global_routes = self::_getForwardedRoutes('global'); - foreach ($global_routes[$method] ?: [] as $regexp => $action) - { - if (preg_match($regexp, $url, $matches)) - { - $matches = array_filter($matches, 'is_string', \ARRAY_FILTER_USE_KEY); - $allargs = array_merge($args, $matches, ['act' => $action[1]]); - $result->module = $action[0]; - $result->act = $action[1]; - $result->forwarded = true; - $result->args = $allargs; - return $result; - } - } - } - - // Try XE-compatible global routes. - foreach (self::$_global_routes as $route_info) - { - if (preg_match($route_info['regexp'], $url, $matches)) - { - $matches = array_filter($matches, 'is_string', \ARRAY_FILTER_USE_KEY); - $allargs = array_merge($args, $matches, $route_info['extra_vars'] ?? []); - $result->module = $allargs['module'] ?? ''; - $result->mid = $allargs['mid'] ?: ''; - $result->act = $allargs['act'] ?: ''; - $result->forwarded = false; - $result->args = $allargs; - return $result; - } - } - - // If no pattern matches, return either an empty route or a 404 error. - $result->module = isset($args['module']) ? $args['module'] : ''; - $result->mid = isset($args['mid']) ? $args['mid'] : ''; - $result->act = isset($args['act']) ? $args['act'] : ''; - $result->args = $args; - if ($url === '' || $url === 'index.php') - { - $result->url = ''; - return $result; - } - else - { - $result->status = 404; - return $result; - } - } - - /** - * Create a URL for the given set of arguments. - * - * @param array $args - * @param int $rewrite_level - * @return string - */ - public static function getURL(array $args, int $rewrite_level): string - { - // If rewrite is turned off, just create a query string. - if ($rewrite_level == 0) - { - return 'index.php?' . http_build_query($args); - } - - // Cache the number of arguments and their keys. - $count = count($args); - $keys = array_keys($args); - - // If there are no arguments, return the URL of the main page. - if ($count == 0) - { - return ''; - } - - // If there is only one argument, try either $mid or $document_srl. - if ($rewrite_level >= 1 && $count == 1 && ($keys[0] === 'mid' || $keys[0] === 'document_srl')) - { - return urlencode($args[$keys[0]]); - } - - // If the list of keys is already cached, return the corresponding route. - $keys_sorted = $keys; sort($keys_sorted); - $keys_string = implode('.', $keys_sorted) . ':' . ($args['mid'] ?? '') . ':' . ($args['act'] ?? ''); - if (isset(self::$_route_cache[$keys_string])) - { - return self::_insertRouteVars(self::$_route_cache[$keys_string], $args); - } - - // Remove $mid and $act from arguments and work with the remainder. - $args2 = $args; unset($args2['module'], $args2['mid'], $args2['act']); - - // If $mid exists, try routes defined in the module. - if ($rewrite_level >= 2 && (isset($args['mid']) || isset($args['module']))) - { - // Get module action info. - if (isset($args['mid'])) - { - $action_info = self::_getActionInfoByPrefix($args['mid']); - $prefix_type = 'mid'; - } - elseif (isset($args['module'])) - { - $action_info = self::_getActionInfoByModule($args['module']); - $prefix_type = 'module'; - } - - // If there is no $act, use the default action. - $act = isset($args['act']) ? $args['act'] : $action_info->default_index_act; - - // Check if $act has any routes defined. - $action = $action_info->action->{$act} ?? null; - if ($action && $action->route) - { - $result = self::_getBestMatchingRoute($action->route, $args2); - if ($result !== false) - { - self::$_route_cache[$keys_string] = '$' . $prefix_type . '/' . $result . '$act:delete'; - return $args[$prefix_type] . '/' . self::_insertRouteVars($result, $args2); - } - } - - // Check other modules for $act. - if ($prefix_type === 'mid') - { - $forwarded_routes = self::_getForwardedRoutes('internal'); - if (isset($forwarded_routes['reverse'][$act])) - { - $result = self::_getBestMatchingRoute($forwarded_routes['reverse'][$act], $args2); - if ($result !== false) - { - self::$_route_cache[$keys_string] = '$' . $prefix_type . '/' . $result . '$act:delete'; - return $args[$prefix_type] . '/' . self::_insertRouteVars($result, $args2); - } - } - } - - // Try the generic mid/act pattern. - if ($prefix_type !== 'module' || !isset(self::$_except_modules[$args[$prefix_type]])) - { - self::$_route_cache[$keys_string] = '$' . $prefix_type . '/$act'; - return $args[$prefix_type] . '/' . $args['act'] . (count($args2) ? ('?' . http_build_query($args2)) : ''); - } - } - - // Try registered global routes. - if ($rewrite_level >= 2 && isset($args['act'])) - { - $global_routes = self::_getForwardedRoutes('global'); - if (isset($global_routes['reverse'][$args['act']])) - { - $result = self::_getBestMatchingRoute($global_routes['reverse'][$args['act']], $args2); - if ($result !== false) - { - self::$_route_cache[$keys_string] = $result . '$act:delete'; - return self::_insertRouteVars($result, $args2); - } - } - } - - // Try XE-compatible global routes. - if ($rewrite_level >= 1) - { - if (!isset($args['act']) || ($args['act'] === 'rss' || $args['act'] === 'atom')) - { - $result = self::_getBestMatchingRoute(self::$_global_routes, $args); - if ($result !== false) - { - self::$_route_cache[$keys_string] = $result; - return self::_insertRouteVars($result, $args); - } - } - } - - // If no route matches, just create a query string. - self::$_route_cache[$keys_string] = 'index.php'; - return 'index.php?' . http_build_query($args); - } - - /** - * Load and cache module action info. - * - * @param string $prefix - * @return object - */ - protected static function _getActionInfoByPrefix(string $prefix, string &$module_name = '') - { - if (isset(self::$_action_cache_prefix[$prefix])) - { - $module_name = self::$_action_cache_prefix[$prefix]; - return self::_getActionInfoByModule(self::$_action_cache_prefix[$prefix]) ?: false; - } - - $module_info = \ModuleModel::getModuleInfoByMid($prefix); - if ($module_info && $module_info->module) - { - $module_name = self::$_action_cache_prefix[$prefix] = $module_info->module; - return self::_getActionInfoByModule(self::$_action_cache_prefix[$prefix]) ?: false; - } - else - { - return self::$_action_cache_prefix[$prefix] = false; - } - } - - /** - * Load and cache module action info. - * - * @param string $prefix - * @return object - */ - protected static function _getActionInfoByModule(string $module) - { - if (isset(self::$_action_cache_module[$module])) - { - return self::$_action_cache_module[$module]; - } - - $action_info = \ModuleModel::getModuleActionXml($module); - return self::$_action_cache_module[$module] = $action_info ?: false; - } - - /** - * Get the list of routes that are registered for action-forward. - * - * @param string $type - * @return array - */ - protected static function _getForwardedRoutes(string $type): array - { - if ($type === 'internal' && count(self::$_internal_forwarded_cache)) - { - return self::$_internal_forwarded_cache; - } - if ($type === 'global' && count(self::$_global_forwarded_cache)) - { - return self::$_global_forwarded_cache; - } - - self::$_global_forwarded_cache['GET'] = array(); - self::$_global_forwarded_cache['POST'] = array(); - self::$_global_forwarded_cache['reverse'] = array(); - self::$_internal_forwarded_cache['GET'] = array(); - self::$_internal_forwarded_cache['POST'] = array(); - self::$_internal_forwarded_cache['reverse'] = array(); - - $action_forward = \ModuleModel::getActionForward(); - foreach ($action_forward as $action_name => $action_info) - { - if ($action_info->route_regexp) - { - foreach ($action_info->route_regexp as $regexp_info) - { - if ($action_info->global_route === 'Y') - { - self::$_global_forwarded_cache[$regexp_info[0]][$regexp_info[1]] = [$action_info->module, $action_name]; - } - else - { - self::$_internal_forwarded_cache[$regexp_info[0]][$regexp_info[1]] = [$action_info->module, $action_name]; - } - } - if ($action_info->global_route === 'Y') - { - self::$_global_forwarded_cache['reverse'][$action_name] = $action_info->route_config; - } - else - { - self::$_internal_forwarded_cache['reverse'][$action_name] = $action_info->route_config; - } - } - } - return $type === 'internal' ? self::$_internal_forwarded_cache : self::$_global_forwarded_cache; - } - - /** - * Find the best matching route for an array of variables. - * - * @param array $routes - * @param array $vars - * @return string|false - */ - protected static function _getBestMatchingRoute(array $routes, array $vars) - { - // If the action only has one route, select it. - if (count($routes) == 1) - { - $only_route = key($routes); - $matched_arguments = array_intersect_key($routes[$only_route]['vars'], $vars); - if (count($matched_arguments) !== count($routes[$only_route]['vars'])) - { - return false; - } - return $only_route; - } - - // If the action has multiple routes, select the one that matches the most arguments. - else - { - // Order the routes by the number of matched arguments. - $reordered_routes = array(); - foreach ($routes as $route => $route_vars) - { - $matched_arguments = array_intersect_key($route_vars['vars'], $vars); - if (count($matched_arguments) === count($route_vars['vars'])) - { - $reordered_routes[$route] = ($route_vars['priority'] * 1000) + count($matched_arguments); - } - } - if (!count($reordered_routes)) - { - return false; - } - arsort($reordered_routes); - $best_route = array_first_key($reordered_routes); - return $best_route; - } - } - - /** - * Insert variables into a route. - * - * @param string $route - * @param array $vars - * @return string - */ - protected static function _insertRouteVars(string $route, array $vars): string - { - // Replace variable placeholders with actual variable values. - $route = preg_replace_callback('#\\$([a-zA-Z0-9_]+)(:[a-z]+)?#i', function($match) use(&$vars) { - if (isset($vars[$match[1]])) - { - $replacement = urlencode($vars[$match[1]]); - unset($vars[$match[1]]); - return (isset($match[2]) && $match[2] === ':delete') ? '' : $replacement; - } - else - { - return ''; - } - }, $route); - - // Add a query string for the remaining arguments. - return $route . (count($vars) ? ('?' . http_build_query($vars)) : ''); - } + /** + * List of XE-compatible global routes. + */ + protected static $_global_routes = array( + '$document_srl' => array( + 'regexp' => '#^(?[0-9]+)$#', + 'vars' => ['document_srl' => 'int'], + 'priority' => 0, + ), + '$mid' => array( + 'regexp' => '#^(?[a-zA-Z0-9_-]+)/?$#', + 'vars' => ['mid' => 'any'], + 'priority' => 0, + ), + '$act' => array( + 'regexp' => '#^(?rss|atom)$#', + 'vars' => ['act' => 'word'], + 'priority' => 0, + ), + '$mid/$document_srl' => array( + 'regexp' => '#^(?[a-zA-Z0-9_-]+)/(?[0-9]+)$#', + 'vars' => ['mid' => 'any', 'document_srl' => 'int'], + 'priority' => 30, + ), + '$mid/category/$category' => array( + 'regexp' => '#^(?[a-zA-Z0-9_-]+)/category/(?[0-9]+)$#', + 'vars' => ['mid' => 'any', 'category' => 'int'], + 'priority' => 10, + ), + '$mid/entry/$entry' => array( + 'regexp' => '#^(?[a-zA-Z0-9_-]+)/entry/(?[^/]+)$#', + 'vars' => ['mid' => 'any', 'entry' => 'any'], + 'priority' => 0, + ), + '$mid/$act' => array( + 'regexp' => '#^(?[a-zA-Z0-9_-]+)/(?rss|atom|api)$#', + 'vars' => ['mid' => 'any', 'act' => 'word'], + 'priority' => 20, + ), + 'files/download/$file_srl/$file_key/$filename' => array( + 'regexp' => '#^files/download/(?[0-9]+)/(?[a-zA-Z0-9_-]+)/(?[^/]+)$#', + 'vars' => ['file_srl' => 'int', 'file_key' => 'any', 'filename' => 'any'], + 'extra_vars' => ['act' => 'procFileOutput'], + 'priority' => 0, + ), + ); + + /** + * List of legacy modules whose URLs should not be shortened. + */ + protected static $_except_modules = array( + 'socialxe' => true, + ); + + /** + * Internal cache for module and route information. + */ + protected static $_action_cache_prefix = array(); + protected static $_action_cache_module = array(); + protected static $_global_forwarded_cache = array(); + protected static $_internal_forwarded_cache = array(); + protected static $_route_cache = array(); + + /** + * Return the currently configured rewrite level. + * + * 0 = None + * 1 = XE-compatible rewrite rules only + * 2 = Full rewrite support + * + * @return int + */ + public static function getRewriteLevel(): int + { + $level = Config::get('url.rewrite'); + if ($level === null) + { + $level = Config::get('use_rewrite') ? 1 : 0; + } + return intval($level); + } + + /** + * Extract request arguments from the current URL. + * + * @param string $method + * @param string $url + * @param int $rewrite_level + * @return object + */ + public static function parseURL(string $method, string $url, int $rewrite_level) + { + // Get the local part of the current URL. + if (starts_with(\RX_BASEURL, $url)) + { + $url = substr($url, strlen(\RX_BASEURL)); + } + + // Prepare the return object. + $result = new \stdClass; + $result->status = 200; + $result->url = ''; + $result->module = ''; + $result->mid = ''; + $result->act = ''; + $result->forwarded = false; + $result->args = array(); + + // Separate additional arguments from the URL. + $args = array(); + $argstart = strpos($url, '?'); + if ($argstart !== false) + { + @parse_str(substr($url, $argstart + 1), $args); + $url = substr($url, 0, $argstart); + } + + // Decode the URL into plain UTF-8. + $url = $result->url = urldecode($url); + if ($url === '') + { + return $result; + } + if (function_exists('mb_check_encoding') && !mb_check_encoding($url, 'UTF-8')) + { + $result->status = 404; + return $result; + } + + // Try to detect the prefix. This might be $mid. + if ($rewrite_level >= 2 && preg_match('#^([a-zA-Z0-9_-]+)(?:/(.*))?#s', $url, $matches)) + { + // Separate the prefix and the internal part of the URL. + $prefix = $matches[1]; + $internal_url = $matches[2] ?? ''; + $prefix_type = 'mid'; + + // Find the module associated with this prefix. + $action_info = self::_getActionInfoByPrefix($prefix, $module_name = ''); + if ($action_info === false) + { + $action_info = self::_getActionInfoByModule($prefix); + if ($action_info !== false) + { + $module_name = $prefix; + $prefix_type = 'module'; + } + } + + // If a module is found, try its routes. + if ($action_info) + { + // Try the list of routes defined by the module. + foreach ($action_info->route->{$method} as $regexp => $action) + { + if (preg_match($regexp, $internal_url, $matches)) + { + $matches = array_filter($matches, 'is_string', \ARRAY_FILTER_USE_KEY); + $allargs = array_merge($args, $matches, [$prefix_type => $prefix, 'act' => $action]); + $result->module = $module_name; + $result->mid = $prefix_type === 'mid' ? $prefix : ''; + $result->act = $action; + $result->args = $allargs; + return $result; + } + } + + // Check other modules. + if ($prefix_type === 'mid') + { + $forwarded_routes = self::_getForwardedRoutes('internal'); + foreach ($forwarded_routes[$method] ?: [] as $regexp => $action) + { + if (preg_match($regexp, $internal_url, $matches)) + { + $matches = array_filter($matches, 'is_string', \ARRAY_FILTER_USE_KEY); + $allargs = array_merge($args, $matches, [$prefix_type => $prefix, 'act' => $action[1]]); + $result->module = $action[0]; + $result->mid = $prefix; + $result->act = $action[1]; + $result->forwarded = true; + $result->args = $allargs; + return $result; + } + } + } + + // Try the generic mid/act pattern. + if (preg_match('#^[a-zA-Z0-9_]+$#', $internal_url)) + { + $allargs = array_merge($args, [$prefix_type => $prefix, 'act' => $internal_url]); + $result->module = $module_name; + $result->mid = $prefix_type === 'mid' ? $prefix : ''; + $result->act = $internal_url; + $result->forwarded = true; + $result->args = $allargs; + return $result; + } + + // If the module defines a 404 error handler, call it. + if ($internal_url && isset($action_info->error_handlers[404])) + { + $allargs = array_merge($args, [$prefix_type => $prefix, 'act' => $action_info->error_handlers[404]]); + $result->module = $module_name; + $result->mid = $prefix_type === 'mid' ? $prefix : ''; + $result->act = $action_info->error_handlers[404]; + $result->forwarded = false; + $result->args = $allargs; + return $result; + } + } + } + + // Try registered global routes. + if ($rewrite_level >= 2) + { + $global_routes = self::_getForwardedRoutes('global'); + foreach ($global_routes[$method] ?: [] as $regexp => $action) + { + if (preg_match($regexp, $url, $matches)) + { + $matches = array_filter($matches, 'is_string', \ARRAY_FILTER_USE_KEY); + $allargs = array_merge($args, $matches, ['act' => $action[1]]); + $result->module = $action[0]; + $result->act = $action[1]; + $result->forwarded = true; + $result->args = $allargs; + return $result; + } + } + } + + // Try XE-compatible global routes. + foreach (self::$_global_routes as $route_info) + { + if (preg_match($route_info['regexp'], $url, $matches)) + { + $matches = array_filter($matches, 'is_string', \ARRAY_FILTER_USE_KEY); + $allargs = array_merge($args, $matches, $route_info['extra_vars'] ?? []); + $result->module = $allargs['module'] ?? ''; + $result->mid = $allargs['mid'] ?: ''; + $result->act = $allargs['act'] ?: ''; + $result->forwarded = false; + $result->args = $allargs; + return $result; + } + } + + // If no pattern matches, return either an empty route or a 404 error. + $result->module = isset($args['module']) ? $args['module'] : ''; + $result->mid = isset($args['mid']) ? $args['mid'] : ''; + $result->act = isset($args['act']) ? $args['act'] : ''; + $result->args = $args; + if ($url === '' || $url === 'index.php') + { + $result->url = ''; + return $result; + } + else + { + $result->status = 404; + return $result; + } + } + + /** + * Create a URL for the given set of arguments. + * + * @param array $args + * @param int $rewrite_level + * @return string + */ + public static function getURL(array $args, int $rewrite_level): string + { + // If rewrite is turned off, just create a query string. + if ($rewrite_level == 0) + { + return 'index.php?' . http_build_query($args); + } + + // Cache the number of arguments and their keys. + $count = count($args); + $keys = array_keys($args); + + // If there are no arguments, return the URL of the main page. + if ($count == 0) + { + return ''; + } + + // If there is only one argument, try either $mid or $document_srl. + if ($rewrite_level >= 1 && $count == 1 && ($keys[0] === 'mid' || $keys[0] === 'document_srl')) + { + return urlencode($args[$keys[0]]); + } + + // If the list of keys is already cached, return the corresponding route. + $keys_sorted = $keys; sort($keys_sorted); + $keys_string = implode('.', $keys_sorted) . ':' . ($args['mid'] ?? '') . ':' . ($args['act'] ?? ''); + if (isset(self::$_route_cache[$keys_string])) + { + return self::_insertRouteVars(self::$_route_cache[$keys_string], $args); + } + + // Remove $mid and $act from arguments and work with the remainder. + $args2 = $args; unset($args2['module'], $args2['mid'], $args2['act']); + + // If $mid exists, try routes defined in the module. + if ($rewrite_level >= 2 && (isset($args['mid']) || isset($args['module']))) + { + // Get module action info. + if (isset($args['mid'])) + { + $action_info = self::_getActionInfoByPrefix($args['mid']); + $prefix_type = 'mid'; + } + elseif (isset($args['module'])) + { + $action_info = self::_getActionInfoByModule($args['module']); + $prefix_type = 'module'; + } + + // If there is no $act, use the default action. + $act = isset($args['act']) ? $args['act'] : $action_info->default_index_act; + + // Check if $act has any routes defined. + $action = $action_info->action->{$act} ?? null; + if ($action && $action->route) + { + $result = self::_getBestMatchingRoute($action->route, $args2); + if ($result !== false) + { + self::$_route_cache[$keys_string] = '$' . $prefix_type . '/' . $result . '$act:delete'; + return $args[$prefix_type] . '/' . self::_insertRouteVars($result, $args2); + } + } + + // Check other modules for $act. + if ($prefix_type === 'mid') + { + $forwarded_routes = self::_getForwardedRoutes('internal'); + if (isset($forwarded_routes['reverse'][$act])) + { + $result = self::_getBestMatchingRoute($forwarded_routes['reverse'][$act], $args2); + if ($result !== false) + { + self::$_route_cache[$keys_string] = '$' . $prefix_type . '/' . $result . '$act:delete'; + return $args[$prefix_type] . '/' . self::_insertRouteVars($result, $args2); + } + } + } + + // Try the generic mid/act pattern. + if ($prefix_type !== 'module' || !isset(self::$_except_modules[$args[$prefix_type]])) + { + self::$_route_cache[$keys_string] = '$' . $prefix_type . '/$act'; + return $args[$prefix_type] . '/' . $args['act'] . (count($args2) ? ('?' . http_build_query($args2)) : ''); + } + } + + // Try registered global routes. + if ($rewrite_level >= 2 && isset($args['act'])) + { + $global_routes = self::_getForwardedRoutes('global'); + if (isset($global_routes['reverse'][$args['act']])) + { + $result = self::_getBestMatchingRoute($global_routes['reverse'][$args['act']], $args2); + if ($result !== false) + { + self::$_route_cache[$keys_string] = $result . '$act:delete'; + return self::_insertRouteVars($result, $args2); + } + } + } + + // Try XE-compatible global routes. + if ($rewrite_level >= 1) + { + if (!isset($args['act']) || ($args['act'] === 'rss' || $args['act'] === 'atom')) + { + $result = self::_getBestMatchingRoute(self::$_global_routes, $args); + if ($result !== false) + { + self::$_route_cache[$keys_string] = $result; + return self::_insertRouteVars($result, $args); + } + } + } + + // If no route matches, just create a query string. + self::$_route_cache[$keys_string] = 'index.php'; + return 'index.php?' . http_build_query($args); + } + + /** + * Load and cache module action info. + * + * @param string $prefix + * @return object + */ + protected static function _getActionInfoByPrefix(string $prefix, string &$module_name = '') + { + if (isset(self::$_action_cache_prefix[$prefix])) + { + $module_name = self::$_action_cache_prefix[$prefix]; + return self::_getActionInfoByModule(self::$_action_cache_prefix[$prefix]) ?: false; + } + + $module_info = \ModuleModel::getModuleInfoByMid($prefix); + if ($module_info && $module_info->module) + { + $module_name = self::$_action_cache_prefix[$prefix] = $module_info->module; + return self::_getActionInfoByModule(self::$_action_cache_prefix[$prefix]) ?: false; + } + else + { + return self::$_action_cache_prefix[$prefix] = false; + } + } + + /** + * Load and cache module action info. + * + * @param string $prefix + * @return object + */ + protected static function _getActionInfoByModule(string $module) + { + if (isset(self::$_action_cache_module[$module])) + { + return self::$_action_cache_module[$module]; + } + + $action_info = \ModuleModel::getModuleActionXml($module); + return self::$_action_cache_module[$module] = $action_info ?: false; + } + + /** + * Get the list of routes that are registered for action-forward. + * + * @param string $type + * @return array + */ + protected static function _getForwardedRoutes(string $type): array + { + if ($type === 'internal' && count(self::$_internal_forwarded_cache)) + { + return self::$_internal_forwarded_cache; + } + if ($type === 'global' && count(self::$_global_forwarded_cache)) + { + return self::$_global_forwarded_cache; + } + + self::$_global_forwarded_cache['GET'] = array(); + self::$_global_forwarded_cache['POST'] = array(); + self::$_global_forwarded_cache['reverse'] = array(); + self::$_internal_forwarded_cache['GET'] = array(); + self::$_internal_forwarded_cache['POST'] = array(); + self::$_internal_forwarded_cache['reverse'] = array(); + + $action_forward = \ModuleModel::getActionForward(); + foreach ($action_forward as $action_name => $action_info) + { + if ($action_info->route_regexp) + { + foreach ($action_info->route_regexp as $regexp_info) + { + if ($action_info->global_route === 'Y') + { + self::$_global_forwarded_cache[$regexp_info[0]][$regexp_info[1]] = [$action_info->module, $action_name]; + } + else + { + self::$_internal_forwarded_cache[$regexp_info[0]][$regexp_info[1]] = [$action_info->module, $action_name]; + } + } + if ($action_info->global_route === 'Y') + { + self::$_global_forwarded_cache['reverse'][$action_name] = $action_info->route_config; + } + else + { + self::$_internal_forwarded_cache['reverse'][$action_name] = $action_info->route_config; + } + } + } + return $type === 'internal' ? self::$_internal_forwarded_cache : self::$_global_forwarded_cache; + } + + /** + * Find the best matching route for an array of variables. + * + * @param array $routes + * @param array $vars + * @return string|false + */ + protected static function _getBestMatchingRoute(array $routes, array $vars) + { + // If the action only has one route, select it. + if (count($routes) == 1) + { + $only_route = key($routes); + $matched_arguments = array_intersect_key($routes[$only_route]['vars'], $vars); + if (count($matched_arguments) !== count($routes[$only_route]['vars'])) + { + return false; + } + return $only_route; + } + + // If the action has multiple routes, select the one that matches the most arguments. + else + { + // Order the routes by the number of matched arguments. + $reordered_routes = array(); + foreach ($routes as $route => $route_vars) + { + $matched_arguments = array_intersect_key($route_vars['vars'], $vars); + if (count($matched_arguments) === count($route_vars['vars'])) + { + $reordered_routes[$route] = ($route_vars['priority'] * 1000) + count($matched_arguments); + } + } + if (!count($reordered_routes)) + { + return false; + } + arsort($reordered_routes); + $best_route = array_first_key($reordered_routes); + return $best_route; + } + } + + /** + * Insert variables into a route. + * + * @param string $route + * @param array $vars + * @return string + */ + protected static function _insertRouteVars(string $route, array $vars): string + { + // Replace variable placeholders with actual variable values. + $route = preg_replace_callback('#\\$([a-zA-Z0-9_]+)(:[a-z]+)?#i', function($match) use(&$vars) { + if (isset($vars[$match[1]])) + { + $replacement = urlencode($vars[$match[1]]); + unset($vars[$match[1]]); + return (isset($match[2]) && $match[2] === ':delete') ? '' : $replacement; + } + else + { + return ''; + } + }, $route); + + // Add a query string for the remaining arguments. + return $route . (count($vars) ? ('?' . http_build_query($vars)) : ''); + } }