Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.45% covered (success)
91.45%
107 / 117
66.67% covered (warning)
66.67%
2 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
FaqController
91.45% covered (success)
91.45%
107 / 117
66.67% covered (warning)
66.67%
2 / 3
41.00
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
 create
91.07% covered (success)
91.07%
102 / 112
0.00% covered (danger)
0.00%
0 / 1
37.97
 isAddingFaqsAllowed
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3/**
4 * The FAQ Controller
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 was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2024-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     2024-03-03
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Controller\Frontend\Api;
21
22use phpMyFAQ\Category;
23use phpMyFAQ\Category\Permission as CategoryPermission;
24use phpMyFAQ\Controller\AbstractController;
25use phpMyFAQ\Core\Exception;
26use phpMyFAQ\Entity\FaqEntity;
27use phpMyFAQ\Enums\PermissionType;
28use phpMyFAQ\Faq;
29use phpMyFAQ\Faq\MetaData;
30use phpMyFAQ\Faq\Permission as FaqPermission;
31use phpMyFAQ\Filter;
32use phpMyFAQ\Helper\CategoryHelper;
33use phpMyFAQ\Helper\FaqHelper;
34use phpMyFAQ\Language;
35use phpMyFAQ\Notification;
36use phpMyFAQ\Question;
37use phpMyFAQ\StopWords;
38use phpMyFAQ\Translation;
39use phpMyFAQ\User\CurrentUser;
40use phpMyFAQ\User\UserSession;
41use Symfony\Component\HttpFoundation\JsonResponse;
42use Symfony\Component\HttpFoundation\Request;
43use Symfony\Component\HttpFoundation\Response;
44use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
45use Symfony\Component\Routing\Attribute\Route;
46
47final class FaqController extends AbstractController
48{
49    /* @mago-expect lint:excessive-parameter-list - the controller dependencies are injected explicitly */
50    public function __construct(
51        private readonly Faq $faq,
52        private readonly FaqHelper $faqHelper,
53        private readonly Question $question,
54        private readonly StopWords $stopWords,
55        private readonly UserSession $userSession,
56        private readonly Language $language,
57        private readonly CategoryHelper $categoryHelper,
58        private readonly Notification $notification,
59    ) {
60        parent::__construct();
61    }
62
63    /**
64     * @throws Exception|\JsonException|\Exception
65     */
66    #[Route(path: 'faq/create', name: 'api.private.faq.create', methods: ['POST'])]
67    public function create(Request $request): JsonResponse
68    {
69        $this->userSession->setCurrentUser($this->currentUser);
70
71        $categoryPermission = new CategoryPermission($this->configuration);
72        $faqPermission = new FaqPermission($this->configuration);
73
74        $defaultLanguage = (string) $this->configuration->get(item: 'main.language');
75        $languageCode = $this->configuration->get(item: 'main.languageDetection')
76            ? $this->language->setLanguageWithDetection($defaultLanguage)
77            : $this->language->setLanguageFromConfiguration($defaultLanguage);
78
79        if (!$this->isAddingFaqsAllowed($this->currentUser)) {
80            return $this->json(['error' => Translation::get(key: 'ad_msg_noauth')], Response::HTTP_FORBIDDEN);
81        }
82
83        $data = json_decode($request->getContent(), associative: false, depth: 512, flags: JSON_THROW_ON_ERROR);
84        if (!$data instanceof \stdClass) {
85            throw new Exception('Invalid request payload');
86        }
87
88        if (!property_exists($data, 'name')) {
89            throw new Exception('Missing name');
90        }
91
92        if (!property_exists($data, 'question') || trim((string) $data->question) === '') {
93            throw new Exception('Missing or empty question');
94        }
95
96        if (!property_exists($data, 'answer')) {
97            throw new Exception('Missing answer');
98        }
99
100        $author = trim((string) Filter::filterVar($data->name, FILTER_SANITIZE_SPECIAL_CHARS));
101        $email = trim((string) Filter::filterEmail($data->email));
102        $email = Filter::filterVar($email, FILTER_SANITIZE_SPECIAL_CHARS);
103
104        if (!$email) {
105            throw new Exception('Invalid email address');
106        }
107
108        $questionText = Filter::filterVar($data->question, FILTER_SANITIZE_SPECIAL_CHARS);
109        $questionText = trim(strip_tags((string) $questionText));
110
111        $answer = Filter::filterVar($data->answer, FILTER_SANITIZE_SPECIAL_CHARS, '');
112        if ($this->configuration->get(item: 'main.enableWysiwygEditorFrontend')) {
113            // html_entity_decode() turns surviving HTML entities back into executable
114            // markup, so the decoded result must be passed through the HTML sanitizer
115            // before it is stored. Otherwise it is rendered unescaped in the admin
116            // FAQ editor (faq.editor.twig uses the |raw filter), enabling stored XSS.
117            $answer = $this->faqHelper->cleanUpContent(trim(html_entity_decode((string) $answer)));
118        }
119
120        if (!$this->configuration->get(item: 'main.enableWysiwygEditorFrontend')) {
121            $answer = trim(nl2br(strip_tags((string) $answer)));
122        }
123
124        $category = new Category($this->configuration);
125        $keywords = Filter::filterVar($data->keywords, FILTER_SANITIZE_SPECIAL_CHARS, '');
126        $categories = [];
127
128        if (property_exists($data, 'rubrik')) {
129            if (is_string($data->rubrik)) {
130                $data->rubrik = [$data->rubrik];
131            }
132
133            $categories = Filter::filterArray(is_array($data->rubrik) ? $data->rubrik : []);
134        }
135
136        if ($categories === []) {
137            $allCategoryIds = $category->getAllCategoryIds();
138            if (count($allCategoryIds) === 0) {
139                throw new Exception('No categories available');
140            }
141
142            $categories = [$allCategoryIds[0]];
143        }
144
145        $categories = array_values(array_filter(
146            array_map(static fn($v): ?int => Filter::filterVar($v, FILTER_VALIDATE_INT), (array) ($categories ?? [])),
147            static fn($v): bool => $v !== null,
148        ));
149
150        if (!$this->captchaCodeIsValid($request)) {
151            return $this->json(['error' => Translation::get(key: 'msgCaptcha')], Response::HTTP_BAD_REQUEST);
152        }
153
154        if (
155            $author !== ''
156            && $author !== '0'
157            && $email !== ''
158            && $email !== '0'
159            && $questionText !== ''
160            && $questionText !== '0'
161            && $this->stopWords->checkBannedWord(strip_tags($questionText))
162        ) {
163            if ($answer === '' || $answer === '0') {
164                $answer = '';
165            }
166
167            if ($answer !== '' && !$this->stopWords->checkBannedWord(strip_tags($answer))) {
168                return $this->json(['error' => Translation::get(key: 'errSaveEntries')], Response::HTTP_BAD_REQUEST);
169            }
170
171            $this->userSession->userTracking('save_new_entry', 0);
172
173            $autoActivate = $this->configuration->get(item: 'records.defaultActivation');
174
175            $faqEntity = new FaqEntity();
176            $faqEntity
177                ->setLanguage($languageCode)
178                ->setQuestion($questionText)
179                ->setActive((bool) $autoActivate)
180                ->setSticky(false)
181                ->setAnswer($answer)
182                ->setKeywords($keywords)
183                ->setAuthor($author)
184                ->setEmail($email)
185                ->setComment(true)
186                ->setNotes('');
187
188            $this->faq->create($faqEntity);
189            $recordId = $faqEntity->getId();
190
191            if ($recordId === null) {
192                return $this->json(['error' => Translation::get(key: 'errSaveEntries')], Response::HTTP_BAD_REQUEST);
193            }
194
195            $openQuestionId = property_exists($data, 'openQuestionID')
196                ? Filter::filterVar($data->openQuestionID, FILTER_VALIDATE_INT)
197                : false;
198            if ($openQuestionId) {
199                if ($this->configuration->get(item: 'records.enableDeleteQuestion')) {
200                    $this->question->delete($openQuestionId);
201                }
202
203                if (!$this->configuration->get(item: 'records.enableDeleteQuestion')) {
204                    // Adds this faq record id to the related open question.
205                    $this->question->updateQuestionAnswer((int) $openQuestionId, (int) $recordId, (int) $categories[0]);
206                }
207            }
208
209            $faqMetaData = new MetaData($this->configuration);
210            $faqMetaData
211                ->setFaqId($recordId)
212                ->setFaqLanguage($faqEntity->getLanguage())
213                ->setCategories($categories)
214                ->save();
215
216            // Let the admin and the category owners to be informed by email of this new entry
217            $this->categoryHelper->setCategory($category)->setConfiguration($this->configuration);
218
219            $moderators = $this->categoryHelper->getModerators($categories);
220
221            // Add user and group permissions
222            $permissions = $categoryPermission->getAll($categories);
223            foreach ($categories as $category) {
224                $faqPermission->add(FaqPermission::USER, $recordId, $permissions[$category]['user'] ?? []);
225                if ($this->configuration->get(item: 'security.permLevel') !== 'basic') {
226                    $faqPermission->add(FaqPermission::GROUP, $recordId, $permissions[$category]['group'] ?? []);
227                }
228            }
229
230            try {
231                $this->notification->sendNewFaqAdded($moderators, $faqEntity);
232            } catch (Exception|TransportExceptionInterface $e) {
233                $this->configuration->getLogger()->info('Notification could not be sent: ', [$e->getMessage()]);
234            }
235
236            $link = [];
237            if ($this->configuration->get(item: 'records.defaultActivation')) {
238                $link = [
239                    'link' => $this->faqHelper->createFaqUrl($faqEntity, (int) $categories[0]),
240                    'info' => Translation::get(key: 'msgRedirect'),
241                ];
242            }
243
244            return $this->json([
245                'success' => Translation::get(key: 'msgNewContentThanks'),
246                ...$link,
247            ], Response::HTTP_OK);
248        }
249
250        return $this->json(['error' => Translation::get(key: 'errSaveEntries')], Response::HTTP_BAD_REQUEST);
251    }
252
253    /**
254     * @throws \Exception
255     */
256    private function isAddingFaqsAllowed(CurrentUser $currentUser): bool
257    {
258        return !(
259            !$this->configuration->get(item: 'records.allowNewFaqsForGuests')
260            && !$currentUser->perm->hasPermission($currentUser->getUserId(), PermissionType::FAQ_ADD->value)
261        );
262    }
263}