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