1<?php 2/** 3 * Copyright (c) 2020. ComboStrap, Inc. and its affiliates. All Rights Reserved. 4 * 5 * This source code is licensed under the GPL license found in the 6 * COPYING file in the root directory of this source tree. 7 * 8 * @license GPL 3 (https://www.gnu.org/licenses/gpl-3.0.en.html) 9 * @author ComboStrap <support@combostrap.com> 10 * 11 */ 12 13namespace ComboStrap; 14 15 16use ComboStrap\Meta\Field\PageTemplateName; 17use ComboStrap\TagAttribute\StyleAttribute; 18use Doku_Renderer_xhtml; 19use dokuwiki\Extension\PluginTrait; 20use dokuwiki\Utf8\Conversion; 21use syntax_plugin_combo_link; 22 23 24/** 25 * 26 * @package ComboStrap 27 * 28 * Parse the ref found in a markup link 29 * and return an {@link LinkMarkup::toAttributes()} array for an anchor (a) 30 * with href, style, ... attributes 31 * 32 */ 33class LinkMarkup 34{ 35 36 37 /** 38 * Class added to the type of link 39 * Class have styling rule conflict, they are by default not set 40 * but this configuration permits to turn it back 41 */ 42 const CONF_USE_DOKUWIKI_CLASS_NAME = "useDokuwikiLinkClassName"; 43 44 /** 45 * This configuration will set for all internal link 46 * the {@link LinkMarkup::PREVIEW_ATTRIBUTE} preview attribute 47 */ 48 const CONF_PREVIEW_LINK = "previewLink"; 49 const CONF_PREVIEW_LINK_DEFAULT = 0; 50 51 52 const TEXT_ERROR_CLASS = "text-danger"; 53 54 /** 55 * The known parameters for an email url 56 * The other are styling attribute :) 57 */ 58 const EMAIL_VALID_PARAMETERS = ["subject"]; 59 60 /** 61 * If set, it will show a page preview 62 */ 63 const PREVIEW_ATTRIBUTE = "preview"; 64 65 66 /** 67 * Highlight Key 68 * Adding this property to the internal query will highlight the words 69 * 70 * See {@link html_hilight} 71 */ 72 const SEARCH_HIGHLIGHT_QUERY_PROPERTY = "s"; 73 const DATA_WIKI_ID = "data-wiki-id"; 74 75 /** 76 * For styling on the anchor tag (ie a) 77 */ 78 public const ANCHOR_HTML_SNIPPET_ID = "anchor"; 79 80 /** 81 * Url properties 82 * that are not seen as styling properties 83 * for a page 84 * We could also build the {@link FetcherPage} 85 * and see which attributes were not taken ? 86 */ 87 const PROTECTED_URL_PROPERTY = [ 88 self::SEARCH_HIGHLIGHT_QUERY_PROPERTY, 89 DokuWikiId::DOKUWIKI_ID_ATTRIBUTE, 90 PageTemplateName::PROPERTY_NAME, 91 FetcherPage::PURGE 92 ]; 93 94 95 private MarkupRef $markupRef; 96 97 98 private TagAttributes $stylingAttributes; 99 100 /** 101 * Link constructor. 102 * @param String $ref 103 * @throws ExceptionBadArgument 104 * @throws ExceptionBadSyntax 105 * @throws ExceptionNotFound 106 */ 107 public function __construct(string $ref) 108 { 109 110 $this->stylingAttributes = TagAttributes::createEmpty(syntax_plugin_combo_link::TAG); 111 112 $this->markupRef = MarkupRef::createLinkFromRef($ref); 113 114 $this->collectStylingAttributeInUrl(); 115 116 117 } 118 119 public static function createFromPageIdOrPath($id): LinkMarkup 120 { 121 WikiPath::addRootSeparatorIfNotPresent($id); 122 try { 123 return new LinkMarkup($id); 124 } catch (ExceptionBadArgument|ExceptionBadSyntax|ExceptionNotFound $e) { 125 throw new ExceptionRuntime("Internal error: an id should be a good reference"); 126 } 127 } 128 129 /** 130 * @throws ExceptionBadArgument 131 * @throws ExceptionBadSyntax 132 * @throws ExceptionNotFound 133 */ 134 public static function createFromRef(string $ref): LinkMarkup 135 { 136 return new LinkMarkup($ref); 137 } 138 139 public static function getHtmlClassLocalLink(): string 140 { 141 return "link-local"; 142 } 143 144 145 /** 146 * 147 * @throws ExceptionNotFound 148 */ 149 public function toAttributes(): TagAttributes 150 { 151 152 $outputAttributes = $this->stylingAttributes; 153 154 155 $url = $this->getMarkupRef()->getUrl(); 156 $outputAttributes->addOutputAttributeValue("href", $url->toString()); 157 158 /** 159 * The search term 160 * Code adapted found at {@link Doku_Renderer_xhtml::internallink()} 161 * We can't use the previous {@link wl function} 162 * because it encode too much 163 */ 164 $snippetSystem = PluginUtility::getSnippetManager(); 165 if ($url->hasProperty(self::SEARCH_HIGHLIGHT_QUERY_PROPERTY)) { 166 $snippetSystem->attachCssInternalStyleSheet("search-hit"); 167 } 168 $snippetSystem->attachCssInternalStyleSheet(self::ANCHOR_HTML_SNIPPET_ID); 169 170 global $conf; 171 172 173 /** 174 * Processing by type 175 */ 176 switch ($this->getMarkupRef()->getSchemeType()) { 177 case MarkupRef::INTERWIKI_URI: 178 try { 179 $interWiki = $this->getMarkupRef()->getInterWiki(); 180 } catch (ExceptionNotFound $e) { 181 LogUtility::internalError("The interwiki should be available. We were unable to create the link attributes."); 182 return $outputAttributes; 183 } 184 // normal link for the `this` wiki 185 if ($interWiki->getWiki() !== "this") { 186 $snippetSystem->attachCssInternalStyleSheet(MarkupRef::INTERWIKI_URI); 187 } 188 $cssRules = $interWiki->getDefaultCssRules(); 189 $snippetSystem->attachCssInternalStyleSheet(MarkupRef::INTERWIKI_URI, $cssRules); 190 try { 191 $cssRules = $interWiki->getSpecificCssRules(); 192 $snippetSystem->attachCssInternalStyleSheet(MarkupRef::INTERWIKI_URI . "-" . $interWiki->getWiki(), $cssRules); 193 } catch (ExceptionNotFound $e) { 194 // no media find for the wiki 195 } 196 /** 197 * Target 198 */ 199 $interWikiConf = $conf['target']['interwiki']; 200 if (!empty($interWikiConf)) { 201 $outputAttributes->addOutputAttributeValue('target', $interWikiConf); 202 $outputAttributes->addOutputAttributeValue('rel', 'noopener'); 203 } 204 $outputAttributes->addClassName($interWiki->getComponentClass()); 205 $outputAttributes->addClassName($interWiki->getSubComponentClass()); 206 break; 207 case MarkupRef::WIKI_URI: 208 /** 209 * Derived from {@link Doku_Renderer_xhtml::internallink()} 210 */ 211 // https://www.dokuwiki.org/config:target 212 $target = $conf['target']['wiki']; 213 if (!empty($target)) { 214 $outputAttributes->addOutputAttributeValue('target', $target); 215 } 216 /** 217 * Internal Page 218 */ 219 try { 220 $dokuPath = $this->getMarkupRef()->getPath(); 221 } catch (ExceptionNotFound $e) { 222 throw new ExceptionNotFound("We were unable to process the internal link dokuwiki id on the link. The path was not found. Error: {$e->getMessage()}"); 223 } 224 $page = MarkupPath::createPageFromPathObject($dokuPath); 225 $outputAttributes->addOutputAttributeValue(self::DATA_WIKI_ID, $dokuPath->getWikiId()); 226 227 228 if (!FileSystems::exists($dokuPath)) { 229 230 /** 231 * Red color 232 * if not `do=edit` 233 */ 234 if (!$this->markupRef->getUrl()->hasProperty("do")) { 235 $outputAttributes->addClassName(self::getHtmlClassNotExist()); 236 $outputAttributes->addOutputAttributeValue("rel", 'nofollow'); 237 } 238 239 } else { 240 241 /** 242 * Internal Link Class 243 */ 244 $outputAttributes->addClassName(self::getHtmlClassInternalLink()); 245 246 /** 247 * Link Creation 248 * Do we need to set the title or the tooltip 249 * Processing variables 250 */ 251 $acronym = ""; 252 253 /** 254 * Preview tooltip 255 */ 256 $previewConfig = SiteConfig::getConfValue(self::CONF_PREVIEW_LINK, self::CONF_PREVIEW_LINK_DEFAULT); 257 $preview = $outputAttributes->hasComponentAttributeAndRemove(self::PREVIEW_ATTRIBUTE); 258 if ($preview || $previewConfig === 1) { 259 Tooltip::addToolTipSnippetIfNeeded(); 260 // We use as heading, the name and not the title of the resource because otherwise it would be to lengthy 261 $tooltipHtml = <<<EOF 262<h3>{$page->getNameOrDefault()}</h3> 263<p>{$page->getDescriptionOrElseDokuWiki()}</p> 264EOF; 265 $dataAttributeNamespace = Bootstrap::getDataNamespace(); 266 $outputAttributes->addOutputAttributeValue("data{$dataAttributeNamespace}-toggle", "tooltip"); 267 $outputAttributes->addOutputAttributeValue("data{$dataAttributeNamespace}-placement", "top"); 268 $outputAttributes->addOutputAttributeValue("data{$dataAttributeNamespace}-html", "true"); 269 $outputAttributes->addOutputAttributeValue("title", $tooltipHtml); 270 } 271 272 /** 273 * Low quality Page 274 * (It has a higher priority than preview and 275 * the code comes then after) 276 */ 277 if ($page->isLowQualityPage()) { 278 279 /** 280 * Add a class to style it differently 281 * (the acronym is added to the description, later) 282 */ 283 $acronym = LowQualityPage::LOW_QUALITY_PROTECTION_ACRONYM; 284 $lowerCaseLowQualityAcronym = strtolower(LowQualityPage::LOW_QUALITY_PROTECTION_ACRONYM); 285 $outputAttributes->addClassName(StyleAttribute::addComboStrapSuffix(LowQualityPage::CLASS_SUFFIX)); 286 $snippetLowQualityPageId = $lowerCaseLowQualityAcronym; 287 $snippetSystem->attachCssInternalStyleSheet($snippetLowQualityPageId); 288 /** 289 * Note The protection does occur on Javascript level, not on the HTML 290 * because the created page is valid for a anonymous or logged-in user 291 * Javascript is controlling 292 */ 293 if (LowQualityPage::isProtectionEnabled()) { 294 295 $linkType = LowQualityPage::getLowQualityLinkType(); 296 $outputAttributes->addOutputAttributeValue(PageProtection::DATA_PP_LINK, $linkType); 297 $outputAttributes->addOutputAttributeValue(PageProtection::DATA_PP_SOURCE, $lowerCaseLowQualityAcronym); 298 299 /** 300 * Low Quality Page protection javascript is only for warning or login link 301 */ 302 if (in_array($linkType, [PageProtection::PAGE_PROTECTION_LINK_WARNING, PageProtection::PAGE_PROTECTION_LINK_LOGIN])) { 303 PageProtection::addPageProtectionSnippet(); 304 } 305 306 } 307 } 308 309 /** 310 * Late publication has a higher priority than 311 * the late publication and the is therefore after 312 * (In case this a low quality page late published) 313 */ 314 if ($page->isLatePublication()) { 315 /** 316 * Add a class to style it differently if needed 317 */ 318 $className = StyleAttribute::addComboStrapSuffix(PagePublicationDate::LATE_PUBLICATION_CLASS_PREFIX_NAME); 319 $outputAttributes->addClassName($className); 320 if (PagePublicationDate::isLatePublicationProtectionEnabled()) { 321 $outputAttributes->removeOutputAttributeIfPresent(PageProtection::DATA_PP_LINK); 322 $outputAttributes->removeOutputAttributeIfPresent(PageProtection::DATA_PP_SOURCE); 323 $outputAttributes->addOutputAttributeValue(PageProtection::DATA_PP_LINK, PageProtection::PAGE_PROTECTION_LINK_LOGIN); 324 $acronym = PagePublicationDate::LATE_PUBLICATION_PROTECTION_ACRONYM; 325 $lowerCaseLatePublicationAcronym = strtolower(PagePublicationDate::LATE_PUBLICATION_PROTECTION_ACRONYM); 326 $outputAttributes->addOutputAttributeValue(PageProtection::DATA_PP_SOURCE, $lowerCaseLatePublicationAcronym); 327 PageProtection::addPageProtectionSnippet(); 328 } 329 330 } 331 332 /** 333 * Title (ie tooltip vs title html attribute) 334 */ 335 if (!$outputAttributes->hasAttribute("title")) { 336 337 $description = PageDescription::createForPage($page)->getValueOrDefault(); 338 if (!empty($acronym)) { 339 $description = $description . " ($acronym)"; 340 } 341 $outputAttributes->addOutputAttributeValue("title", $description); 342 343 } 344 345 } 346 347 break; 348 349 case MarkupRef::WINDOWS_SHARE_URI: 350 // https://www.dokuwiki.org/config:target 351 $windowsTarget = $conf['target']['windows']; 352 if (!empty($windowsTarget)) { 353 $outputAttributes->addOutputAttributeValue('target', $windowsTarget); 354 } 355 $outputAttributes->addClassName("windows"); 356 break; 357 case MarkupRef::LOCAL_URI: 358 $outputAttributes->addClassName(self::getHtmlClassLocalLink()); 359 if (!$outputAttributes->hasAttribute("title")) { 360 $description = ucfirst($this->markupRef->getUrl()->getFragment()); 361 if ($description !== "") { 362 $description = str_replace("_", " ", $description); 363 $outputAttributes->addOutputAttributeValue("title", $description); 364 } 365 } 366 break; 367 case MarkupRef::EMAIL_URI: 368 $outputAttributes->addClassName(self::getHtmlClassEmailLink()); 369 /** 370 * An email link is `<email>` 371 * {@link Emaillink::connectTo()} 372 * or 373 * {@link PluginTrait::email() 374 */ 375 // common.php#obfsucate implements the $conf['mailguard'] 376 $uri = $url->getPath(); 377 $uri = $this->obfuscateEmail($uri); 378 $uri = urlencode($uri); 379 $queryParameters = $url->getQueryProperties(); 380 if (sizeof($queryParameters) > 0) { 381 $uri .= "?"; 382 foreach ($queryParameters as $key => $value) { 383 $value = urlencode($value); 384 $key = urlencode($key); 385 if (in_array($key, self::EMAIL_VALID_PARAMETERS)) { 386 $uri .= "$key=$value"; 387 } 388 } 389 } 390 // replace href 391 $outputAttributes->removeOutputAttributeIfPresent("href"); 392 $outputAttributes->addOutputAttributeValue("href", 'mailto:' . $uri); 393 break; 394 case MarkupRef::WEB_URI: 395 /** 396 * It may be a absolute url 397 * that points to the local website 398 * (case of the {@link \syntax_plugin_combo_permalink} 399 */ 400 if ($url->isExternal()) { 401 402 if ($conf['relnofollow']) { 403 $outputAttributes->addOutputAttributeValue("rel", 'nofollow ugc'); 404 } 405 // https://www.dokuwiki.org/config:target 406 $externTarget = $conf['target']['extern']; 407 if (!empty($externTarget)) { 408 $outputAttributes->addOutputAttributeValue('target', $externTarget); 409 $outputAttributes->addOutputAttributeValue("rel", 'noopener'); 410 } 411 /** 412 * Default class for default external link 413 * To not interfere with other external link style 414 * For instance, {@link \syntax_plugin_combo_share} 415 */ 416 $outputAttributes->addClassName(self::getHtmlClassExternalLink()); 417 } 418 break; 419 default: 420 /** 421 * May be any external link 422 * such as {@link \syntax_plugin_combo_share} 423 */ 424 break; 425 426 } 427 428 /** 429 * An email URL and title 430 * may be already encoded because of the vanguard configuration 431 * 432 * The url is not treated as an attribute 433 * because the transformation function encodes the value 434 * to mitigate XSS 435 * 436 */ 437 if ($this->getMarkupRef()->getSchemeType() == MarkupRef::EMAIL_URI) { 438 $emailAddress = $this->obfuscateEmail($this->markupRef->getUrl()->getPath()); 439 $outputAttributes->addOutputAttributeValue("title", $emailAddress); 440 } 441 442 443 /** 444 * Return 445 */ 446 return $outputAttributes; 447 448 449 } 450 451 452 /** 453 * The label inside the anchor tag if there is none 454 * @param false $navigation 455 * @return string 456 * @throws ExceptionNotFound|ExceptionBadArgument 457 * 458 */ 459 public 460 function getDefaultLabel(bool $navigation = false): string 461 { 462 463 switch ($this->getMarkupRef()->getSchemeType()) { 464 case MarkupRef::WIKI_URI: 465 $page = $this->getPage(); 466 if ($navigation) { 467 return ResourceName::createForResource($page)->getValueOrDefault(); 468 } else { 469 return PageTitle::createForMarkup($page)->getValueOrDefault(); 470 } 471 case MarkupRef::EMAIL_URI: 472 global $conf; 473 $email = $this->markupRef->getUrl()->getPath(); 474 switch ($conf['mailguard']) { 475 case 'none' : 476 return $email; 477 case 'visible' : 478 default : 479 $obfuscate = array('@' => ' [at] ', '.' => ' [dot] ', '-' => ' [dash] '); 480 return strtr($email, $obfuscate); 481 } 482 case MarkupRef::INTERWIKI_URI: 483 try { 484 $path = $this->markupRef->getInterWiki()->toUrl()->getPath(); 485 if ($path[0] === "/") { 486 return substr($path, 1); 487 } else { 488 return $path; 489 } 490 } catch (ExceptionBadSyntax|ExceptionNotFound $e) { 491 return "interwiki"; 492 } 493 case MarkupRef::LOCAL_URI: 494 return $this->markupRef->getUrl()->getFragment(); 495 default: 496 return $this->markupRef->getRef(); 497 } 498 } 499 500 501 private 502 function obfuscateEmail($email, $inAttribute = true): string 503 { 504 /** 505 * adapted from {@link obfuscate()} in common.php 506 */ 507 global $conf; 508 509 $mailGuard = $conf['mailguard']; 510 if ($mailGuard === "hex" && $inAttribute) { 511 $mailGuard = "visible"; 512 } 513 switch ($mailGuard) { 514 case 'visible' : 515 $obfuscate = array('@' => ' [at] ', '.' => ' [dot] ', '-' => ' [dash] '); 516 return strtr($email, $obfuscate); 517 518 case 'hex' : 519 return Conversion::toHtml($email, true); 520 521 case 'none' : 522 default : 523 return $email; 524 } 525 } 526 527 528 /** 529 * @return bool 530 * @deprecated should not be here ref does not have the notion of relative 531 */ 532 public 533 function isRelative(): bool 534 { 535 return strpos($this->getMarkupRef()->getRef(), WikiPath::NAMESPACE_SEPARATOR_DOUBLE_POINT) !== 0; 536 } 537 538 public 539 function getMarkupRef(): MarkupRef 540 { 541 return $this->markupRef; 542 } 543 544 545 public 546 static function getHtmlClassInternalLink(): string 547 { 548 $oldClassName = SiteConfig::getConfValue(self::CONF_USE_DOKUWIKI_CLASS_NAME); 549 if ($oldClassName) { 550 return "wikilink1"; 551 } else { 552 return "link-internal"; 553 } 554 } 555 556 public 557 static function getHtmlClassEmailLink(): string 558 { 559 $oldClassName = SiteConfig::getConfValue(self::CONF_USE_DOKUWIKI_CLASS_NAME); 560 if ($oldClassName) { 561 return "mail"; 562 } else { 563 return "link-mail"; 564 } 565 } 566 567 public static function getHtmlClassExternalLink(): string 568 { 569 $oldClassName = SiteConfig::getConfValue(self::CONF_USE_DOKUWIKI_CLASS_NAME); 570 if ($oldClassName) { 571 return "urlextern"; 572 } else { 573 return "link-external"; 574 } 575 } 576 577//FYI: exist in dokuwiki is "wikilink1 but we let the control to the user 578 public 579 static function getHtmlClassNotExist(): string 580 { 581 $oldClassName = SiteConfig::getConfValue(self::CONF_USE_DOKUWIKI_CLASS_NAME); 582 if ($oldClassName) { 583 return "wikilink2"; 584 } else { 585 return self::TEXT_ERROR_CLASS; 586 } 587 } 588 589 public 590 function __toString() 591 { 592 return $this->getMarkupRef()->getRef(); 593 } 594 595 596 /** 597 * @throws ExceptionNotFound 598 */ 599 private 600 function getPage(): MarkupPath 601 { 602 return MarkupPath::createPageFromPathObject($this->getMarkupRef()->getPath()); 603 } 604 605 /** 606 * Styling attribute 607 * may be passed via parameters 608 * for internal link 609 * We don't want the styling attribute 610 * in the URL 611 */ 612 private 613 function collectStylingAttributeInUrl() 614 { 615 616 617 /** 618 * We will not overwrite the parameters if this is an dokuwiki 619 * action link (with the `do` property) 620 */ 621 if ($this->markupRef->getUrl()->hasProperty("do")) { 622 return; 623 } 624 625 /** 626 * Add the attribute from the URL 627 * if this is not a `do` 628 */ 629 switch ($this->markupRef->getSchemeType()) { 630 case MarkupRef::WIKI_URI: 631 foreach ($this->getMarkupRef()->getUrl()->getQueryProperties() as $key => $value) { 632 if (!in_array($key, self::PROTECTED_URL_PROPERTY)) { 633 $this->getMarkupRef()->getUrl()->deleteQueryParameter($key); 634 if (!TagAttributes::isEmptyValue($value)) { 635 $this->stylingAttributes->addComponentAttributeValue($key, $value); 636 } else { 637 $this->stylingAttributes->addEmptyComponentAttributeValue($key); 638 } 639 } 640 } 641 break; 642 case 643 MarkupRef::EMAIL_URI: 644 foreach ($this->getMarkupRef()->getUrl()->getQueryProperties() as $key => $value) { 645 if (!in_array($key, self::EMAIL_VALID_PARAMETERS)) { 646 $this->stylingAttributes->addComponentAttributeValue($key, $value); 647 } 648 } 649 break; 650 } 651 652 } 653 654 /** 655 * @return TagAttributes - the unknown attributes in a url are collected as styling attributes if this not a do query 656 * by {@link LinkMarkup::collectStylingAttributeInUrl()} 657 */ 658 public 659 function getStylingAttributes(): TagAttributes 660 { 661 return $this->stylingAttributes; 662 } 663 664 665} 666