Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.67% covered (success)
96.67%
116 / 120
80.00% covered (warning)
80.00%
8 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
HtmlUtils
96.67% covered (success)
96.67%
116 / 120
80.00% covered (warning)
80.00%
8 / 10
30
0.00% covered (danger)
0.00%
0 / 1
 escapeMarkdown
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 renderMarkdown
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 postprocess
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 replaceEmailAdresses
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
2
 replaceMailtoLink
91.89% covered (success)
91.89%
34 / 37
0.00% covered (danger)
0.00%
0 / 1
12.08
 replaceNodeWithHtml
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getDOMNodeInnerHtml
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 getDOMNode
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getImageSrcHtml
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
3
 getLinkedReactions
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3namespace Olz\Utils;
4
5use League\CommonMark\Environment\Environment;
6use League\CommonMark\Extension\Attributes\AttributesExtension;
7use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
8use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
9use League\CommonMark\MarkdownConverter;
10use Olz\Captcha\Components\OlzEmailModal\OlzEmailModal;
11use Olz\Entity\Roles\Role;
12use Olz\Roles\Components\OlzRoleInfoModal\OlzRoleInfoModal;
13
14class HtmlUtils {
15    use WithUtilsTrait;
16
17    public string $subject_regex = '\?subject=([^\'"]*)';
18    public string $olz_email_regex = '';
19    public string $email_regex = '([A-Z0-9a-z._%+-]+)@([A-Za-z0-9.-]+\.[A-Za-z]{2,64})';
20
21    public function escapeMarkdown(string $text): string {
22        $esc_text = preg_replace('/([\`\*\_\{\}\[\]\(\)\#\+\-\.\!\\\])/', '\\\$1', $text);
23        $this->generalUtils()->checkNotNull($esc_text, 'esc_text must not be null');
24        return $esc_text;
25    }
26
27    /** @param array<string, mixed> $override_config */
28    public function renderMarkdown(string $markdown, array $override_config = []): string {
29        $default_config = [
30            'html_input' => 'escape',
31            'allow_unsafe_links' => false,
32            'max_nesting_level' => 100,
33        ];
34        $config = array_merge($default_config, $override_config);
35
36        $environment = new Environment($config);
37        $environment->addExtension(new CommonMarkCoreExtension());
38        $environment->addExtension(new GithubFlavoredMarkdownExtension());
39        $environment->addExtension(new AttributesExtension());
40        $converter = new MarkdownConverter($environment);
41        $rendered = $converter->convert($markdown);
42        $postprocessed = $this->postprocess(strval($rendered));
43        return "<div class='rendered-markdown'>{$postprocessed}</div>";
44    }
45
46    public function postprocess(string $html): string {
47        return $this->replaceEmailAdresses($html);
48    }
49
50    public function replaceEmailAdresses(string $html): string {
51        $host = $this->envUtils()->getEmailForwardingHost();
52        $esc_host = preg_quote($host);
53        $this->olz_email_regex = '([A-Z0-9a-z._%+-]+)@'.$esc_host;
54
55        $html = preg_replace(
56            "/(\\s|^){$this->email_regex}([\\s,\\.!\\?]|$)/",
57            "\$1<a href='mailto:\$2@\$3'></a>\$4",
58            $html
59        );
60        $this->generalUtils()->checkNotNull($html, "String replacement failed");
61
62        $doc = \DOM\HTMLDocument::createFromString(
63            "<div id='root'>{$html}</div>",
64            LIBXML_NOERROR
65        );
66        $links = [...$doc->getElementsByTagName('a')];
67        foreach ($links as $link) {
68            $this->replaceMailtoLink($link);
69        }
70        $root = $doc->getElementById('root');
71        $this->generalUtils()->checkNotNull($root, 'Root not found anymore');
72        return $this->getDOMNodeInnerHtml($root);
73    }
74
75    protected function replaceMailtoLink(\DOM\Element $link): void {
76        $href_attr = $link->attributes->getNamedItem('href');
77        if (!$href_attr) {
78            return;
79        }
80        $olz_email_pattern = "/^mailto:{$this->olz_email_regex}(?:{$this->subject_regex})?\$/";
81        $is_olz_email = preg_match($olz_email_pattern, $href_attr->value, $matches);
82        if ($is_olz_email) {
83            $username = $matches[1];
84            // Note: Subject remains unused
85            $role_repo = $this->entityManager()->getRepository(Role::class);
86            $role = $role_repo->findOneBy(['username' => $username, 'on_off' => 1]);
87            if (!$role) {
88                $role = $role_repo->findOneBy(['old_username' => $username, 'on_off' => 1]);
89                if ($role) {
90                    $this->log()->notice("Old username {$role->getOldUsername()} of Role {$role->getId()} is still used. Use {$role->getUsername()} instead!");
91                }
92            }
93            if ($role) {
94                $text = $this->getDOMNodeInnerHtml($link) ?: null;
95                if (preg_match("/{$this->email_regex}/", $text ?? '')) {
96                    $text = null;
97                }
98                $this->replaceNodeWithHtml($link, OlzRoleInfoModal::render([
99                    'role' => $role,
100                    'text' => $text,
101                ]));
102                return;
103            }
104        }
105        $email_pattern = "/^mailto:{$this->email_regex}(?:{$this->subject_regex})?\$/";
106        $is_email = preg_match($email_pattern, $href_attr->value, $matches);
107        if ($is_email) {
108            $username = $matches[1];
109            $domain = $matches[2];
110            $subject = $matches[3] ?? null;
111            $text = $this->getDOMNodeInnerHtml($link) ?: null;
112            if (preg_match("/{$this->email_regex}/", $text ?? '')) {
113                $text = null;
114            }
115            $this->replaceNodeWithHtml($link, OlzEmailModal::render([
116                'email' => "{$username}@{$domain}",
117                'text' => $text,
118                'subject' => $subject ?: null,
119            ]));
120            return;
121        }
122    }
123
124    protected function replaceNodeWithHtml(\DOM\Element $old, string $new): void {
125        $replacement_nodes = $this->getDOMNode($new)->childNodes ?? [];
126        $doc = $old->ownerDocument;
127        $this->generalUtils()->checkNotNull($doc, 'No owner doc');
128        foreach ($replacement_nodes as $replacement_node) {
129            $imported_node = $doc->importNode($replacement_node, true);
130            $old->parentNode?->insertBefore($imported_node, $old);
131        }
132        $old->parentNode?->removeChild($old);
133    }
134
135    protected function getDOMNodeInnerHtml(\DOM\Element $node): string {
136        $innerHTML = "";
137        $doc = $node->ownerDocument;
138        if (!$doc instanceof \DOM\HTMLDocument) {
139            throw new \Exception('No owner HTML doc');
140        }
141        $children = $node->childNodes;
142        foreach ($children as $child) {
143            $innerHTML .= $doc->saveHTML($child) ?: '';
144        }
145        return $innerHTML;
146    }
147
148    protected function getDOMNode(string $html): ?\DOM\Node {
149        $tmp_doc = \DOM\HTMLDocument::createFromString(
150            "<div id='root'>{$html}</div>",
151            LIBXML_NOERROR
152        );
153        return $tmp_doc->getElementById('root');
154    }
155
156    /** @param array<string, string> $image_hrefs */
157    public function getImageSrcHtml(array $image_hrefs): string {
158        $keys = array_keys($image_hrefs);
159        if (count($keys) < 1) {
160            return '';
161        }
162        $default_src = $image_hrefs['1x'] ?? $image_hrefs[$keys[0]];
163        if (count($keys) < 2) {
164            return <<<ZZZZZZZZZZ
165                src='{$default_src}'
166                ZZZZZZZZZZ;
167        }
168        $srcset = implode(",\n    ", array_map(function ($key) use ($image_hrefs) {
169            $value = $image_hrefs[$key];
170            return "{$value} {$key}";
171        }, $keys));
172        return <<<ZZZZZZZZZZ
173            srcset='
174                {$srcset}
175            '
176            src='{$default_src}'
177            ZZZZZZZZZZ;
178    }
179
180    /** @return array<non-empty-string> */
181    public function getLinkedReactions(string $html): array {
182        $num_matches = preg_match_all('/#react-([^\"\']+)[\"\']/u', $html, $matches);
183        $count_by_reaction = [];
184        for ($i = 0; $i < $num_matches ?: 0; $i++) {
185            $emoji = urldecode($matches[1][$i]);
186            $count_by_reaction[$emoji] ??= 0;
187            $count_by_reaction[$emoji]++;
188        }
189        $reactions = array_keys($count_by_reaction);
190        usort($reactions, fn ($a, $b) => $count_by_reaction[$b] <=> $count_by_reaction[$a]);
191        return $reactions;
192    }
193}