Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
78.05% covered (warning)
78.05%
96 / 123
71.43% covered (warning)
71.43%
5 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
FaqHelper
78.05% covered (warning)
78.05%
96 / 123
71.43% covered (warning)
71.43%
5 / 7
18.71
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 rewriteUrlFragments
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 renderAnswerPreview
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 createOverview
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 createFaqUrl
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 cleanUpContent
100.00% covered (success)
100.00%
49 / 49
100.00% covered (success)
100.00%
1 / 1
3
 convertOldInternalLinks
51.02% covered (warning)
51.02%
25 / 49
0.00% covered (danger)
0.00%
0 / 1
12.76
1<?php
2
3/**
4 * Helper class for phpMyFAQ FAQs.
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL wasn't distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ\Helper
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2010-2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2010-11-12
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Helper;
21
22use League\CommonMark\Environment\Environment;
23use League\CommonMark\Exception\CommonMarkException;
24use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
25use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
26use League\CommonMark\MarkdownConverter;
27use phpMyFAQ\Category;
28use phpMyFAQ\Configuration;
29use phpMyFAQ\Entity\FaqEntity;
30use phpMyFAQ\Faq;
31use phpMyFAQ\Link;
32use phpMyFAQ\Link\Util\TitleSlugifier;
33use phpMyFAQ\Utils;
34use Symfony\Component\HtmlSanitizer\HtmlSanitizer;
35use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
36use Symfony\Component\HttpFoundation\Request;
37
38/**
39 * Class FaqHelper
40 *
41 * @package phpMyFAQ\Helper
42 */
43class FaqHelper extends AbstractHelper
44{
45    /**
46     * Constructor.
47     */
48    public function __construct(Configuration $configuration)
49    {
50        $this->configuration = $configuration;
51    }
52
53    /**
54     * Extends URL fragments (e.g. <a href="#foo">) with the full default URL.
55     */
56    public function rewriteUrlFragments(string $answer, string $currentUrl): string
57    {
58        return str_replace('href="#', 'href="' . $currentUrl . '#', $answer);
59    }
60
61    /**
62     * Renders a preview of the answer
63     *
64     * @param string $answer The answer to be previewed
65     * @param int    $wordCount The number of words to display in the preview
66     * @return string The preview of the answer
67     * @throws CommonMarkException
68     */
69    public function renderAnswerPreview(string $answer, int $wordCount): string
70    {
71        if ($this->configuration->get(item: 'main.enableMarkdownEditor')) {
72            $config = [
73                'html_input' => 'strip',
74                'allow_unsafe_links' => false,
75            ];
76
77            $environment = new Environment($config);
78            $environment->addExtension(new CommonMarkCoreExtension());
79            $environment->addExtension(new GithubFlavoredMarkdownExtension());
80
81            $markdownConverter = new MarkdownConverter($environment);
82
83            $cleanedAnswer = $markdownConverter->convert($answer)->getContent();
84            return Utils::chopString(strip_tags($cleanedAnswer), $wordCount);
85        }
86
87        return Utils::chopString(strip_tags($answer), $wordCount);
88    }
89
90    /**
91     * Creates an overview with all categories with their FAQs.
92     */
93    public function createOverview(Category $category, Faq $faq, string $language = ''): array
94    {
95        $category->transform(0);
96
97        $faq->getAllFaqs(Faq::SORTING_TYPE_CATID_FAQID, ['lang' => $language, 'active' => 'yes']);
98
99        return $faq->faqRecords;
100    }
101
102    /**
103     * Returns the URL for a given FAQ Entity and category ID.
104     */
105    public function createFaqUrl(FaqEntity $faqEntity, int $categoryId): string
106    {
107        return sprintf(
108            '%scontent/%d/%d/%s/%s.html',
109            $this->configuration->getDefaultUrl(),
110            $categoryId,
111            $faqEntity->getId(),
112            $faqEntity->getLanguage(),
113            TitleSlugifier::slug($faqEntity->getQuestion()),
114        );
115    }
116
117    /**
118     * Remove <script> tags, we don't need them
119     */
120    public function cleanUpContent(string $content): string
121    {
122        $contentLength = strlen($content);
123        $allowedHosts = array_values($this->configuration->getAllowedMediaHosts());
124        $allowedHosts[] = Request::createFromGlobals()->getHost();
125        $forceHttpsUrls = filter_var($this->configuration->get(item: 'security.useSslOnly'), FILTER_VALIDATE_BOOLEAN);
126        $htmlSanitizer = new HtmlSanitizer(new HtmlSanitizerConfig()
127            ->withMaxInputLength($contentLength + 1)
128            ->allowSafeElements()
129            ->allowRelativeLinks()
130            ->allowStaticElements()
131            ->allowRelativeMedias()
132            ->forceHttpsUrls($forceHttpsUrls)
133            ->allowElement('iframe', ['title', 'src', 'width', 'height', 'allow', 'allowfullscreen'])
134            ->allowMediaSchemes(['https', 'http', 'mailto', 'data'])
135            ->allowMediaHosts($allowedHosts)
136            ->allowLinkSchemes(['https', 'http', 'mailto', 'data']));
137
138        // Pre-encode whitespace in src/href attribute values, since Symfony HtmlSanitizer
139        // rejects URLs containing unencoded spaces and strips the attribute entirely.
140        $content =
141            preg_replace_callback(
142                '/\b(src|href)\s*=\s*"([^"]*)"/i',
143                static fn(array $matches): string => sprintf(
144                    '%s="%s"',
145                    $matches[1],
146                    str_replace([' ', "\t"], ['%20', '%09'], $matches[2]),
147                ),
148                $content,
149            ) ?? $content;
150
151        // Suppress HTML parser warnings during sanitization. Dom\HTMLDocument::createFromString()
152        // emits tokenizer warnings for slightly malformed user-generated HTML content, and a
153        // registered error handler (e.g. Symfony ErrorHandler) may otherwise convert them to
154        // uncaught ErrorExceptions.
155        $previousErrorReporting = error_reporting(E_ALL & ~E_WARNING);
156        set_error_handler(static fn(int $severity): bool => ($severity & E_WARNING) !== 0);
157        try {
158            $sanitizedContent = $htmlSanitizer->sanitize($content);
159        } finally {
160            restore_error_handler();
161            error_reporting($previousErrorReporting);
162        }
163
164        $strippedContent = preg_replace(
165            '/<iframe\b(?:(?!src)[^>])*>\s*<\/iframe>/i',
166            replacement: '',
167            subject: $sanitizedContent,
168        );
169        $sanitizedContent = $strippedContent ?? $sanitizedContent;
170
171        return preg_replace_callback(
172            '/style\s*=\s*"([^"]*)"/i',
173            static function (array $matches): string {
174                $styles = explode(';', $matches[1]);
175                $filteredStyles = array_filter(
176                    $styles,
177                    static fn(string $style): bool => stripos(trim($style), needle: 'overflow:') !== 0,
178                );
179                $newStyle = implode('; ', $filteredStyles);
180                // Remove the style attribute if empty
181                return $newStyle !== '' && $newStyle !== '0' ? 'style="' . $newStyle . '"' : '';
182            },
183            (string) $sanitizedContent,
184        ) ?? (string) $sanitizedContent;
185    }
186
187    /**
188     * Converts old internal links to the current format
189     * Formats from:
190     * - http://<url>/index.php?action=artikel&cat=<category id>&id=<id>
191     * - http://<url>/index.php?action=artikel&cat=<category id>&id=<id>&artlang=<language>
192     * - http://<url>/index.php?action=faq&cat=<category id>&id=<id>
193     * - http://<url>/index.php?action=faq&cat=<category id>&id=<id>&artlang=<language>
194     * - supports also HTML encoded parameter (&#61; instead of =, & instead of &)
195     *
196     * to the new URL structure:
197     * https://<url>/content/<category id>/<id>/<language>/<the question with underscores as spaces>.html
198     */
199    public function convertOldInternalLinks(string $question, string $answer): string
200    {
201        $link = new Link($this->configuration->getDefaultUrl(), $this->configuration);
202        // Optional artlang parameter; prevents an empty match (sets fallback later)
203        $pattern = '#(https?://[^/]+)/index\.php\?action=(artikel|faq)&cat=(\d+)&id=(\d+)(?:&artlang=([a-z]{2}))?#i';
204
205        $decodedAnswer = html_entity_decode($answer);
206
207        $result =
208            preg_replace_callback(
209                $pattern,
210                function (array $matches) use ($question, $link): string {
211                    $baseUrl = $this->configuration->getDefaultUrl();
212                    $categoryId = (int) $matches[3];
213                    $faqId = (int) $matches[4];
214                    $language = $matches[5] ?? $this->configuration->getLanguage()->getLanguage();
215                    if ($language === '' || $language === '0') {
216                        $language = 'en';
217                    }
218
219                    return sprintf(
220                        '%scontent/%d/%d/%s/%s.html',
221                        $baseUrl,
222                        $categoryId,
223                        $faqId,
224                        $language,
225                        $link->getSEOTitle($question),
226                    );
227                },
228                $decodedAnswer,
229            ) ?? $decodedAnswer;
230
231        if ($result === $decodedAnswer && $decodedAnswer !== $answer) {
232            $htmlEncodedPattern =
233                '/(https?:\/\/[^\/]+)\/index\.php\?action(&#61;|=)(artikel|faq)(&|&)cat'
234                . '(&#61;|=)(\d+)(&|&)id(&#61;|=)(\d+)((&|&)artlang(&#61;|=)([a-z]{2}))?/i';
235
236            return preg_replace_callback(
237                $htmlEncodedPattern,
238                function (array $matches) use ($question, $link): string {
239                    $baseUrl = $this->configuration->getDefaultUrl();
240                    $categoryId = (int) $matches[6];
241                    $faqId = (int) $matches[9];
242                    $language = $matches[13] ?? $this->configuration->getLanguage()->getLanguage();
243                    if ($language === '' || $language === '0') {
244                        $language = 'en';
245                    }
246
247                    return sprintf(
248                        '%scontent/%d/%d/%s/%s.html',
249                        $baseUrl,
250                        $categoryId,
251                        $faqId,
252                        $language,
253                        $link->getSEOTitle($question),
254                    );
255                },
256                $answer,
257            ) ?? $answer;
258        }
259
260        return $result;
261    }
262}