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                 * Preview, we add it here because even if the file does not exist
229                 * we need to delete this attribute so that it's not in the HTML
230                 */
231                $previewConfig = SiteConfig::getConfValue(self::CONF_PREVIEW_LINK, self::CONF_PREVIEW_LINK_DEFAULT);
232                $preview = $outputAttributes->getBooleanValueAndRemoveIfPresent(self::PREVIEW_ATTRIBUTE, $previewConfig);
233
234                if (!FileSystems::exists($dokuPath)) {
235
236                    /**
237                     * Red color
238                     * if not `do=edit`
239                     */
240                    if (!$this->markupRef->getUrl()->hasProperty("do")) {
241                        $outputAttributes->addClassName(self::getHtmlClassNotExist());
242                        $outputAttributes->addOutputAttributeValue("rel", 'nofollow');
243                    }
244
245                } else {
246
247                    /**
248                     * Internal Link Class
249                     */
250                    $outputAttributes->addClassName(self::getHtmlClassInternalLink());
251
252                    /**
253                     * Link Creation
254                     * Do we need to set the title or the tooltip
255                     * Processing variables
256                     */
257                    $acronym = "";
258
259                    /**
260                     * Preview tooltip
261                     */
262                    if ($preview) {
263                        Tooltip::addToolTipSnippetIfNeeded();
264                        // We use as heading, the name and not the title of the resource because otherwise it would be to lengthy
265                        $tooltipHtml = <<<EOF
266<h3>{$page->getNameOrDefault()}</h3>
267<p>{$page->getDescriptionOrElseDokuWiki()}</p>
268EOF;
269                        $dataAttributeNamespace = Bootstrap::getDataNamespace();
270                        $outputAttributes->addOutputAttributeValue("data{$dataAttributeNamespace}-toggle", "tooltip");
271                        $outputAttributes->addOutputAttributeValue("data{$dataAttributeNamespace}-placement", "top");
272                        $outputAttributes->addOutputAttributeValue("data{$dataAttributeNamespace}-html", "true");
273                        $outputAttributes->addOutputAttributeValue("title", $tooltipHtml);
274                    }
275
276                    /**
277                     * Low quality Page
278                     * (It has a higher priority than preview and
279                     * the code comes then after)
280                     */
281                    if ($page->isLowQualityPage()) {
282
283                        /**
284                         * Add a class to style it differently
285                         * (the acronym is added to the description, later)
286                         */
287                        $acronym = LowQualityPage::LOW_QUALITY_PROTECTION_ACRONYM;
288                        $lowerCaseLowQualityAcronym = strtolower(LowQualityPage::LOW_QUALITY_PROTECTION_ACRONYM);
289                        $outputAttributes->addClassName(StyleAttribute::addComboStrapSuffix(LowQualityPage::CLASS_SUFFIX));
290                        $snippetLowQualityPageId = $lowerCaseLowQualityAcronym;
291                        $snippetSystem->attachCssInternalStyleSheet($snippetLowQualityPageId);
292                        /**
293                         * Note The protection does occur on Javascript level, not on the HTML
294                         * because the created page is valid for a anonymous or logged-in user
295                         * Javascript is controlling
296                         */
297                        if (LowQualityPage::isProtectionEnabled()) {
298
299                            $linkType = LowQualityPage::getLowQualityLinkType();
300                            $outputAttributes->addOutputAttributeValue(PageProtection::DATA_PP_LINK, $linkType);
301                            $outputAttributes->addOutputAttributeValue(PageProtection::DATA_PP_SOURCE, $lowerCaseLowQualityAcronym);
302
303                            /**
304                             * Low Quality Page protection javascript is only for warning or login link
305                             */
306                            if (in_array($linkType, [PageProtection::PAGE_PROTECTION_LINK_WARNING, PageProtection::PAGE_PROTECTION_LINK_LOGIN])) {
307                                PageProtection::addPageProtectionSnippet();
308                            }
309
310                        }
311                    }
312
313                    /**
314                     * Late publication has a higher priority than
315                     * the late publication and the is therefore after
316                     * (In case this a low quality page late published)
317                     */
318                    if ($page->isLatePublication()) {
319                        /**
320                         * Add a class to style it differently if needed
321                         */
322                        $className = StyleAttribute::addComboStrapSuffix(PagePublicationDate::LATE_PUBLICATION_CLASS_PREFIX_NAME);
323                        $outputAttributes->addClassName($className);
324                        if (PagePublicationDate::isLatePublicationProtectionEnabled()) {
325                            $outputAttributes->removeOutputAttributeIfPresent(PageProtection::DATA_PP_LINK);
326                            $outputAttributes->removeOutputAttributeIfPresent(PageProtection::DATA_PP_SOURCE);
327                            $outputAttributes->addOutputAttributeValue(PageProtection::DATA_PP_LINK, PageProtection::PAGE_PROTECTION_LINK_LOGIN);
328                            $acronym = PagePublicationDate::LATE_PUBLICATION_PROTECTION_ACRONYM;
329                            $lowerCaseLatePublicationAcronym = strtolower(PagePublicationDate::LATE_PUBLICATION_PROTECTION_ACRONYM);
330                            $outputAttributes->addOutputAttributeValue(PageProtection::DATA_PP_SOURCE, $lowerCaseLatePublicationAcronym);
331                            PageProtection::addPageProtectionSnippet();
332                        }
333
334                    }
335
336                    /**
337                     * Title (ie tooltip vs title html attribute)
338                     */
339                    if (!$outputAttributes->hasAttribute("title")) {
340
341                        $description = PageDescription::createForPage($page)->getValueOrDefault();
342                        if (!empty($acronym)) {
343                            $description = $description . " ($acronym)";
344                        }
345                        $outputAttributes->addOutputAttributeValue("title", $description);
346
347                    }
348
349                }
350
351                break;
352
353            case MarkupRef::WINDOWS_SHARE_URI:
354                // https://www.dokuwiki.org/config:target
355                $windowsTarget = $conf['target']['windows'];
356                if (!empty($windowsTarget)) {
357                    $outputAttributes->addOutputAttributeValue('target', $windowsTarget);
358                }
359                $outputAttributes->addClassName("windows");
360                break;
361            case MarkupRef::LOCAL_URI:
362                $outputAttributes->addClassName(self::getHtmlClassLocalLink());
363                if (!$outputAttributes->hasAttribute("title")) {
364                    $description = ucfirst($this->markupRef->getUrl()->getFragment());
365                    if ($description !== "") {
366                        $description = str_replace("_", " ", $description);
367                        $outputAttributes->addOutputAttributeValue("title", $description);
368                    }
369                }
370                break;
371            case MarkupRef::EMAIL_URI:
372                $outputAttributes->addClassName(self::getHtmlClassEmailLink());
373                /**
374                 * An email link is `<email>`
375                 * {@link Emaillink::connectTo()}
376                 * or
377                 * {@link PluginTrait::email()
378                 */
379                // common.php#obfsucate implements the $conf['mailguard']
380                $uri = $url->getPath();
381                $uri = $this->obfuscateEmail($uri);
382                $uri = urlencode($uri);
383                $queryParameters = $url->getQueryProperties();
384                if (sizeof($queryParameters) > 0) {
385                    $uri .= "?";
386                    foreach ($queryParameters as $key => $value) {
387                        $value = urlencode($value);
388                        $key = urlencode($key);
389                        if (in_array($key, self::EMAIL_VALID_PARAMETERS)) {
390                            $uri .= "$key=$value";
391                        }
392                    }
393                }
394                // replace href
395                $outputAttributes->removeOutputAttributeIfPresent("href");
396                $outputAttributes->addOutputAttributeValue("href", 'mailto:' . $uri);
397                break;
398            case MarkupRef::WEB_URI:
399                /**
400                 * It may be a absolute url
401                 * that points to the local website
402                 * (case of the {@link \syntax_plugin_combo_permalink}
403                 */
404                if ($url->isExternal()) {
405
406                    if ($conf['relnofollow']) {
407                        $outputAttributes->addOutputAttributeValue("rel", 'nofollow ugc');
408                    }
409                    // https://www.dokuwiki.org/config:target
410                    $externTarget = $conf['target']['extern'];
411                    if (!empty($externTarget)) {
412                        $outputAttributes->addOutputAttributeValue('target', $externTarget);
413                        $outputAttributes->addOutputAttributeValue("rel", 'noopener');
414                    }
415                    /**
416                     * Default class for default external link
417                     * To not interfere with other external link style
418                     * For instance, {@link \syntax_plugin_combo_share}
419                     */
420                    $outputAttributes->addClassName(self::getHtmlClassExternalLink());
421                }
422                break;
423            default:
424                /**
425                 * May be any external link
426                 * such as {@link \syntax_plugin_combo_share}
427                 */
428                break;
429
430        }
431
432        /**
433         * An email URL and title
434         * may be already encoded because of the vanguard configuration
435         *
436         * The url is not treated as an attribute
437         * because the transformation function encodes the value
438         * to mitigate XSS
439         *
440         */
441        if ($this->getMarkupRef()->getSchemeType() == MarkupRef::EMAIL_URI) {
442            $emailAddress = $this->obfuscateEmail($this->markupRef->getUrl()->getPath());
443            $outputAttributes->addOutputAttributeValue("title", $emailAddress);
444        }
445
446
447        /**
448         * Return
449         */
450        return $outputAttributes;
451
452
453    }
454
455
456    /**
457     * The label inside the anchor tag if there is none
458     * @param false $navigation
459     * @return string
460     * @throws ExceptionNotFound|ExceptionBadArgument
461     *
462     */
463    public
464    function getDefaultLabel(bool $navigation = false): string
465    {
466
467        switch ($this->getMarkupRef()->getSchemeType()) {
468            case MarkupRef::WIKI_URI:
469                $page = $this->getPage();
470                if ($navigation) {
471                    return ResourceName::createForResource($page)->getValueOrDefault();
472                } else {
473                    return PageTitle::createForMarkup($page)->getValueOrDefault();
474                }
475            case MarkupRef::EMAIL_URI:
476                global $conf;
477                $email = $this->markupRef->getUrl()->getPath();
478                switch ($conf['mailguard']) {
479                    case 'none' :
480                        return $email;
481                    case 'visible' :
482                    default :
483                        $obfuscate = array('@' => ' [at] ', '.' => ' [dot] ', '-' => ' [dash] ');
484                        return strtr($email, $obfuscate);
485                }
486            case MarkupRef::INTERWIKI_URI:
487                try {
488                    $path = $this->markupRef->getInterWiki()->toUrl()->getPath();
489                    if ($path[0] === "/") {
490                        return substr($path, 1);
491                    } else {
492                        return $path;
493                    }
494                } catch (ExceptionBadSyntax|ExceptionNotFound $e) {
495                    return "interwiki";
496                }
497            case MarkupRef::LOCAL_URI:
498                return $this->markupRef->getUrl()->getFragment();
499            default:
500                return $this->markupRef->getRef();
501        }
502    }
503
504
505    private
506    function obfuscateEmail($email, $inAttribute = true): string
507    {
508        /**
509         * adapted from {@link obfuscate()} in common.php
510         */
511        global $conf;
512
513        $mailGuard = $conf['mailguard'];
514        if ($mailGuard === "hex" && $inAttribute) {
515            $mailGuard = "visible";
516        }
517        switch ($mailGuard) {
518            case 'visible' :
519                $obfuscate = array('@' => ' [at] ', '.' => ' [dot] ', '-' => ' [dash] ');
520                return strtr($email, $obfuscate);
521
522            case 'hex' :
523                return Conversion::toHtml($email, true);
524
525            case 'none' :
526            default :
527                return $email;
528        }
529    }
530
531
532    /**
533     * @return bool
534     * @deprecated should not be here ref does not have the notion of relative
535     */
536    public
537    function isRelative(): bool
538    {
539        return strpos($this->getMarkupRef()->getRef(), WikiPath::NAMESPACE_SEPARATOR_DOUBLE_POINT) !== 0;
540    }
541
542    public
543    function getMarkupRef(): MarkupRef
544    {
545        return $this->markupRef;
546    }
547
548
549    public
550    static function getHtmlClassInternalLink(): string
551    {
552        $oldClassName = SiteConfig::getConfValue(self::CONF_USE_DOKUWIKI_CLASS_NAME);
553        if ($oldClassName) {
554            return "wikilink1";
555        } else {
556            return "link-internal";
557        }
558    }
559
560    public
561    static function getHtmlClassEmailLink(): string
562    {
563        $oldClassName = SiteConfig::getConfValue(self::CONF_USE_DOKUWIKI_CLASS_NAME);
564        if ($oldClassName) {
565            return "mail";
566        } else {
567            return "link-mail";
568        }
569    }
570
571    public static function getHtmlClassExternalLink(): string
572    {
573        $oldClassName = SiteConfig::getConfValue(self::CONF_USE_DOKUWIKI_CLASS_NAME);
574        if ($oldClassName) {
575            return "urlextern";
576        } else {
577            return "link-external";
578        }
579    }
580
581//FYI: exist in dokuwiki is "wikilink1 but we let the control to the user
582    public
583    static function getHtmlClassNotExist(): string
584    {
585        $oldClassName = SiteConfig::getConfValue(self::CONF_USE_DOKUWIKI_CLASS_NAME);
586        if ($oldClassName) {
587            return "wikilink2";
588        } else {
589            return self::TEXT_ERROR_CLASS;
590        }
591    }
592
593    public
594    function __toString()
595    {
596        return $this->getMarkupRef()->getRef();
597    }
598
599
600    /**
601     * @throws ExceptionNotFound
602     */
603    private
604    function getPage(): MarkupPath
605    {
606        return MarkupPath::createPageFromPathObject($this->getMarkupRef()->getPath());
607    }
608
609    /**
610     * Styling attribute
611     * may be passed via parameters
612     * for internal link
613     * We don't want the styling attribute
614     * in the URL
615     */
616    private
617    function collectStylingAttributeInUrl()
618    {
619
620
621        /**
622         * We will not overwrite the parameters if this is an dokuwiki
623         * action link (with the `do` property)
624         */
625        if ($this->markupRef->getUrl()->hasProperty("do")) {
626            return;
627        }
628
629        /**
630         * Add the attribute from the URL
631         * if this is not a `do`
632         */
633        switch ($this->markupRef->getSchemeType()) {
634            case MarkupRef::WIKI_URI:
635                foreach ($this->getMarkupRef()->getUrl()->getQueryProperties() as $key => $value) {
636                    if (!in_array($key, self::PROTECTED_URL_PROPERTY)) {
637                        $this->getMarkupRef()->getUrl()->deleteQueryParameter($key);
638                        if (!TagAttributes::isEmptyValue($value)) {
639                            $this->stylingAttributes->addComponentAttributeValue($key, $value);
640                        } else {
641                            $this->stylingAttributes->addEmptyComponentAttributeValue($key);
642                        }
643                    }
644                }
645                break;
646            case
647            MarkupRef::EMAIL_URI:
648                foreach ($this->getMarkupRef()->getUrl()->getQueryProperties() as $key => $value) {
649                    if (!in_array($key, self::EMAIL_VALID_PARAMETERS)) {
650                        $this->stylingAttributes->addComponentAttributeValue($key, $value);
651                    }
652                }
653                break;
654        }
655
656    }
657
658    /**
659     * @return TagAttributes - the unknown attributes in a url are collected as styling attributes if this not a do query
660     * by {@link LinkMarkup::collectStylingAttributeInUrl()}
661     */
662    public
663    function getStylingAttributes(): TagAttributes
664    {
665        return $this->stylingAttributes;
666    }
667
668
669}
670