Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.37% covered (success)
97.37%
74 / 76
33.33% covered (danger)
33.33%
1 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
QuestionController
97.37% covered (success)
97.37%
74 / 76
33.33% covered (danger)
33.33%
1 / 3
27
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
98.51% covered (success)
98.51%
66 / 67
0.00% covered (danger)
0.00%
0 / 1
23
 isAddingQuestionsAllowed
87.50% covered (success)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
1<?php
2
3/**
4 * The Question & Smart Answer 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\Controller\AbstractController;
24use phpMyFAQ\Core\Exception;
25use phpMyFAQ\Entity\QuestionEntity;
26use phpMyFAQ\Enums\PermissionType;
27use phpMyFAQ\Faq\Permission;
28use phpMyFAQ\Filter;
29use phpMyFAQ\Helper\QuestionHelper;
30use phpMyFAQ\Notification;
31use phpMyFAQ\Question;
32use phpMyFAQ\Search;
33use phpMyFAQ\Search\SearchResultSet;
34use phpMyFAQ\StopWords;
35use phpMyFAQ\Translation;
36use Symfony\Component\HttpFoundation\JsonResponse;
37use Symfony\Component\HttpFoundation\Request;
38use Symfony\Component\HttpFoundation\Response;
39use Symfony\Component\Routing\Attribute\Route;
40
41final class QuestionController extends AbstractController
42{
43    public function __construct(
44        private readonly StopWords $stopWords,
45        private readonly QuestionHelper $questionHelper,
46        private readonly Search $search,
47        private readonly Question $question,
48        private readonly Notification $notification,
49    ) {
50        parent::__construct();
51    }
52
53    /**
54     * @throws Exception
55     * @throws \JsonException
56     * @throws \Exception
57     */
58    #[Route(path: 'question/create', name: 'api.private.question.create', methods: ['POST'])]
59    public function create(Request $request): JsonResponse
60    {
61        if (!$this->isAddingQuestionsAllowed()) {
62            return $this->json(['error' => Translation::get(key: 'ad_msg_noauth')], Response::HTTP_FORBIDDEN);
63        }
64
65        $category = new Category($this->configuration);
66
67        $this->questionHelper->setConfiguration($this->configuration)->setCategory($category);
68
69        $categories = $category->getAllCategories();
70
71        $data = json_decode($request->getContent(), associative: false, depth: 512, flags: JSON_THROW_ON_ERROR);
72
73        if (($data->name ?? null) === null) {
74            throw new Exception('Missing name');
75        }
76
77        if (($data->email ?? null) === null) {
78            throw new Exception('Missing email');
79        }
80
81        if (($data->lang ?? null) === null) {
82            throw new Exception('Missing language');
83        }
84
85        if (($data->question ?? null) === null || $data->question === '') {
86            throw new Exception('Missing or empty question');
87        }
88
89        $author = trim((string) Filter::filterVar($data->name, FILTER_SANITIZE_SPECIAL_CHARS));
90        $email = trim((string) Filter::filterEmail($data->email));
91
92        if (!$email) {
93            throw new Exception('Invalid email address');
94        }
95
96        $email = Filter::filterVar($email, FILTER_SANITIZE_SPECIAL_CHARS, '');
97
98        $selectedCategory = ($data->category ?? null) !== null
99            ? Filter::filterVar($data->category, FILTER_VALIDATE_INT)
100            : false;
101
102        if (($data->category ?? null) !== null && $data->category !== '') {
103            throw new Exception('Category validation failed');
104        }
105
106        $language = trim((string) Filter::filterVar($data->lang, FILTER_SANITIZE_SPECIAL_CHARS));
107        $userQuestion = trim(strip_tags((string) $data->question));
108        $save = Filter::filterVar($data->save ?? 0, FILTER_VALIDATE_INT);
109
110        if (($data->save ?? null) !== null) {
111            throw new Exception('Save parameter not allowed');
112        }
113
114        $storeNow = Filter::filterVar($data->store ?? 'not', FILTER_SANITIZE_SPECIAL_CHARS);
115
116        // If smart answering is disabled, save the question immediately
117        if (!filter_var($this->configuration->get(item: 'main.enableSmartAnswering'), FILTER_VALIDATE_BOOLEAN)) {
118            $save = true;
119        }
120
121        // Validate captcha if we can store the question after displaying the smart answer
122        if ($storeNow !== 'now' && !$this->captchaCodeIsValid($request)) {
123            return $this->json(['error' => Translation::get(key: 'msgCaptcha')], Response::HTTP_BAD_REQUEST);
124        }
125
126        // Check if all necessary fields are provided and not empty
127        if (
128            $author !== ''
129            && $email !== ''
130            && $userQuestion !== ''
131            && $this->stopWords->checkBannedWord($userQuestion)
132        ) {
133            if ($selectedCategory === false) {
134                $selectedCategory = $category->getAllCategoryIds()[0];
135            }
136
137            $visibility = $this->configuration->get(item: 'records.enableVisibilityQuestions') ? 'Y' : 'N';
138
139            $questionEntity = new QuestionEntity();
140            $questionEntity
141                ->setUsername($author)
142                ->setEmail($email)
143                ->setCategoryId((int) $selectedCategory)
144                ->setLanguage($language)
145                ->setQuestion($userQuestion)
146                ->setIsVisible($visibility === 'Y');
147
148            // Save the question immediately if smart answering is disabled
149            if (false === (bool) $save) {
150                $cleanQuestion = $this->stopWords->clean($userQuestion);
151
152                $this->search->setCategory(new Category($this->configuration));
153                $this->search->setCategoryId((int) $selectedCategory);
154
155                $faqPermission = new Permission($this->configuration);
156                $searchResultSet = new SearchResultSet($this->currentUser, $faqPermission, $this->configuration);
157
158                $searchResult = array_merge(...array_map(fn($word) => $this->search->search(
159                    $word,
160                    allLanguages: false,
161                ), array_filter($cleanQuestion)));
162
163                $searchResultSet->reviewResultSet($searchResult);
164
165                if ($searchResultSet->getNumberOfResults() > 0) {
166                    $smartAnswer = $this->questionHelper->generateSmartAnswer($searchResultSet);
167                    return $this->json(['result' => $smartAnswer], Response::HTTP_OK);
168                }
169            }
170
171            $this->question->add($questionEntity);
172            $this->notification->sendQuestionSuccessMail($questionEntity, $categories);
173
174            return $this->json(['success' => Translation::get(key: 'msgAskThx4Mail')], Response::HTTP_OK);
175        }
176
177        return $this->json(['error' => Translation::get(key: 'errSaveEntries')], Response::HTTP_BAD_REQUEST);
178    }
179
180    /**
181     * @throws \Exception
182     */
183    private function isAddingQuestionsAllowed(): bool
184    {
185        if ($this->configuration->get(item: 'records.allowQuestionsForGuests')) {
186            return true;
187        }
188
189        if ($this->configuration->get(item: 'main.enableAskQuestions')) {
190            return true;
191        }
192
193        return $this->currentUser->perm->hasPermission(
194            $this->currentUser->getUserId(),
195            PermissionType::QUESTION_ADD->value,
196        );
197    }
198}