* */ namespace ComboStrap; use Doku_Renderer_metadata; use Doku_Renderer_xhtml; use dokuwiki\Extension\PluginTrait; use dokuwiki\Utf8\Conversion; use syntax_plugin_combo_tooltip; require_once(__DIR__ . '/PluginUtility.php'); /** * * @package ComboStrap * * Parse the ref found in a markup link * and return an XHTML compliant array * with href, style, ... attributes */ class MarkupRef { /** * Type of link */ const INTERWIKI_URI = 'interwiki'; const WINDOWS_SHARE_URI = 'windowsShare'; const WEB_URI = 'external'; const EMAIL_URI = 'email'; const LOCAL_URI = 'local'; const WIKI_URI = 'internal'; const VARIABLE_URI = 'internal_template'; /** * Class added to the type of link * Class have styling rule conflict, they are by default not set * but this configuration permits to turn it back */ const CONF_USE_DOKUWIKI_CLASS_NAME = "useDokuwikiLinkClassName"; /** * This configuration will set for all internal link * the {@link MarkupRef::PREVIEW_ATTRIBUTE} preview attribute */ const CONF_PREVIEW_LINK = "previewLink"; const CONF_PREVIEW_LINK_DEFAULT = 0; const TEXT_ERROR_CLASS = "text-danger"; /** * The known parameters for an email url */ const EMAIL_VALID_PARAMETERS = ["subject"]; /** * If set, it will show a page preview */ const PREVIEW_ATTRIBUTE = "preview"; const PREVIEW_TOOLTIP = "preview"; /** * Highlight Key * Adding this property to the internal query will highlight the words * * See {@link html_hilight} */ const SEARCH_HIGHLIGHT_QUERY_PROPERTY = "s"; /** * @var mixed */ private $uriType; /** * @var mixed */ private $ref; /** * @var Page the internal linked page if the link is an internal one */ private $linkedPage; /** * @var string The value of the title attribute of an anchor */ private $title; /** * The name of the wiki for an inter wiki link * @var string */ private $wiki; /** * * @var false|string */ private $schemeUri; /** * The uri scheme that can be used inside a page * @var array */ private $authorizedSchemes; /** * @var DokuwikiUrl */ private $dokuwikiUrl; /** * @var array|string|null */ private $type; /** * @var array */ private $interwiki; /** * Link constructor. * @param $ref */ public function __construct($ref) { /** * Windows share link */ if ($this->uriType == null) { if (preg_match('/^\\\\\\\\[^\\\\]+?\\\\/u', $ref)) { $this->uriType = self::WINDOWS_SHARE_URI; $this->ref = $ref; return; } } /** * URI like links section with query and fragment */ /** * Local */ if ($this->uriType == null) { if (preg_match('!^#.+!', $ref)) { $this->uriType = self::LOCAL_URI; $this->ref = $ref; } } /** * Email validation pattern * E-Mail (pattern below is defined in inc/mail.php) * * Example: * [[support@combostrap.com?subject=hallo]] * [[support@combostrap.com]] */ if ($this->uriType == null) { $emailRfc2822 = "0-9a-zA-Z!#$%&'*+/=?^_`{|}~-"; $emailPattern = '[' . $emailRfc2822 . ']+(?:\.[' . $emailRfc2822 . ']+)*@(?i:[0-9a-z][0-9a-z-]*\.)+(?i:[a-z]{2,63})'; if (preg_match('<' . $emailPattern . '>', $ref)) { $this->uriType = self::EMAIL_URI; $this->ref = $ref; // we don't return. The query part is parsed afterwards } } /** * External (ie only https) */ if ($this->uriType == null) { /** * Example: `https://` * * Other scheme are not yet recognized * because it can also be a wiki id * For instance, `mailto:` is also a valid page */ if (preg_match('#^([a-z0-9\-\.+]+?)://#i', $ref)) { $this->uriType = self::WEB_URI; $this->schemeUri = strtolower(substr($ref, 0, strpos($ref, ":"))); $this->ref = $ref; } } /** * Interwiki ? */ $refProcessing = $ref; if ($this->uriType == null) { $interwikiPosition = strpos($refProcessing, ">"); if ($interwikiPosition !== false) { $this->wiki = strtolower(substr($refProcessing, 0, $interwikiPosition)); $refProcessing = substr($refProcessing, $interwikiPosition + 1); $this->ref = $ref; $this->uriType = self::INTERWIKI_URI; } } /** * Internal then */ if ($this->uriType == null) { /** * It can be a link with a ref template */ if (TemplateUtility::isVariable($ref)) { $this->uriType = self::VARIABLE_URI; } else { $this->uriType = self::WIKI_URI; } $this->ref = $ref; } /** * Url (called ref by dokuwiki) */ $this->dokuwikiUrl = DokuwikiUrl::createFromUrl($refProcessing); } public static function createFromPageId($id): MarkupRef { return new MarkupRef(":$id"); } public static function createFromRef(string $ref): MarkupRef { return new MarkupRef($ref); } /** * @param $uriType * @return $this */ public function setUriType($uriType): MarkupRef { $this->uriType = $uriType; return $this; } /** * * * * @throws ExceptionCombo */ public function toAttributes($logicalTag = \syntax_plugin_combo_link::TAG): TagAttributes { $outputAttributes = TagAttributes::createEmpty($logicalTag); $type = $this->getUriType(); /** * Add the attribute from the URL * if this is not a `do` */ switch ($type) { case self::WIKI_URI: if (!$this->dokuwikiUrl->hasQueryParameter("do")) { foreach ($this->getDokuwikiUrl()->getQueryParameters() as $key => $value) { if ($key !== self::SEARCH_HIGHLIGHT_QUERY_PROPERTY) { $outputAttributes->addComponentAttributeValue($key, $value); } } } break; case self::EMAIL_URI: foreach ($this->getDokuwikiUrl()->getQueryParameters() as $key => $value) { if (!in_array($key, self::EMAIL_VALID_PARAMETERS)) { $outputAttributes->addComponentAttributeValue($key, $value); } } break; } global $conf; /** * Get the url */ $url = $this->getUrl(); if (!empty($url)) { $outputAttributes->addOutputAttributeValue("href", $url); } /** * Processing by type */ switch ($this->getUriType()) { case self::INTERWIKI_URI: // normal link for the `this` wiki if ($this->getWiki() !== "this") { PluginUtility::getSnippetManager()->attachCssInternalStyleSheetForSlot(self::INTERWIKI_URI); } /** * Target */ $interWikiConf = $conf['target']['interwiki']; if (!empty($interWikiConf)) { $outputAttributes->addOutputAttributeValue('target', $interWikiConf); $outputAttributes->addOutputAttributeValue('rel', 'noopener'); } $outputAttributes->addClassName(self::getHtmlClassInterWikiLink()); $wikiClass = "iw_" . preg_replace('/[^_\-a-z0-9]+/i', '_', $this->getWiki()); $outputAttributes->addClassName($wikiClass); if (!$this->wikiExists()) { $outputAttributes->addClassName(self::getHtmlClassNotExist()); $outputAttributes->addOutputAttributeValue("rel", 'nofollow'); } break; case self::WIKI_URI: /** * Derived from {@link Doku_Renderer_xhtml::internallink()} */ // https://www.dokuwiki.org/config:target $target = $conf['target']['wiki']; if (!empty($target)) { $outputAttributes->addOutputAttributeValue('target', $target); } /** * Internal Page */ $linkedPage = $this->getInternalPage(); $outputAttributes->addOutputAttributeValue("data-wiki-id", $linkedPage->getDokuwikiId()); if (!$linkedPage->exists()) { /** * Red color */ $outputAttributes->addClassName(self::getHtmlClassNotExist()); $outputAttributes->addOutputAttributeValue("rel", 'nofollow'); } else { /** * Internal Link Class */ $outputAttributes->addClassName(self::getHtmlClassInternalLink()); /** * Link Creation * Do we need to set the title or the tooltip * Processing variables */ $acronym = ""; /** * Preview tooltip */ $previewConfig = PluginUtility::getConfValue(self::CONF_PREVIEW_LINK, self::CONF_PREVIEW_LINK_DEFAULT); $preview = $outputAttributes->getBooleanValueAndRemoveIfPresent(self::PREVIEW_ATTRIBUTE, $previewConfig); if ($preview) { Tooltip::addToolTipSnippetIfNeeded(); $tooltipHtml = <<{$linkedPage->getNameOrDefault()}

{$linkedPage->getDescriptionOrElseDokuWiki()}

EOF; $dataAttributeNamespace = Bootstrap::getDataNamespace(); $outputAttributes->addOutputAttributeValue("data{$dataAttributeNamespace}-toggle", "tooltip"); $outputAttributes->addOutputAttributeValue("data{$dataAttributeNamespace}-placement", "top"); $outputAttributes->addOutputAttributeValue("data{$dataAttributeNamespace}-html", "true"); $outputAttributes->addOutputAttributeValue("title", $tooltipHtml); } /** * Low quality Page * (It has a higher priority than preview and * the code comes then after) */ $pageProtectionAcronym = strtolower(PageProtection::ACRONYM); if ($linkedPage->isLowQualityPage()) { /** * Add a class to style it differently * (the acronym is added to the description, later) */ $acronym = LowQualityPage::LOW_QUALITY_PROTECTION_ACRONYM; $lowerCaseLowQualityAcronym = strtolower(LowQualityPage::LOW_QUALITY_PROTECTION_ACRONYM); $outputAttributes->addClassName(LowQualityPage::CLASS_NAME . "-combo"); $snippetLowQualityPageId = $lowerCaseLowQualityAcronym; PluginUtility::getSnippetManager()->attachCssInternalStyleSheetForSlot($snippetLowQualityPageId); /** * Note The protection does occur on Javascript level, not on the HTML * because the created page is valid for a anonymous or logged-in user * Javascript is controlling */ if (LowQualityPage::isProtectionEnabled()) { $linkType = LowQualityPage::getLowQualityLinkType(); $outputAttributes->addOutputAttributeValue("data-$pageProtectionAcronym-link", $linkType); $outputAttributes->addOutputAttributeValue("data-$pageProtectionAcronym-source", $lowerCaseLowQualityAcronym); /** * Low Quality Page protection javascript is only for warning or login link */ if (in_array($linkType, [PageProtection::PAGE_PROTECTION_LINK_WARNING, PageProtection::PAGE_PROTECTION_LINK_LOGIN])) { PageProtection::addPageProtectionSnippet(); } } } /** * Late publication has a higher priority than * the late publication and the is therefore after * (In case this a low quality page late published) */ if ($linkedPage->isLatePublication()) { /** * Add a class to style it differently if needed */ $outputAttributes->addClassName(PagePublicationDate::LATE_PUBLICATION_CLASS_NAME . "-combo"); if (PagePublicationDate::isLatePublicationProtectionEnabled()) { $acronym = PagePublicationDate::LATE_PUBLICATION_PROTECTION_ACRONYM; $lowerCaseLatePublicationAcronym = strtolower(PagePublicationDate::LATE_PUBLICATION_PROTECTION_ACRONYM); $outputAttributes->addOutputAttributeValue("data-$pageProtectionAcronym-link", PageProtection::PAGE_PROTECTION_LINK_LOGIN); $outputAttributes->addOutputAttributeValue("data-$pageProtectionAcronym-source", $lowerCaseLatePublicationAcronym); PageProtection::addPageProtectionSnippet(); } } /** * Title (ie tooltip vs title html attribute) */ if (!$outputAttributes->hasAttribute("title")) { /** * If this is not a link into the same page */ if (!empty($this->getDokuwikiUrl()->getPath())) { $description = $linkedPage->getDescriptionOrElseDokuWiki(); if (empty($description)) { // Rare case $description = $linkedPage->getH1OrDefault(); } if (!empty($acronym)) { $description = $description . " ($acronym)"; } $outputAttributes->addOutputAttributeValue("title", $description); } } } break; case self::WINDOWS_SHARE_URI: // https://www.dokuwiki.org/config:target $windowsTarget = $conf['target']['windows']; if (!empty($windowsTarget)) { $outputAttributes->addOutputAttributeValue('target', $windowsTarget); } $outputAttributes->addClassName("windows"); break; case self::LOCAL_URI: break; case self::EMAIL_URI: $outputAttributes->addClassName(self::getHtmlClassEmailLink()); break; case self::WEB_URI: if ($conf['relnofollow']) { $outputAttributes->addOutputAttributeValue("rel", 'nofollow ugc'); } // https://www.dokuwiki.org/config:target $externTarget = $conf['target']['extern']; if (!empty($externTarget)) { $outputAttributes->addOutputAttributeValue('target', $externTarget); $outputAttributes->addOutputAttributeValue("rel", 'noopener'); } if ($this->type === null) { /** * Default class for default external link * To not interfere with other external link style * For instance, {@link \syntax_plugin_combo_share} */ $outputAttributes->addClassName(self::getHtmlClassExternalLink()); } break; default: /** * May be any external link * such as {@link \syntax_plugin_combo_share} */ break; } /** * An email URL and title * may be already encoded because of the vanguard configuration * * The url is not treated as an attribute * because the transformation function encodes the value * to mitigate XSS * */ if ($this->getUriType() == self::EMAIL_URI) { $emailAddress = $this->obfuscateEmail($this->dokuwikiUrl->getPath()); $outputAttributes->addOutputAttributeValue("title", $emailAddress); } /** * Return */ return $outputAttributes; } /** * Return the type of link from an ID * * @return string a `TYPE_xxx` constant */ public function getUriType(): string { return $this->uriType; } /** * @return Page - the internal page or an error if the link is not an internal one */ public function getInternalPage(): Page { if ($this->linkedPage == null) { if ($this->getUriType() == self::WIKI_URI) { // if there is no path, this is the actual page $pathOrId = $this->dokuwikiUrl->getPath(); $this->linkedPage = Page::createPageFromNonQualifiedPath($pathOrId); } else { throw new \RuntimeException("You can't ask the internal page id from a link that is not an internal one"); } } return $this->linkedPage; } public function getRef() { return $this->ref; } /** * The label inside the anchor tag if there is none * @param false $navigation * @return string|null */ public function getLabel(bool $navigation = false): ?string { switch ($this->getUriType()) { case self::WIKI_URI: if ($navigation) { return $this->getInternalPage()->getNameOrDefault(); } else { return $this->getInternalPage()->getTitleOrDefault(); } case self::EMAIL_URI: global $conf; $email = $this->dokuwikiUrl->getPath(); switch ($conf['mailguard']) { case 'none' : return $email; case 'visible' : default : $obfuscate = array('@' => ' [at] ', '.' => ' [dot] ', '-' => ' [dash] '); return strtr($email, $obfuscate); } case self::INTERWIKI_URI: return $this->dokuwikiUrl->getPath(); case self::LOCAL_URI: return $this->dokuwikiUrl->getFragment(); default: return $this->getRef(); } } /** * @param $title - the value of the title attribute of the anchor */ public function setTitle($title) { $this->title = $title; } /** * @throws ExceptionCombo * @var string $targetEnvironmentAmpersand * By default, all data are encoded * at {@link TagAttributes::encodeToHtmlValue()} * therefore the default is non-encoded * */ public function getUrl() { switch ($this->getUriType()) { case self::WIKI_URI: $page = $this->getInternalPage(); /** * Styling attribute * may be passed via parameters * for internal link * We don't want the styling attribute * in the URL * * We will not overwrite the parameters if this is an dokuwiki * action link (with the `do` property) */ if ($this->dokuwikiUrl->hasQueryParameter("do")) { $absoluteUrl = Site::shouldUrlBeAbsolute(); $url = wl( $page->getDokuwikiId(), $this->dokuwikiUrl->getQueryParameters(), $absoluteUrl ); } else { /** * No parameters by default known */ $url = $page->getCanonicalUrl( [], false ); /** * The search term * Code adapted found at {@link Doku_Renderer_xhtml::internallink()} * We can't use the previous {@link wl function} * because it encode too much */ $searchTerms = $this->dokuwikiUrl->getQueryParameter(self::SEARCH_HIGHLIGHT_QUERY_PROPERTY); if ($searchTerms !== null) { $url .= DokuwikiUrl::AMPERSAND_CHARACTER; PluginUtility::getSnippetManager()->attachCssInternalStyleSheetForSlot("search-hit"); if (is_array($searchTerms)) { /** * To verify, do we really need the [] * to get an array in php ? */ $searchTermsQuery = []; foreach ($searchTerms as $searchTerm) { $searchTermsQuery[] = "s[]=$searchTerm"; } $url .= implode(DokuwikiUrl::AMPERSAND_CHARACTER, $searchTermsQuery); } else { $url .= "s=$searchTerms"; } } } if ($this->dokuwikiUrl->getFragment() != null) { /** * pageutils (transform a fragment in section id) */ $check = false; $url .= '#' . sectionID($this->dokuwikiUrl->getFragment(), $check); } break; case self::INTERWIKI_URI: $wiki = $this->wiki; $extendedPath = $this->dokuwikiUrl->getPath(); if ($this->dokuwikiUrl->getFragment() !== null) { $extendedPath .= "#{$this->dokuwikiUrl->getFragment()}"; } $url = $this->interWikiRefToUrl($wiki, $extendedPath); break; case self::WINDOWS_SHARE_URI: $url = str_replace('\\', '/', $this->getRef()); $url = 'file:///' . $url; break; case self::EMAIL_URI: /** * An email link is `` * {@link Emaillink::connectTo()} * or * {@link PluginTrait::email() */ // common.php#obfsucate implements the $conf['mailguard'] $uri = $this->getDokuwikiUrl()->getPath(); $uri = $this->obfuscateEmail($uri); $uri = urlencode($uri); $queryParameters = $this->getDokuwikiUrl()->getQueryParameters(); if (sizeof($queryParameters) > 0) { $uri .= "?"; foreach ($queryParameters as $key => $value) { $value = urlencode($value); $key = urlencode($key); if (in_array($key, self::EMAIL_VALID_PARAMETERS)) { $uri .= "$key=$value"; } } } $url = 'mailto:' . $uri; break; case self::LOCAL_URI: $check = false; $url = '#' . sectionID($this->ref, $check); break; case self::WEB_URI: /** * Default is external * For instance, {@link \syntax_plugin_combo_share} link */ /** * Authorized scheme only * to not inject code */ if (is_null($this->authorizedSchemes)) { // https://www.dokuwiki.org/urlschemes $this->authorizedSchemes = getSchemes(); $this->authorizedSchemes[] = "whatsapp"; $this->authorizedSchemes[] = "mailto"; } if (!in_array($this->schemeUri, $this->authorizedSchemes)) { throw new ExceptionCombo("The scheme ($this->schemeUri) is not authorized as uri"); } else { $url = $this->ref; } break; case self::VARIABLE_URI: throw new ExceptionCombo("A template variable uri ($this->ref) can not give back an url, it should be first be replaced"); default: throw new ExceptionCombo("The structure of the reference ($this->ref) is unknown"); } return $url; } public function getWiki(): ?string { return $this->wiki; } public function getScheme() { return $this->schemeUri; } private function wikiExists(): bool { $wikis = getInterwiki(); return key_exists($this->wiki, $wikis); } private function obfuscateEmail($email, $inAttribute = true): string { /** * adapted from {@link obfuscate()} in common.php */ global $conf; $mailGuard = $conf['mailguard']; if ($mailGuard === "hex" && $inAttribute) { $mailGuard = "visible"; } switch ($mailGuard) { case 'visible' : $obfuscate = array('@' => ' [at] ', '.' => ' [dot] ', '-' => ' [dash] '); return strtr($email, $obfuscate); case 'hex' : return Conversion::toHtml($email, true); case 'none' : default : return $email; } } public function isRelative(): bool { return strpos($this->path, ':') !== 0; } public function getDokuwikiUrl(): DokuwikiUrl { return $this->dokuwikiUrl; } public static function getHtmlClassInternalLink(): string { $oldClassName = PluginUtility::getConfValue(self::CONF_USE_DOKUWIKI_CLASS_NAME); if ($oldClassName) { return "wikilink1"; } else { return "link-internal"; } } public static function getHtmlClassEmailLink(): string { $oldClassName = PluginUtility::getConfValue(self::CONF_USE_DOKUWIKI_CLASS_NAME); if ($oldClassName) { return "mail"; } else { return "link-mail"; } } public static function getHtmlClassInterWikiLink(): string { $oldClassName = PluginUtility::getConfValue(self::CONF_USE_DOKUWIKI_CLASS_NAME); if ($oldClassName) { return "interwiki"; } else { return "link-interwiki"; } } public static function getHtmlClassExternalLink(): string { $oldClassName = PluginUtility::getConfValue(self::CONF_USE_DOKUWIKI_CLASS_NAME); if ($oldClassName) { return "urlextern"; } else { return "link-external"; } } //FYI: exist in dokuwiki is "wikilink1 but we let the control to the user public static function getHtmlClassNotExist(): string { $oldClassName = PluginUtility::getConfValue(self::CONF_USE_DOKUWIKI_CLASS_NAME); if ($oldClassName) { return "wikilink2"; } else { return self::TEXT_ERROR_CLASS; } } public function __toString() { return $this->ref; } private function getEmailObfuscationConfiguration() { global $conf; return $conf['mailguard']; } /** * @param string $shortcut * @param string $reference * @return mixed|string * Adapted from {@link Doku_Renderer_xhtml::_resolveInterWiki()} * @noinspection DuplicatedCode */ private function interWikiRefToUrl(string &$shortcut, string $reference) { if ($this->interwiki === null) { $this->interwiki = getInterwiki(); } // Get interwiki URL if (isset($this->interwiki[$shortcut])) { $url = $this->interwiki[$shortcut]; } elseif (isset($this->interwiki['default'])) { $shortcut = 'default'; $url = $this->interwiki[$shortcut]; } else { // not parsable interwiki outputs '' to make sure string manipulation works $shortcut = ''; $url = ''; } //split into hash and url part $hash = strrchr($reference, '#'); if ($hash) { $reference = substr($reference, 0, -strlen($hash)); $hash = substr($hash, 1); } //replace placeholder if (preg_match('#\{(URL|NAME|SCHEME|HOST|PORT|PATH|QUERY)\}#', $url)) { //use placeholders $url = str_replace('{URL}', rawurlencode($reference), $url); //wiki names will be cleaned next, otherwise urlencode unsafe chars $url = str_replace('{NAME}', ($url[0] === ':') ? $reference : preg_replace_callback('/[[\\\\\]^`{|}#%]/', function ($match) { return rawurlencode($match[0]); }, $reference), $url); $parsed = parse_url($reference); if (empty($parsed['scheme'])) $parsed['scheme'] = ''; if (empty($parsed['host'])) $parsed['host'] = ''; if (empty($parsed['port'])) $parsed['port'] = 80; if (empty($parsed['path'])) $parsed['path'] = ''; if (empty($parsed['query'])) $parsed['query'] = ''; $url = strtr($url, [ '{SCHEME}' => $parsed['scheme'], '{HOST}' => $parsed['host'], '{PORT}' => $parsed['port'], '{PATH}' => $parsed['path'], '{QUERY}' => $parsed['query'], ]); } else if ($url != '') { // make sure when no url is defined, we keep it null // default $url = $url . rawurlencode($reference); } //handle as wiki links if ($url[0] === ':') { $urlParam = null; $id = $url; if (strpos($url, '?') !== false) { list($id, $urlParam) = explode('?', $url, 2); } $url = wl(cleanID($id), $urlParam); $exists = page_exists($id); } if ($hash) $url .= '#' . rawurlencode($hash); return $url; } }