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