diff --git a/CRM/Core/Invoke.php b/CRM/Core/Invoke.php index 51b2d0cb1502..f326d3835ef8 100644 --- a/CRM/Core/Invoke.php +++ b/CRM/Core/Invoke.php @@ -207,6 +207,9 @@ public static function runItem($item) { self::registerPharHandler(); $config = CRM_Core_Config::singleton(); + + // WISHLIST: if $item is a web-service route, swap prepend to $civicrm_url_defaults + if ($config->userFramework == 'Joomla' && $item) { $config->userFrameworkURLVar = 'task'; diff --git a/CRM/Core/Menu.php b/CRM/Core/Menu.php index d22073f07b03..1f9b8c123bc0 100644 --- a/CRM/Core/Menu.php +++ b/CRM/Core/Menu.php @@ -280,6 +280,44 @@ public static function build(&$menu) { self::buildAdminLinks($menu); } + /** + * Determine whether a route should canonically use a frontend or backend UI. + * + * @param string $path + * Ex: 'civicrm/contribute/transact' + * @return bool + * TRUE if the route is marked with 'is_public=1'. + * @internal + * We may wish to revise the metadata to allow more distinctions. In that case, `isPublicRoute()` + * would probably get replaced by something else. + */ + public static function isPublicRoute(string $path): bool { + // A page-view may include hundreds of links - so don't hit DB for every link. Use cache. + // In default+demo builds, the list of public routes is much smaller than the list of + // private routes (roughly 1:10; ~50 entries vs ~450 entries). Cache the smaller list. + $cache = Civi::cache('long'); + $index = $cache->get('PublicRouteIndex'); + if ($index === NULL) { + $routes = CRM_Core_DAO::executeQuery('SELECT id, path FROM civicrm_menu WHERE is_public = 1') + ->fetchMap('id', 'path'); + if (empty($routes)) { + Civi::log()->warning('isPublicRoute() should not be called before the menu has been built.'); + return FALSE; + } + $index = array_fill_keys(array_values($routes), TRUE); + $cache->set('PublicRouteIndex', $index); + } + + $parts = explode('/', $path); + while (count($parts) > 1) { + if (isset($index[implode('/', $parts)])) { + return TRUE; + } + array_pop($parts); + } + return FALSE; + } + /** * This function recomputes menu from xml and populates civicrm_menu. * @@ -291,6 +329,7 @@ public static function store($truncate = TRUE) { $query = 'TRUNCATE civicrm_menu'; CRM_Core_DAO::executeQuery($query); } + Civi::cache('long')->delete('PublicRouteIndex'); $menuArray = self::items($truncate); self::build($menuArray); diff --git a/CRM/Core/Smarty/plugins/block.url.php b/CRM/Core/Smarty/plugins/block.url.php new file mode 100644 index 000000000000..71814af2a1b1 --- /dev/null +++ b/CRM/Core/Smarty/plugins/block.url.php @@ -0,0 +1,60 @@ +addVars($params); + + // This could be neat, but see discussion in CRM_Core_Smarty_plugins_UrlTest for why it's currently off. + // $url->setVarsCallback([$smarty, 'get_template_vars']); + + if ($assign !== NULL) { + $smarty->assign([$assign => $url]); + return ''; + } + else { + return $url; + } +} diff --git a/CRM/Core/xml/Menu/Misc.xml b/CRM/Core/xml/Menu/Misc.xml index 5aad20becc9d..625c91840549 100644 --- a/CRM/Core/xml/Menu/Misc.xml +++ b/CRM/Core/xml/Menu/Misc.xml @@ -135,6 +135,7 @@ civicrm/asset/builder \Civi\Core\AssetBuilder::pageRun *always allow* + 1 civicrm/contribute/ajax/tableview diff --git a/CRM/Utils/System/Base.php b/CRM/Utils/System/Base.php index c78f72c0c97f..c6c0220e79be 100644 --- a/CRM/Utils/System/Base.php +++ b/CRM/Utils/System/Base.php @@ -136,6 +136,42 @@ abstract public function url( $forceBackend = FALSE ); + /** + * Compose the URL for a page/route. + * + * @internal + * @see \Civi\Core\Url::__toString + * @param string $scheme + * Ex: 'frontend', 'backend', 'service' + * @param string $path + * Ex: 'civicrm/event/info' + * @param string|null $query + * Ex: 'id=100&msg=Hello+world' + * @return string|null + * Absolute URL, or NULL if scheme is unsupported. + * Ex: 'https://subdomain.example.com/index.php?q=civicrm/event/info&id=100&msg=Hello+world' + */ + public function getRouteUrl(string $scheme, string $path, ?string $query): ?string { + switch ($scheme) { + case 'frontend': + return $this->url($path, $query, TRUE, NULL, TRUE, FALSE, FALSE); + + case 'service': + // The original `url()` didn't have an analog for "service://". But "frontend" is probably the closer bet? + // Or maybe getNotifyUrl() makes sense? + return $this->url($path, $query, TRUE, NULL, TRUE, FALSE, FALSE); + + case 'backend': + return $this->url($path, $query, TRUE, NULL, FALSE, TRUE, FALSE); + + // If the UF defines other major UI/URL conventions, then you might hypothetically handle + // additional schemes. + + default: + return NULL; + } + } + /** * Return the Notification URL for Payments. * diff --git a/Civi.php b/Civi.php index d582bba2b080..7c8684554188 100644 --- a/Civi.php +++ b/Civi.php @@ -218,4 +218,89 @@ public static function settings($domainID = NULL) { return \Civi\Core\Container::getBootService('settings_manager')->getBagByDomain($domainID); } + /** + * Construct a URL based on a logical service address. For example: + * + * Civi::url('frontend://civicrm/user?reset=1'); + * + * Civi::url() + * ->setScheme('frontend') + * ->setPath(['civicrm', 'user']) + * ->setQuery(['reset' => 1]) + * + * URL building follows a few rules: + * + * 1. You may initialize with a baseline URL. + * 2. The scheme indicates the general type of URL ('frontend://', 'backend://', 'asset://', 'assetBuilder://'). + * 3. The result object provides getters, setters, and adders (e.g. `getScheme()`, `setPath(...)`, `addQuery(...)`) + * 4. Strings are raw. Arrays are auto-encoded. (`addQuery('name=John+Doughnut')` or `addQuery(['name' => 'John Doughnut'])`) + * 5. You may use variable expressions (`id=[contact]&gid=[profile]`). + * 6. The URL can be cast to string (aka `__toString()`). + * + * If you are converting from `CRM_Utils_System::url()` to `Civi::url()`, then be sure to: + * + * - Pay attention to the scheme (eg 'current://' vs 'frontend://') + * - Pay attention to HTML escaping, as the behavior changed: + * - Civi::url() returns plain URLs (eg "id=100&gid=200") by default + * - CRM_Utils_System::url() returns HTML-escaped URLs (eg "id=100&gid=200") by default + * + * Here are several examples: + * + * Ex: Link to constituent's dashboard (on frontend UI or backend UI -- based on the active scheme of current page-view) + * $url = Civi::url('current://civicrm/user?reset=1'); + * $url = Civi::url('//civicrm/user?reset=1'); + * + * Ex: Link to constituent's dashboard (with method calls - good for dynamic options) + * $url = Civi::url('frontend:') + * ->setPath('civicrm/user') + * ->addQuery(['reset' => 1]); + * + * Ex: Link to constituent's dashboard (with quick flags: absolute URL, SSL required, HTML escaping) + * $url = Civi::url('frontend://civicrm/user?reset=1', 'ash'); + * + * Ex: Link to constituent's dashboard (with method flags - good for dynamic options) + * $url = Civi::url('frontend://civicrm/user?reset=1') + * ->setPreferFormat('absolute') + * ->setSsl(TRUE) + * ->setHtmlEscape(TRUE); + * + * Ex: Link to a dynamically generated asset-file. + * $url = Civi::url('assetBuilder://crm-l10n.js?locale=en_US'); + * + * Ex: Link to a static asset (resource-file) in one of core's configurable paths. + * $url = Civi::url('[civicrm.root]/js/Common.js'); + * + * Ex: Link to a static asset (resource-file) in an extension. + * $url = Civi::url('ext://org.civicrm.search_kit/css/crmSearchTasks.css'); + * + * Ex: Link with variable substitution + * $url = Civi::url('frontend://civicrm/ajax/api4/[entity]/[action]') + * ->addVars(['entity' => 'Foo', 'action' => 'bar']); + * + * @param string|null $logicalUri + * Logical URI. The scheme of the URI may be one of: + * - 'frontend://' (Front-end page-route for constituents) + * - 'backend://' (Back-end page-route for staff) + * - 'service://' (Web-service page-route for automated integrations; aka webhooks and IPNs) + * - 'current://' (Whichever UI is currently active) + * - 'default://' (Whichever UI is recorded in the metadata) + * - 'asset://' (Static asset-file; see \Civi::paths()) + * - 'assetBuilder://' (Dynamically-generated asset-file; see \Civi\Core\AssetBuilder) + * - 'ext://' (Static asset-file provided by an extension) + * An empty scheme (`//hello.txt`) is equivalent to `current://hello.txt`. + * @param string|null $flags + * List of flags. Some combination of the following: + * - 'a': absolute (aka `setPreferFormat('absolute')`) + * - 'r': relative (aka `setPreferFormat('relative')`) + * - 'h': html (aka `setHtmlEscape(TRUE)`) + * - 't': text (aka `setHtmlEscape(FALSE)`) + * - 's': ssl (aka `setSsl(TRUE)`) + * - 'c': cache code for resources (aka Civi::resources()->addCacheCode()) + * @return \Civi\Core\Url + * URL object which may be modified or rendered as text. + */ + public static function url(?string $logicalUri = NULL, ?string $flags = NULL): \Civi\Core\Url { + return new \Civi\Core\Url($logicalUri, $flags); + } + } diff --git a/Civi/Core/Url.php b/Civi/Core/Url.php new file mode 100644 index 000000000000..7d7ee73cf4db --- /dev/null +++ b/Civi/Core/Url.php @@ -0,0 +1,713 @@ + 100, "b" => "Hello world"] + * - "Fragment": "#/mailing/new" vs ["/mailing", "/new"] + * - "Fragment Query": "angularDebug=1" vs ["angularDebug" => 1] + * + * The raw-string is supported from all angles (storage+getters+setters+adders). + * Additionally, the setters+adders accept arrays. + * + * This cl + * @see \Civi::url() + */ +final class Url implements \JsonSerializable { + + /** + * @var string + * Ex: 'frontend', 'backend' + */ + private $scheme; + + /** + * @var string + * Ex: 'civicrm/dashboard' + */ + private $path; + + /** + * @var string + * Ex: abc=123&xyz=456 + */ + private $query; + + /** + * @var string|null + */ + private $fragment; + + /** + * @var string|null + */ + private $fragmentQuery; + + /** + * Whether to auto-append the cache-busting resource code. + * + * @var bool|null + * NULL definition TBD (either "off" or "automatic"?) + */ + private $cacheCode; + + /** + * Preferred format. + * + * Note that this is not strictly guaranteed. It may sometimes return absolute URLs even if you + * prefer relative URLs (e.g. if there's no easy/correct way to form a relative URL). + * + * @var string|null + * 'relative' or 'absolute' + * NULL means "decide automatically" + */ + private $preferFormat; + + /** + * Whether to HTML-encode the output. + * + * Note: Why does this exist? It's insane, IMHO. There's nothing intrinsically HTML-y about URLs. + * However, practically speaking, this class aims to replace `CRM_Utils_System::url()` which + * performed HTML encoding by default. Retaining some easy variant of this flag should make the + * off-ramp a bit smoother. + * + * @var bool + */ + private $htmlEscape = FALSE; + + /** + * @var bool|null + * NULL means "decide automatically" + */ + private $ssl = NULL; + + /** + * List of values to mix-in to the final/rendered URL. + * + * @var string[]|null + */ + private $vars; + + /** + * Define a dynamic lookup for variables. + * + * @var callable|null + */ + private $varsCallback; + + /** + * @param string|null $logicalUri + * @param string|null $flags + * @see \Civi::url() + */ + public function __construct(?string $logicalUri = NULL, ?string $flags = NULL) { + if ($logicalUri !== NULL) { + $this->useUri($logicalUri); + } + if ($flags !== NULL) { + $this->useFlags($flags); + } + } + + /** + * Parse a logical URI. + * + * @param string $logicalUri + * @return void + */ + protected function useUri(string $logicalUri): void { + if ($logicalUri[0] === '/') { + // Scheme-relative path implies a preferences to inherit current scheme. + $logicalUri = 'current:' . $logicalUri; + } + elseif ($logicalUri[0] === '[') { + $logicalUri = 'asset://' . $logicalUri; + } + // else: Should we fill in scheme when there is NO indicator (eg $logicalUri===`civicrm/event/info')? + // It could be a little annoying to write `frontend://` everywhere. It's not hard to add this. + // But it's ambiguous whether `current://` or `default://` is the better interpretation. + // I'd sooner vote for something explicit but short -- eg aliases (f<=>frontend; d<=>default) + // - `Civi::url('f://civicrm/event/info')` + // - `Civi::url('civicrm/event/info', 'f')`. + + $parsed = parse_url($logicalUri); + $this->scheme = $parsed['scheme'] ?? NULL; + $this->path = $parsed['host'] ?? NULL; + if (isset($parsed['path'])) { + $this->path .= $parsed['path']; + } + $this->query = $parsed['query'] ?? NULL; + $fragmentParts = isset($parsed['fragment']) ? explode('?', $parsed['fragment'], 2) : []; + $this->fragment = $fragmentParts[0] ?? NULL; + $this->fragmentQuery = $fragmentParts[1] ?? NULL; + } + + /** + * @return string + * Ex: 'frontend' or 'backend' + */ + public function getScheme() { + return $this->scheme; + } + + /** + * @param string $scheme + * Ex: 'frontend' or 'backend' + */ + public function setScheme(string $scheme): Url { + $this->scheme = $scheme; + return $this; + } + + /** + * @return string|null + * Ex: 'civicrm/event/info' + * Ex: 'civicrm/hello+world%3F' + */ + public function getPath() { + return $this->path; + } + + /** + * @param string|string[]|null $path + * Ex: 'civicrm/event/info' + * Ex: 'civicrm/hello+world%3F' + * Ex: ['civicrm', 'hello world?'] + */ + public function setPath($path): Url { + $this->path = static::encodePath($path); + return $this; + } + + /** + * Add new sections to the path. + * + * When adding new parts to the path, there is an implicit delimiter ('/') between parts. + * + * @param string|string[] $path + * Ex: 'civicrm/event/info' + * Ex: 'civicrm/hello+world%3F' + * Ex: ['civicrm', 'hello world?'] + * @return $this + */ + public function addPath($path): Url { + static::appendString($this->path, '/', static::encodePath($path)); + return $this; + } + + /** + * @return string|null + * Ex: 'name=John+Doughnut&id=9' + */ + public function getQuery(): ?string { + return $this->query; + } + + /** + * @param string|string[]|null $query + * Ex: 'name=John+Doughnut&id=9' + * Ex: ['name' => 'John Doughnut', 'id' => 9] + * @return $this + */ + public function setQuery($query): Url { + if (is_array($query)) { + $query = \CRM_Utils_System::makeQueryString($query); + } + $this->query = $query; + return $this; + } + + /** + * @param string|string[] $query + * Ex: 'name=John+Doughnut&id=9' + * Ex: ['name' => 'John Doughnut', 'id' => 9] + * @return $this + */ + public function addQuery($query): Url { + if (is_array($query)) { + $query = \CRM_Utils_System::makeQueryString($query); + } + static::appendString($this->query, '&', $query); + return $this; + } + + /** + * Get the primary fragment. + * + * NOTE: This is the primary fragment identifier (as in `#id` or `#/client/side/route`). + * and does not include fregment queries. (as in '#?angularDebug=1'). + * + * @return string|null + * Ex: '/mailing/new' + * Ex: '/foo+bar%3F/newish%3F' + * @see Url::getFragmentQuery() + * @see Url::composeFragment() + */ + public function getFragment(): ?string { + return $this->fragment; + } + + /** + * Replace the fragment. + * + * NOTE: This is the primary fragment identifier (as in `#id` or `#/client/side/route`). + * and does not include fregment queries. (as in '#?angularDebug=1'). + * + * @param string|string[]|null $fragment + * Ex: '/mailing/new' + * Ex: '/foo+bar/newish%3F' + * Ex: ['', 'foo bar', 'newish?'] + * @return $this + * @see Url::setFragmentQuery() + * @see url::composeFragment() + */ + public function setFragment($fragment): Url { + $this->fragment = static::encodePath($fragment); + return $this; + } + + /** + * Add to fragment. + * + * @param string|string[] $fragment + * Ex: 'mailing/new' + * Ex: 'foo+bar/newish%3F' + * Ex: ['foo bar', 'newish?'] + * @return $this + */ + public function addFragment($fragment): Url { + static::appendString($this->fragment, '/', static::encodePath($fragment)); + return $this; + } + + /** + * @return string|null + * Ex: 'name=John+Doughnut&id=9' + */ + public function getFragmentQuery(): ?string { + return $this->fragmentQuery; + } + + /** + * @param string|string[]|null $fragmentQuery + * Ex: 'name=John+Doughnut&id=9' + * Ex: ['name' => 'John Doughnut', 'id' => 9] + * @return $this + */ + public function setFragmentQuery($fragmentQuery) { + if (is_array($fragmentQuery)) { + $fragmentQuery = \CRM_Utils_System::makeQueryString($fragmentQuery); + } + $this->fragmentQuery = $fragmentQuery; + return $this; + } + + /** + * @param string|array $fragmentQuery + * Ex: 'name=John+Doughnut&id=9' + * Ex: ['name' => 'John Doughnut', 'id' => 9] + * @return $this + */ + public function addFragmentQuery($fragmentQuery): Url { + if (is_array($fragmentQuery)) { + $fragmentQuery = \CRM_Utils_System::makeQueryString($fragmentQuery); + } + static::appendString($this->fragmentQuery, '&', $fragmentQuery); + return $this; + } + + /** + * @return bool|null + */ + public function getCacheCode(): ?bool { + return $this->cacheCode; + } + + /** + * Specify whether to append a cache-busting code. + * + * @param bool|null $cacheCode + * TRUE: Do append + * FALSE: Do not append + * @return $this; + */ + public function setCacheCode(?bool $cacheCode) { + $this->cacheCode = $cacheCode; + return $this; + } + + /** + * @return string|null + * 'relative' or 'absolute' + */ + public function getPreferFormat(): ?string { + return $this->preferFormat; + } + + /** + * Specify whether to prefer absolute or relative formatting. + * + * @param string|null $preferFormat + * One of: + * - 'relative': Prefer relative format, if available + * - 'absolute': Prefer absolute format + * - NULL: Decide format based on current environment/request. (Ordinary web UI requests prefer 'relative'.) + */ + public function setPreferFormat(?string $preferFormat): Url { + $this->preferFormat = $preferFormat; + return $this; + } + + /** + * @return bool + */ + public function getHtmlEscape(): bool { + return $this->htmlEscape; + } + + /** + * Specify whether to enable HTML escaping of the final output. + * + * @param bool $htmlEscape + * @return $this + */ + public function setHtmlEscape(bool $htmlEscape): Url { + $this->htmlEscape = $htmlEscape; + return $this; + } + + /** + * @return bool|null + */ + public function getSsl(): ?bool { + return $this->ssl; + } + + /** + * Specify whether the hyperlink should use SSL. + * + * @param bool|null $ssl + * TRUE: Force SSL on. (Convert "http:" to "https:") + * FALSE: Force SSL off. (Convert "https:" to "http:") + * NULL: Inherit current SSL-ness + */ + public function setSsl(?bool $ssl): Url { + $this->ssl = $ssl; + return $this; + } + + /** + * @return string[]|null + */ + public function getVars(): ?array { + return $this->vars; + } + + /** + * Specify a list of variables. After composing all parts of the URL, variables will be replaced + * with their URL-encoded values. + * + * Example: + * Civi::url('frontend://civicrm/greeter?cid=[contact]&msg=[message]') + * ->setVars(['contact' => 123, 'message' => 'Hello to you & you & you!'); + * + * @param string[]|null $vars + * @return $this + */ + public function setVars(?array $vars): Url { + $this->vars = $vars; + return $this; + } + + /** + * Add more variables. After composing all parts of the URL, variables will be replaced + * with their URL-encoded values. + * + * Example: + * Civi::url('frontend://civicrm/greeter?cid=[contact]&msg=[message]') + * ->addVars(['contact' => 123, 'message' => 'Hello to you & you & you!'); + * + * @param string[] $vars + * @return $this + */ + public function addVars(array $vars): Url { + $this->vars = $vars + ($this->vars ?: []); + return $this; + } + + /** + * @return callable|null + */ + public function getVarsCallback(): ?callable { + return $this->varsCallback; + } + + /** + * Configure dynamic lookup for variables. + * + * @param callable|null $varsCallback + * Function(string $varName): ?string + * Determine the string-value of the variable. (May be ''.) + * If the variable is unavailable, return NULL. + * @return $this + */ + public function setVarsCallback(?callable $varsCallback) { + $this->varsCallback = $varsCallback; + return $this; + } + + /** + * Apply a series of flags using short-hand notation. + * + * @param string $flags + * List of flag-letters, such as (a)bsolute or (r)elative + * For a full list, see Civi::url(). + * @see Civi::url() + * @return $this + */ + public function useFlags(string $flags): Url { + $len = strlen($flags); + for ($i = 0; $i < $len; $i++) { + switch ($flags[$i]) { + // (a)bsolute url + case 'a': + $this->preferFormat = 'absolute'; + break; + + // (r)elative url + case 'r': + $this->preferFormat = 'relative'; + break; + + // (h)tml encoding + case 'h': + $this->htmlEscape = TRUE; + break; + + // (t)ext encoding (canonical URL form) + case 't': + $this->htmlEscape = FALSE; + break; + + // (s)sl + case 's'; + $this->ssl = TRUE; + break; + + // (c)ache code for resources + case 'c': + $this->cacheCode = TRUE; + break; + } + } + return $this; + } + + /** + * Render the final URL as a string. + * + * @return string + */ + public function __toString(): string { + $userSystem = \CRM_Core_Config::singleton()->userSystem; + $preferFormat = $this->getPreferFormat() ?: static::detectFormat(); + $scheme = $this->getScheme(); + + if ($scheme === NULL || $scheme === 'current') { + $scheme = static::detectScheme(); + } + + if ($scheme === 'default') { + $scheme = \CRM_Core_Menu::isPublicRoute($this->getPath()) ? 'frontend' : 'backend'; + } + + // Goal: After this switch(), we should have the $scheme, $path, and $query combined. + switch ($scheme) { + case 'assetBuilder': + $assetName = $this->getPath(); + $assetParams = []; + parse_str('' . $this->getQuery(), $assetParams); + $result = \Civi::service('asset_builder')->getUrl($assetName, $assetParams); + break; + + case 'asset': + if (preg_match(';^\[([\w\.]+)\](.*)$;', $this->getPath(), $m)) { + [, $var, $rest] = $m; + $varValue = rtrim(\Civi::paths()->getVariable($var, 'url'), '/'); + $result = $varValue . $rest . $this->composeQuery(); + } + else { + throw new \RuntimeException("Malformed asset path: {$this->getPath()}"); + } + break; + + case 'ext': + $parts = explode('/', $this->getPath(), 2); + $result = \Civi::resources()->getUrl($parts[0], $parts[1] ?? NULL, FALSE) . $this->composeQuery(); + break; + + // Handle 'frontend', 'backend', 'service', and any extras. + default: + $result = $userSystem->getRouteUrl($scheme, $this->getPath(), $this->getQuery()); + if ($result === NULL) { + throw new \RuntimeException("Unknown URL scheme: $scheme"); + } + break; + } + + if ($this->cacheCode) { + $result = \Civi::resources()->addCacheCode($result); + } + + $result .= $this->composeFragment(); + + if ($preferFormat === 'relative') { + $result = \CRM_Utils_Url::toRelative($result); + } + + // TODO decide if the current default is good enough for future + $ssl = $this->getSsl() ?: \CRM_Utils_System::isSSL(); + if ($ssl && str_starts_with($result, 'http:')) { + $result = 'https:' . substr($result, 5); + } + elseif (!$ssl && str_starts_with($result, 'https:')) { + $result = 'http:' . substr($result, 6); + } + + if ($this->vars !== NULL) { + // Replace variables + $result = preg_replace_callback('/\[(\w+)\]/', function($m) { + $var = $m[1]; + if (isset($this->vars[$var])) { + return urlencode($this->vars[$var]); + } + if ($this->varsCallback !== NULL) { + $value = call_user_func($this->varsCallback, $var); + if ($value !== NULL) { + return urlencode($value); + } + } + return "[$var]"; + }, $result); + } + + return $this->htmlEscape ? htmlentities($result) : $result; + } + + #[\ReturnTypeWillChange] + public function jsonSerialize() { + return $this->__toString(); + } + + /** + * @return string + * '' or '?foo=bar' + */ + private function composeQuery(): string { + if ($this->query !== NULL && $this->query !== '') { + return '?' . $this->query; + } + else { + return ''; + } + } + + /** + * @return string + * '' or '#foobar' + */ + private function composeFragment(): string { + $fragment = $this->fragment ?: ''; + if ($this->fragmentQuery !== NULL && $this->fragmentQuery !== '') { + $fragment .= '?' . $this->fragmentQuery; + } + return ($fragment === '') ? '' : "#$fragment"; + } + + private static function detectFormat(): string { + // Some environments may override default - e.g. cv-cli prefers absolute URLs + // WISHLIST: If handling `Job.*`, then 'absolute' + // WISHLIST: If active route is a web-service/web-hook/IPN, then 'absolute' + foreach ($GLOBALS['civicrm_url_defaults'] ?? [] as $default) { + if (isset($default['format'])) { + return $default['format']; + } + } + + // Web UI: Most CiviCRM routes (`CRM_Core_Invoke::invoke()`) and CMS blocks + return 'relative'; + } + + private static function detectScheme(): string { + // Some environments may override default - e.g. cv-cli prefers 'default://'. + // WISHLIST: If handling `Job.*`, then `default://' + // WISHLIST: If active route is a web-service/web-hook/IPN, then 'default://' + foreach ($GLOBALS['civicrm_url_defaults'] ?? [] as $default) { + if (isset($default['scheme'])) { + return $default['scheme']; + } + } + + // Web UI: Most CiviCRM routes (`CRM_Core_Invoke::invoke()`) and CMS blocks + return \CRM_Core_Config::singleton()->userSystem->isFrontEndPage() ? 'frontend' : 'backend'; + } + + /** + * @param string|string[]|null $path + * Ex: 'greet/hello+world/en' + * Ex: ['greet', 'hello world', 'en'] + * @return string|null + * Ex: 'greet/hello+world/en' + */ + private static function encodePath($path): ?string { + if (is_array($path)) { + $encodedArray = array_map('urlencode', $path); + return implode('/', $encodedArray); + } + else { + return $path; + } + } + + private static function appendString(?string &$var, string $separator, ?string $value): void { + if ($value === NULL) { + return; + } + + if ($var === NULL) { + $var = $value; + return; + } + + // Dedupe separators + if (str_ends_with($var, $separator)) { + $var = rtrim($var, $separator); + } + if ($value[0] === $separator) { + $value = ltrim($value, $separator); + } + + $var = $var . $separator . $value; + } + +} diff --git a/tests/phpunit/CRM/Core/MenuTest.php b/tests/phpunit/CRM/Core/MenuTest.php index a01842077648..c70023a091c4 100644 --- a/tests/phpunit/CRM/Core/MenuTest.php +++ b/tests/phpunit/CRM/Core/MenuTest.php @@ -135,4 +135,9 @@ public function testGetArrayForPathArgs($inputString, $expectedArray) { $this->assertEquals($expectedArray, $actual); } + public function testIsPublicRoute(): void { + $this->assertEquals(FALSE, \CRM_Core_Menu::isPublicRoute('civicrm/contribute')); + $this->assertEquals(TRUE, \CRM_Core_Menu::isPublicRoute('civicrm/contribute/transact')); + } + } diff --git a/tests/phpunit/CRM/Core/Smarty/plugins/UrlTest.php b/tests/phpunit/CRM/Core/Smarty/plugins/UrlTest.php new file mode 100644 index 000000000000..b9a214139177 --- /dev/null +++ b/tests/phpunit/CRM/Core/Smarty/plugins/UrlTest.php @@ -0,0 +1,92 @@ +useTransaction(); + } + + /** + * @return array + */ + public function urlCases() { + $literal = function(string $s) { + return '!' . preg_quote($s, '!') . '!'; + }; + + $cases = []; + $cases[] = [ + // Generate an ordinary, HTML-style URL. + $literal('q=civicrm/profile/view&id=123&gid=456'), + '{url}//civicrm/profile/view?id=123&gid=456{/url}', + ]; + $cases[] = [ + // Here, we assign the plain-text variable and then use it for JS expression + '!window.location = ".*q=civicrm/profile/view&id=123&gid=456"!', + '{url assign=myUrl flags=t}//civicrm/profile/view?id=123&gid=456{/url}' . + 'window.location = "{$myUrl}";', + ]; + $cases[] = [ + $literal('q=civicrm/profile/view&id=999&message=hello+world'), + '{url 1="999" 2="hello world"}//civicrm/profile/view?id=[1]&message=[2]{/url}', + ]; + $cases[] = [ + $literal('q=civicrm/profile/view&id=123&message=hello+world'), + '{url msg="hello world"}//civicrm/profile/view?id=123&message=[msg]{/url}', + ]; + $cases[] = [ + // Define a temporary variable for use in the URL. + $literal('q=civicrm/profile/view&id=123&message=this+%26+that'), + '{url msg="this & that"}//civicrm/profile/view?id=123&message=[msg]{/url}', + ]; + $cases[] = [ + // We have a Smarty variable which already included escaped data. Smarty should do substitution. + $literal('q=civicrm/profile/view&id=123&message=this+%2B+that'), + '{assign var=msg value="this+%2B+that"}' . + '{url flags=%}//civicrm/profile/view?id=123&message={$msg}{/url}', + ]; + $cases[] = [ + // Generate client-side route (with Angular path and params) + $literal('q=civicrm/a/#/mailing/100?angularDebug=1'), + '{url id=100}backend://civicrm/a/#/mailing/[id]?angularDebug=1{/url}', + ]; + + // This example is neat - you just replace `{$msg}` with `[msg]`, and then you get encoded URL data. + // But... it's pretty shallow. You can't use Smarty expressions or modifiers. Additionally, + // enabling this mode increases the risk of accidental collisions between Smarty variables + // and deep-form-params. So I've left it disabled for now. + // + // $cases[] = [ + // // We have a Smarty variable with canonical (unescaped) data. Use it as URL variable. + // $literal('q=civicrm/profile/view&id=123&message=this+%2B+that'), + // '{assign var=msg value="this + that"}' . + // '{url}//civicrm/profile/view?id=123&message=[msg]{/url}', + // ]; + + // return CRM_Utils_Array::subset($cases, [2]); + return $cases; + } + + /** + * @dataProvider urlCases + * @param string $expected + * @param string $input + */ + public function testUrl($expected, $input) { + $smarty = CRM_Core_Smarty::singleton(); + $actual = $smarty->fetch('string:' . $input); + $this->assertRegExp($expected, $actual, "Process input=[$input]"); + } + +} diff --git a/tests/phpunit/Civi/Core/UrlTest.php b/tests/phpunit/Civi/Core/UrlTest.php new file mode 100644 index 000000000000..3548458b28f0 --- /dev/null +++ b/tests/phpunit/Civi/Core/UrlTest.php @@ -0,0 +1,188 @@ +assertRegexp(';^[a-z0-9\.\-]+(:\d+)?$;', $parts[2], 'CIVICRM_UF_BASEURL should have domain name and/or port'); + $tmpVars['_SERVER']['HTTP_HOST'] = $parts[2]; + \CRM_Utils_GlobalStack::singleton()->push($tmpVars); + + parent::setUp(); + $this->useTransaction(); + } + + protected function tearDown(): void { + parent::tearDown(); + \CRM_Utils_GlobalStack::singleton()->pop(); + } + + public function testAbsoluteRelative() { + $absolutes = []; + $absolutes['flag'] = Civi::url('backend://civicrm/admin', 'a'); + $absolutes['method'] = Civi::url('backend://civicrm/admin')->setPreferFormat('absolute'); + $absolutes['ext'] = Civi::url('ext://org.civicrm.search_kit/js/foobar.js', 'a'); + $absolutes['asset'] = Civi::url('asset://[civicrm.packages]/js/foobar.js', 'a'); + + $relatives = []; + $relatives['default'] = Civi::url('backend://civicrm/admin'); + $relatives['flag'] = Civi::url('backend://civicrm/admin', 'r'); + $relatives['method'] = Civi::url('backend://civicrm/admin')->setPreferFormat('relative'); + $relatives['ext'] = Civi::url('ext://org.civicrm.search_kit/js/foobar.js', 'r'); + $relatives['asset'] = Civi::url('asset://[civicrm.packages]/js/foobar.js', 'r'); + + foreach ($absolutes as $key => $url) { + $this->assertRegExp(';^https?://;', (string) $url, "absolutes[$key] should be absolute URL"); + } + foreach ($relatives as $key => $url) { + $this->assertNotRegExp(';^https?://;', (string) $url, "relatives[$key] should be relative URL"); + } + } + + public function testPath() { + $examples = []; + $examples[] = ['civicrm/ajax/api4', Civi::url('service://civicrm/ajax/api4')]; + $examples[] = ['civicrm/ajax/api4/Contact/get+stuff', Civi::url('service://civicrm/ajax/api4/Contact/get+stuff')]; + $examples[] = ['civicrm/ajax/api4/Contact/get+stuff', Civi::url('service://civicrm/ajax/api4')->addPath(['Contact', 'get stuff'])]; + $examples[] = ['civicrm/ajax/api4/Contact/get+stuff', Civi::url('service://civicrm/ajax/api4/Contact')->addPath('get+stuff')]; + $examples[] = ['civicrm/ajax/api4/Contact/get+stuff', Civi::url('service://civicrm/ajax/api4/Contact')->addPath(['get stuff'])]; + $examples[] = ['civicrm/new-path', Civi::url('service://civicrm/old-path')->setPath('civicrm/new-path')]; + + foreach ($examples as $key => $example) { + /** @var \Civi\Core\Url $url */ + [$expected, $url] = $example; + $this->assertEquals($expected, $url->getPath(), sprintf("%s at %d should be have matching property", __FUNCTION__, $key)); + $this->assertStringContainsString($expected, (string) $url, sprintf("%s at %d should be have matching output", __FUNCTION__, $key)); + } + } + + public function testQuery() { + $examples = []; + $examples[] = ['reset=1&id=9', Civi::url('frontend://civicrm/profile/view?reset=1&id=9')]; + $examples[] = ['reset=1&id=9', Civi::url('frontend://civicrm/profile/view')->addQuery('reset=1&id=9')]; + $examples[] = ['reset=1&id=9', Civi::url('frontend://civicrm/profile/view')->addQuery(['reset' => 1, 'id' => 9])]; + $examples[] = ['noise=Hello+world%3F', Civi::url('frontend://civicrm/profile/view?noise=Hello+world%3F')]; + $examples[] = ['noise=Hello+world%3F', Civi::url('frontend://civicrm/profile/view')->addQuery('noise=Hello+world%3F')]; + $examples[] = ['noise=Hello+world%3F', Civi::url('frontend://civicrm/profile/view')->addQuery(['noise' => 'Hello world?'])]; + $examples[] = ['reset=1&id=9', Civi::url('frontend://civicrm/profile/view?forget=this')->setQuery('reset=1&id=9')]; + $examples[] = ['reset=1&id=9', Civi::url('frontend://civicrm/profile/view?forget=this')->setQuery(['reset' => 1, 'id' => 9])]; + $examples[] = ['reset=1&id=9', Civi::url('frontend://civicrm/profile/view?forget=this')->setQuery('reset=1')->addQuery('id=9')]; + + foreach ($examples as $key => $example) { + /** @var \Civi\Core\Url $url */ + [$expected, $url] = $example; + $this->assertEquals($expected, $url->getQuery(), sprintf("%s at %d should be have matching property", __FUNCTION__, $key)); + $this->assertStringContainsString($expected, (string) $url, sprintf("%s at %d should be have matching output", __FUNCTION__, $key)); + } + } + + public function testFragment() { + $examples = []; + $examples[] = ['/mailing/new', Civi::url('frontend://civicrm/a/#/mailing/new')]; + $examples[] = ['/mailing/new', Civi::url('frontend://civicrm/a/#/')->addFragment('mailing/new')]; + $examples[] = ['/mailing/new', Civi::url('frontend://civicrm/a/#/')->addFragment('/mailing/new')]; + $examples[] = ['/mailing/new', Civi::url('frontend://civicrm/a/#/')->addFragment(['mailing', 'new'])]; + $examples[] = [NULL, Civi::url('frontend://civicrm/a/#/mailing/new')->setFragment(NULL)]; + $examples[] = ['/mailing/new+stuff', Civi::url('frontend://civicrm/a/#/mailing/new+stuff?extra=1')]; + $examples[] = ['/mailing/new+stuff', Civi::url('frontend://civicrm/a/#/mailing?extra=1')->addFragment('new+stuff')]; + $examples[] = ['/mailing/new+stuff', Civi::url('frontend://civicrm/a/#/mailing?extra=1')->addFragment(['new stuff'])]; + $examples[] = ['/mailing/new+stuff', Civi::url('frontend://civicrm/a/#/ignore?extra=1')->setFragment('/mailing/new+stuff')]; + $examples[] = ['/mailing/new+stuff', Civi::url('frontend://civicrm/a/#/ignore?extra=1')->setFragment(['', 'mailing', 'new stuff'])]; + + foreach ($examples as $key => $example) { + /** @var \Civi\Core\Url $url */ + [$expected, $url] = $example; + $this->assertEquals($expected, $url->getFragment(), sprintf("%s at %d should be have matching property", __FUNCTION__, $key)); + if ($expected !== NULL) { + $this->assertStringContainsString($expected, (string) $url, sprintf("%s at %d should be have matching output", __FUNCTION__, $key)); + } + } + } + + public function testFragmentQuery() { + $examples = []; + $examples[] = ['angularDebug=1&extra=hello+world%3F', Civi::url('frontend://civicrm/a/#/mailing/new?angularDebug=1&extra=hello+world%3F')]; + $examples[] = ['angularDebug=1&extra=hello+world%3F', Civi::url('frontend://civicrm/a/#/mailing/new?angularDebug=1')->addFragmentQuery('extra=hello+world%3F')]; + $examples[] = ['angularDebug=1&extra=hello+world%3F', Civi::url('frontend://civicrm/a/#/mailing/new')->addFragmentQuery('angularDebug=1&extra=hello+world%3F')]; + $examples[] = ['angularDebug=1&extra=hello+world%3F', Civi::url('frontend://civicrm/a/#/mailing/new')->addFragmentQuery(['angularDebug' => 1, 'extra' => 'hello world?'])]; + $examples[] = ['angularDebug=1&extra=hello+world%3F', Civi::url('frontend://civicrm/a/#/mailing/new')->setFragmentQuery('angularDebug=1&extra=hello+world%3F')]; + $examples[] = ['angularDebug=1&extra=hello+world%3F', Civi::url('frontend://civicrm/a/#/mailing/new')->setFragmentQuery(['angularDebug' => 1, 'extra' => 'hello world?'])]; + + foreach ($examples as $key => $example) { + /** @var \Civi\Core\Url $url */ + [$expected, $url] = $example; + $this->assertEquals($expected, $url->getFragmentQuery(), sprintf("%s at %d should be have matching property", __FUNCTION__, $key)); + if ($expected !== NULL) { + $this->assertStringContainsString($expected, (string) $url, sprintf("%s at %d should be have matching output", __FUNCTION__, $key)); + } + } + } + + public function testVars(): void { + $vars = ['hi' => 'hello world?', 'contact' => 123]; + + $examples = []; + $examples[] = ['civicrm/admin/hello+world%3F', Civi::url('backend://civicrm/admin/[hi]?x=1')]; + $examples[] = ['msg=hello+world%3F&id=123', Civi::url('backend://civicrm/admin?msg=[hi]&id=[contact]')]; + $examples[] = ['a=123&b=456', Civi::url('backend://civicrm/admin?a=[1]&b=[2]')->addVars([1 => 123, 2 => 456])]; + $examples[] = ['#/page?msg=hello+world%3F', Civi::url('backend://civicrm/a/#/page?msg=[hi]')]; + $examples[] = ['a=hello+world%3F&b=Au+re%2Fvoir', Civi::url('frontend://civicrm/user?a=[hi]&b=[bye]')->addVars(['bye' => 'Au re/voir'])]; + $examples[] = ['some_xyz=123', Civi::url('//civicrm/foo?some_[key]=123')->addVars(['key' => 'xyz'])]; + + // Unrecognized []'s are preserved as literals, which allows interop with deep form fields + $examples[] = ['some[key]=123', Civi::url('//civicrm/foo?some[key]=123')]; + + foreach ($examples as $key => $example) { + /** @var \Civi\Core\Url $url */ + [$expected, $url] = $example; + $url->addVars($vars); + $this->assertStringContainsString($expected, (string) $url, sprintf("%s at %d should be have matching output", __FUNCTION__, $key)); + } + } + + public function testFunkyStartPoints(): void { + $baseline = (string) \Civi::url('frontend://civicrm/event/info?id=1'); + $this->assertStringContainsString('event/info', $baseline); + + $alternatives = [ + // Start with nothing! + \Civi::url() + ->setScheme('frontend') + ->setPath(['civicrm', 'event', 'info']) + ->addQuery(['id' => 1]), + + // Start with nothing! And build it backwards! + \Civi::url() + ->addQuery(['id' => 1]) + ->addPath('civicrm')->addPath('event')->addPath('info') + ->setScheme('frontend'), + + // Start with just the scheme + \Civi::url('frontend:') + ->addPath('civicrm/event/info') + ->addQuery('id=1'), + + // Start with just the path + \Civi::url('civicrm/event/info') + ->setScheme('frontend') + ->addQuery(['id' => 1]), + ]; + foreach ($alternatives as $key => $alternative) { + $this->assertEquals($baseline, (string) $alternative, "Alternative #$key should match baseline"); + } + } + +} diff --git a/tests/phpunit/E2E/Core/PathUrlTest.php b/tests/phpunit/E2E/Core/PathUrlTest.php index 2fd0f4c121a0..c08fb5650f17 100644 --- a/tests/phpunit/E2E/Core/PathUrlTest.php +++ b/tests/phpunit/E2E/Core/PathUrlTest.php @@ -68,6 +68,91 @@ public function testPaths_getVariable() { } } + /** + * Get URLs through Civi::url(). + * + * @see \Civi\Core\UrlTest + */ + public function testUrl(): void { + // Make some requests for actual URLs + $this->assertUrlContentRegex(';MIT-LICENSE.txt;', \Civi::url('[civicrm.packages]/jquery/plugins/jquery.timeentry.js', 'a')); + $this->assertUrlContentRegex(';MIT-LICENSE.txt;', \Civi::url('asset://[civicrm.packages]/jquery/plugins/jquery.timeentry.js', 'a')); + $this->assertUrlContentRegex(';Please enter a valid email address;', \Civi::url('assetBuilder://crm-l10n.js?locale=en_US', 'a')); + $this->assertUrlContentRegex(';.module..crmSearchAdmin;', \Civi::url('ext://org.civicrm.search_kit/ang/crmSearchAdmin.module.js', 'a')); + $this->assertUrlContentRegex(';crm-section event_date_time-section;', \Civi::url('frontend://civicrm/event/info?id=1', 'a')); + + // Check for well-formedness of some URLs + $urlPats = []; + switch (CIVICRM_UF) { + case 'Drupal': + case 'Drupal8': + case 'Backdrop': + $urlPats[] = [';/civicrm/event/info\?reset=1&id=9;', \Civi::url('frontend://civicrm/event/info?reset=1')->addQuery('id=9')]; + $urlPats[] = [';/civicrm/admin\?reset=1;', \Civi::url('backend://civicrm/admin')->addQuery(['reset' => 1])]; + break; + + case 'WordPress': + $urlPats[] = [';civiwp=CiviCRM.*civicrm.*event.*info.*reset=1&id=9;', \Civi::url('frontend://civicrm/event/info?reset=1')->addQuery('id=9')]; + $urlPats[] = [';/wp-admin.*civicrm.*admin.*reset=1;', \Civi::url('backend://civicrm/admin?reset=1')]; + break; + + case 'Joomla': + $urlPats[] = [';/index.php\?.*task=civicrm/event/info&reset=1&id=9;', \Civi::url('frontend://civicrm/event/inof?reset=1')->addQuery('id=9')]; + $urlPats[] = [';/administrator/.*task=civicrm/admin/reset=1;', \Civi::url('backend://civicrm/admin')->addQuery('reset=1')]; + break; + + default: + $this->fail('Unrecognized UF: ' . CIVICRM_UF); + } + + $urlPats[] = [';^https?://.*civicrm;', \Civi::url('frontend://civicrm/event/info?reset=1', 'a')]; + $urlPats[] = [';^https://.*civicrm;', \Civi::url('frontend://civicrm/event/info?reset=1', 'as')]; + $urlPats[] = [';civicrm(/|%2F)a(/|%2F).*#/mailing/new\?angularDebug=1;', \Civi::url('backend://civicrm/a/#/mailing/new?angularDebug=1')]; + $urlPats[] = [';/jquery.timeentry.js\?r=.*#foo;', \Civi::url('asset://[civicrm.packages]/jquery/plugins/jquery.timeentry.js', 'c')->addFragment('foo')]; + $urlPats[] = [';/stuff.js\?r=.*#foo;', \Civi::url('ext://org.civicrm.search_kit/stuff.js', 'c')->addFragment('foo')]; + $urlPats[] = [';#foo;', \Civi::url('assetBuilder://crm-l10n.js?locale=en_US')->addFragment('foo')]; + + // Some test-harnesses have HTTP_HOST. Some don't. It's pre-req for truly relative URLs. + if (!empty($_SERVER['HTTP_HOST'])) { + $urlPats[] = [';^/.*civicrm.*ajax.*api4.*Contact.*get;', \Civi::url('backend://civicrm/ajax/api4/Contact/get', 'r')]; + } + + $this->assertNotEmpty($urlPats); + foreach ($urlPats as $urlPat) { + $this->assertRegExp($urlPat[0], $urlPat[1]); + } + } + + /** + * Check that 'frontend://', 'backend://', and 'current://' have the expected relations. + */ + public function testUrl_FrontBackCurrent(): void { + $front = (string) \Civi::url('frontend://civicrm/profile/view'); + $back = (string) \Civi::url('backend://civicrm/profile/view'); + $current = (string) \Civi::url('current://civicrm/profile/view'); + $this->assertStringContainsString('profile', $front); + $this->assertStringContainsString('profile', $back); + $this->assertStringContainsString('profile', $current); + if (CIVICRM_UF === 'WordPress' || CIVICRM_UF === 'Joomla') { + $this->assertNotEquals($front, $back, "On WordPress/Joomla, some URLs should support frontend+backend flavors."); + } + else { + $this->assertEquals($front, $back, "On Drupal/Backdrop/Standalone, frontend and backend URLs should look the same."); + } + $this->assertEquals($back, $current, "Within E2E tests, current routing style is backend."); + // For purposes of this test, it doesn't matter if "current" is frontend or backend - as long as it's consistent. + } + + public function testUrl_DefaultUI(): void { + $adminDefault = (string) \Civi::url('default://civicrm/admin'); + $adminBackend = (string) \Civi::url('backend://civicrm/admin'); + $this->assertEquals($adminBackend, $adminDefault, "civicrm/admin should default to backend"); + + $userDefault = (string) \Civi::url('default://civicrm/user'); + $userBackend = (string) \Civi::url('frontend://civicrm/user'); + $this->assertEquals($userBackend, $userDefault, "civicrm/user should default to frontend"); + } + /** * @param string $expectContentRegex * @param string $url