Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
78.05% |
96 / 123 |
|
71.43% |
5 / 7 |
CRAP | |
0.00% |
0 / 1 |
| FaqHelper | |
78.05% |
96 / 123 |
|
71.43% |
5 / 7 |
18.71 | |
0.00% |
0 / 1 |
| __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 | |||
| 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 | |
| 18 | declare(strict_types=1); |
| 19 | |
| 20 | namespace phpMyFAQ\Helper; |
| 21 | |
| 22 | use League\CommonMark\Environment\Environment; |
| 23 | use League\CommonMark\Exception\CommonMarkException; |
| 24 | use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension; |
| 25 | use League\CommonMark\Extension\GithubFlavoredMarkdownExtension; |
| 26 | use League\CommonMark\MarkdownConverter; |
| 27 | use phpMyFAQ\Category; |
| 28 | use phpMyFAQ\Configuration; |
| 29 | use phpMyFAQ\Entity\FaqEntity; |
| 30 | use phpMyFAQ\Faq; |
| 31 | use phpMyFAQ\Link; |
| 32 | use phpMyFAQ\Link\Util\TitleSlugifier; |
| 33 | use phpMyFAQ\Utils; |
| 34 | use Symfony\Component\HtmlSanitizer\HtmlSanitizer; |
| 35 | use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig; |
| 36 | use Symfony\Component\HttpFoundation\Request; |
| 37 | |
| 38 | /** |
| 39 | * Class FaqHelper |
| 40 | * |
| 41 | * @package phpMyFAQ\Helper |
| 42 | */ |
| 43 | class 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 (= 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(=|=)(artikel|faq)(&|&)cat' |
| 234 | . '(=|=)(\d+)(&|&)id(=|=)(\d+)((&|&)artlang(=|=)([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 | } |