From b42c6d93f959d042d79030f9de7bbad89b96c91e Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Mon, 17 Jul 2023 03:03:24 -0700 Subject: [PATCH] Implement Civi::url() --- Civi.php | 58 ++++++++ Civi/Core/Url.php | 353 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 411 insertions(+) create mode 100644 Civi/Core/Url.php diff --git a/Civi.php b/Civi.php index d582bba2b0..83a264cb94 100644 --- a/Civi.php +++ b/Civi.php @@ -218,4 +218,62 @@ class Civi { return \Civi\Core\Container::getBootService('settings_manager')->getBagByDomain($domainID); } + /** + * Construct a URL based on a logical service address. + * + * Ex: Link to constituent's dashboard (on frontend UI) + * $url = Civi::url('frontend://civicrm/user?reset=1'); + * + * Ex: Link to constituent's dashboard (on frontend UI or backend UI -- whatever matches current page-view) + * $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 an extension. + * $url = Civi::url('ext://org.civicrm.search_kit/css/crmSearchTasks.css'); + * + * NOTE: CiviCRM is integrated into many environments, and they handle URL-construction different ways. + * For example, in Joomla+WordPress, there are separate sub-applications for the public-facing + * frontend UI (`/`) and the staff-facing backend UI (`/wp-admin/` or `/administrator/`) -- each follows + * a different URL-formula. But in Drupal, all use the same formula. To + * + * @param string $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) + * - 'assetBuilder://' (Dynamically-generated asset-file) + * - '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 + * - 'r': relative + * - 'h': html + * - 'p': plain text + * - 's': ssl + * FIXME: Should we have a flag for appending 'resCacheCode'? + * @return \Civi\Core\Url + * URL object which may be modified or rendered as text. + */ + public static function url(string $logicalUri, ?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 0000000000..009c94eac8 --- /dev/null +++ b/Civi/Core/Url.php @@ -0,0 +1,353 @@ +scheme = $parsed['scheme'] ?? NULL; + $this->path = $parsed['host'] ?? NULL; + if (isset($parsed['path'])) { + $this->path .= $parsed['path']; + } + $this->query = $parsed['query'] ?? NULL; + $this->fragment = $parsed['fragment'] ?? NULL; + + if ($flags !== NULL) { + $this->useFlags($flags); + } + } + + /** + * @return string + */ + public function getScheme() { + return $this->scheme; + } + + /** + * @param string $scheme + */ + public function setScheme(string $scheme): Url { + $this->scheme = $scheme; + return $this; + } + + /** + * @return mixed + */ + public function getPath() { + return $this->path; + } + + /** + * @param string $path + */ + public function setPath(string $path): Url { + $this->path = $path; + return $this; + } + + /** + * @param string|string[] $pathParts + * @return $this + */ + public function addPath($pathParts): Url { + $suffix = implode('/', (array) $pathParts); + if ($this->path === NULL) { + $this->path = $suffix; + } + else { + $this->path = rtrim($this->path, '/') . '/' . $suffix; + } + return $this; + } + + /** + * @return string|null + */ + public function getQuery(): ?string { + return $this->query; + } + + /** + * @param string|array|null $query + */ + public function setQuery($query): Url { + $this->query = \CRM_Utils_System::makeQueryString($query); + return $this; + } + + /** + * @param string|array $query + * @return $this + */ + public function addQuery($query): Url { + if ($this->query === NULL) { + $this->query = \CRM_Utils_System::makeQueryString($query); + } + else { + $this->query .= '&' . \CRM_Utils_System::makeQueryString($query); + } + return $this; + } + + /** + * @return string|null + */ + public function getFragment(): ?string { + return $this->fragment; + } + + /** + * @param string|null $fragment + */ + public function setFragment(?string $fragment): Url { + $this->fragment = \CRM_Utils_System::makeQueryString($fragment); + return $this; + } + + /** + * @param string|array $fragment + * @return $this + */ + public function addFragment($fragment): Url { + if ($this->fragment === NULL) { + $this->fragment = \CRM_Utils_System::makeQueryString($fragment); + } + else { + $this->fragment .= '&' . \CRM_Utils_System::makeQueryString($fragment); + } + return $this; + } + + /** + * @return string|null + * 'relative' or 'absolute' + */ + public function getPreferFormat(): ?string { + return $this->preferFormat; + } + + /** + * @param string|null $preferFormat + */ + public function setPreferFormat(?string $preferFormat): Url { + $this->preferFormat = $preferFormat; + return $this; + } + + /** + * @return bool + */ + public function getHtmlEscape(): bool { + return $this->htmlEscape; + } + + /** + * @param bool $htmlEscape + */ + public function setHtmlEscape(bool $htmlEscape): Url { + $this->htmlEscape = $htmlEscape; + return $this; + } + + /** + * @return bool|null + */ + public function getSsl(): ?bool { + return $this->ssl; + } + + /** + * @param bool|null $ssl + */ + public function setSsl(?bool $ssl): Url { + $this->ssl = $ssl; + return $this; + } + + /** + * @param string $flags + * A series of flag-letters. Any of the following: + * - [a]bsolute + * - [r]elative + * - [h]tml + * - [s]sl + * @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; + + // (p)lain text encoding + case 'p': + $this->htmlEscape = FALSE; + break; + + // (s)sl + case 's'; + $this->ssl = TRUE; + break; + } + } + return $this; + } + + /** + * Render the final URL as a string. + * + * @return string + */ + public function __toString(): string { + $userSystem = \CRM_Core_Config::singleton()->userSystem; + $scheme = $this->getScheme(); + $preferFormat = $this->getPreferFormat(); + + // Translate subjective values to real values. + switch ($scheme) { + case 'current': + $preferFormat = $preferFormat ?: 'relative'; + $scheme = $userSystem->isFrontEndPage() ? 'frontend' : 'backend'; + // The current call could actually be a 'service' request, but we treat those as equivalent to 'frontend', so maybe it doesn't matter. + break; + + case 'default': + // $preferFormat = $preferFormat ?: 'absolute'; + // TODO pick $scheme = 'frontend' or 'backend' or 'service'; + throw new \RuntimeException("FIXME: Implement lookup for default "); + + default: + $preferFormat = $preferFormat ?: 'absolute'; + } + + switch ($scheme) { + case 'frontend': + case 'service': + $result = $userSystem->url($this->getPath(), $this->getQuery(), $preferFormat === 'absolute', $this->getFragment(), TRUE, FALSE, FALSE); + break; + + case 'backend': + $result = $userSystem->url($this->getPath(), $this->getQuery(), $preferFormat === 'absolute', $this->getFragment(), FALSE, TRUE, FALSE); + break; + + case 'assetBuilder': + $assetName = $this->getPath(); + $assetParams = []; + parse_str('' . $this->getQuery(), $assetParams); + $result = \Civi::service('asset_builder')->getUrl($assetName, $assetParams); + break; + + case 'ext': + $parts = explode('/', $this->getPath(), 2); + $result = \Civi::resources()->getUrl($parts[0], $parts[1] ?? NULL, FALSE); + if ($this->query) { + $result .= '?' . $this->query; + } + if ($this->fragment) { + $result .= '#' . $this->fragment; + } + break; + + default: + throw new \RuntimeException("Unknown URL scheme: {$this->getScheme()}"); + } + + // 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); + } + + return $this->htmlEscape ? htmlentities($result) : $result; + } + +} -- 2.25.1