Lines 80.57% 112 / 139
Methods 88.23% 15 / 17
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 rewriteUrlFragments 100.00% 1 / 1 100.00% 1 / 1 1
 renderAnswerPreview 100.00% 12 / 12 100.00% 1 / 1 2
 createOverview 0.00% 0 / 3 0.00% 0 / 1 2
 createFaqUrl 100.00% 8 / 8 100.00% 1 / 1 1
 cleanUpContent 100.00% 49 / 49 100.00% 1 / 1 3
 convertOldInternalLinks 51.02% 25 / 49 0.00% 0 / 1 12.76
 [phpMyFAQ\Helper\AbstractHelper] setCategory 100.00% 2 / 2 100.00% 1 / 1 1
 [phpMyFAQ\Helper\AbstractHelper] getCategory 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Helper\AbstractHelper] category 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Helper\AbstractHelper] plurals 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Helper\AbstractHelper] setCategoryRelation 100.00% 2 / 2 100.00% 1 / 1 1
 [phpMyFAQ\Helper\AbstractHelper] setTags 100.00% 2 / 2 100.00% 1 / 1 1
 [phpMyFAQ\Helper\AbstractHelper] setPlurals 100.00% 2 / 2 100.00% 1 / 1 1
 [phpMyFAQ\Helper\AbstractHelper] setSessionId 100.00% 2 / 2 100.00% 1 / 1 1
 [phpMyFAQ\Helper\AbstractHelper] setConfiguration 100.00% 2 / 2 100.00% 1 / 1 1
 [phpMyFAQ\Helper\AbstractHelper] getConfiguration 100.00% 1 / 1 100.00% 1 / 1 1
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}

Inherited from phpMyFAQ\Helper\AbstractHelper

47    public function setCategory(Category $Category): AbstractHelper
48    {
49        $this->Category = $Category;
50        return $this;
51    }
53    public function getCategory(): Category
54    {
55        return $this->category();
56    }
61    protected function category(): Category
62    {
63        return $this->Category ?? throw new \LogicException('setCategory() must be called before use.');
64    }
69    protected function plurals(): Plurals
70    {
71        return $this->plurals ?? throw new \LogicException('setPlurals() must be called before use.');
72    }
74    public function setCategoryRelation(Relation $categoryRelation): AbstractHelper
75    {
76        $this->categoryRelation = $categoryRelation;
77        return $this;
78    }
80    public function setTags(Tags $Tags): AbstractHelper
81    {
82        $this->Tags = $Tags;
83        return $this;
84    }
86    public function setPlurals(Plurals $plurals): AbstractHelper
87    {
88        $this->plurals = $plurals;
89        return $this;
90    }
92    public function setSessionId(int|string $sid): AbstractHelper
93    {
94        $this->sessionId = $sid;
95        return $this;
96    }
98    public function setConfiguration(Configuration $configuration): AbstractHelper
99    {
100        $this->configuration = $configuration;
101        return $this;
102    }
104    public function getConfiguration(): Configuration
105    {
106        return $this->configuration;
107    }