Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.92% covered (success)
91.92%
91 / 99
66.67% covered (warning)
66.67%
2 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
CommentController
91.92% covered (success)
91.92%
91 / 99
66.67% covered (warning)
66.67%
2 / 3
35.65
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.49% covered (success)
91.49%
86 / 94
0.00% covered (danger)
0.00%
0 / 1
32.63
 isCommentAllowed
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3/**
4 * The Comment 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\Comments;
23use phpMyFAQ\Controller\AbstractController;
24use phpMyFAQ\Core\Exception;
25use phpMyFAQ\Entity\Comment;
26use phpMyFAQ\Enums\PermissionType;
27use phpMyFAQ\Faq;
28use phpMyFAQ\Filter;
29use phpMyFAQ\Language;
30use phpMyFAQ\News;
31use phpMyFAQ\Notification;
32use phpMyFAQ\Service\Gravatar;
33use phpMyFAQ\Session\Token;
34use phpMyFAQ\StopWords;
35use phpMyFAQ\Translation;
36use phpMyFAQ\User;
37use phpMyFAQ\User\CurrentUser;
38use phpMyFAQ\User\UserSession;
39use Symfony\Component\HttpFoundation\JsonResponse;
40use Symfony\Component\HttpFoundation\Request;
41use Symfony\Component\HttpFoundation\Response;
42use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
43use Symfony\Component\Routing\Attribute\Route;
44
45final class CommentController extends AbstractController
46{
47    /* @mago-expect lint:excessive-parameter-list - the controller dependencies are injected explicitly */
48    public function __construct(
49        private readonly Faq $faq,
50        private readonly Comments $comments,
51        private readonly StopWords $stopWords,
52        private readonly UserSession $userSession,
53        private readonly Language $language,
54        private readonly User $user,
55        private readonly Notification $notification,
56        private readonly News $news,
57        private readonly Gravatar $gravatar,
58    ) {
59        parent::__construct();
60    }
61
62    /**
63     * @throws Exception
64     * @throws \JsonException
65     * @throws \Exception|TransportExceptionInterface
66     */
67    #[Route(path: 'comment/create', name: 'api.private.comment', methods: ['POST'])]
68    public function create(Request $request): JsonResponse
69    {
70        $this->userSession->setCurrentUser($this->currentUser);
71
72        $defaultLanguage = (string) $this->configuration->get(item: 'main.language');
73        $languageCode = $this->configuration->get(item: 'main.languageDetection')
74            ? $this->language->setLanguageWithDetection($defaultLanguage)
75            : $this->language->setLanguageFromConfiguration($defaultLanguage);
76
77        if (!$this->isCommentAllowed($this->currentUser)) {
78            return $this->json(['error' => Translation::get(key: 'ad_msg_noauth')], Response::HTTP_FORBIDDEN);
79        }
80
81        $data = json_decode($request->getContent(), associative: false, depth: 512, flags: JSON_THROW_ON_ERROR);
82        if (!$data instanceof \stdClass) {
83            throw new Exception('The request body must be a JSON object');
84        }
85
86        if (($data->{'pmf-csrf-token'} ?? null) === null) {
87            throw new Exception('Missing CSRF token');
88        }
89
90        if (!Token::getInstance($this->session)->verifyToken(
91            page: 'add-comment',
92            requestToken: (string) $data->{'pmf-csrf-token'},
93        )) {
94            throw new Exception('Invalid CSRF token');
95        }
96
97        if (($data->user ?? null) === null) {
98            throw new Exception('Missing user');
99        }
100
101        if (($data->mail ?? null) === null) {
102            throw new Exception('Missing email');
103        }
104
105        if (($data->comment_text ?? null) === null) {
106            throw new Exception('Missing or empty comment text');
107        }
108
109        $type = Filter::filterVar($data->type ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
110
111        if ($type === 'news') {
112            throw new Exception('News comments not supported');
113        }
114
115        $faqId = Filter::filterVar($data->id ?? null, FILTER_VALIDATE_INT, default: 0);
116        $newsId = Filter::filterVar($data->newsId ?? null, FILTER_VALIDATE_INT);
117        $username = Filter::filterVar($data->user, FILTER_SANITIZE_SPECIAL_CHARS, '');
118        $email = Filter::filterEmail($data->mail);
119
120        if (!$email) {
121            throw new Exception('Invalid email address');
122        }
123
124        $email = Filter::filterVar($email, FILTER_SANITIZE_SPECIAL_CHARS, '');
125
126        if (!$this->captchaCodeIsValid($request)) {
127            return $this->json(['error' => Translation::get(key: 'msgCaptcha')], Response::HTTP_BAD_REQUEST);
128        }
129
130        // Check if user is logged in and editor is enabled
131        $enableCommentEditor = (bool) $this->configuration->get('main.enableCommentEditor');
132        $isLoggedIn = $this->currentUser->isLoggedIn();
133
134        // Sanitize comment text based on user status and configuration
135        $commentText = Filter::filterVar($data->comment_text, FILTER_SANITIZE_SPECIAL_CHARS, '');
136        if ($enableCommentEditor && $isLoggedIn) {
137            // Allow HTML for logged-in users when editor is enabled, using Symfony HtmlSanitizer
138            $commentText = Filter::removeAttributes((string) $data->comment_text);
139        }
140
141        $commentId = match ($type) {
142            'news' => (int) $newsId,
143            'faq' => (int) $faqId,
144            default => 0,
145        };
146
147        if ($commentId === 0) {
148            return $this->json(['error' => Translation::get(key: 'errSaveComment')], Response::HTTP_BAD_REQUEST);
149        }
150
151        // Check display name and e-mail address for not logged-in users
152        if (!$this->currentUser->isLoggedIn()) {
153            if ($this->user->checkDisplayName($username) && $this->user->checkMailAddress($email)) {
154                $this->configuration->getLogger()->error(message: 'Name and email already used by registered user.');
155                return $this->json(['error' => Translation::get(key: 'errSaveComment')], Response::HTTP_CONFLICT);
156            }
157        }
158
159        if (
160            $username !== ''
161            && $email !== ''
162            && $commentText !== ''
163            && $this->stopWords->checkBannedWord($commentText)
164            && $this->comments->isCommentAllowed($commentId, $languageCode, $type)
165            && $this->faq->isActive($commentId, $languageCode, $type)
166        ) {
167            $this->userSession->userTracking(action: 'save_comment', data: $commentId);
168            $commentEntity = new Comment();
169            $commentEntity
170                ->setRecordId((int) $commentId)
171                ->setType($type)
172                ->setUsername($username)
173                ->setEmail($email)
174                ->setComment(
175                    $enableCommentEditor && $isLoggedIn
176                        ? (string) $commentText
177                        : nl2br(strip_tags((string) $commentText)),
178                ) // Already sanitized with HTML support // Plain text with line breaks
179                ->setDate((string) $request->server->get(key: 'REQUEST_TIME'));
180
181            if ($this->comments->create($commentEntity)) {
182                if ('faq' === $type) {
183                    $this->faq->getFaq($commentId);
184                    $this->notification->sendFaqCommentNotification($this->faq, $commentEntity);
185                }
186
187                if ('news' === $type) {
188                    $newsData = $this->news->get($commentId);
189                    $this->notification->sendNewsCommentNotification($newsData, $commentEntity);
190                }
191
192                $gravatarUrl = $this->gravatar->getImageUrl($commentEntity->getEmail(), [
193                    'size' => '50',
194                    'default' => 'mm',
195                ]);
196
197                return $this->json([
198                    'success' => Translation::get(key: 'msgCommentThanks'),
199                    'commentData' => [
200                        'username' => $commentEntity->getUsername(),
201                        'comment' => $commentEntity->getComment(),
202                        'date' => $commentEntity->getDate(),
203                        'gravatarUrl' => $gravatarUrl,
204                    ],
205                ], Response::HTTP_OK);
206            }
207
208            $this->userSession->userTracking(action: 'error_save_comment', data: $commentId);
209            return $this->json(['error' => Translation::get(key: 'errSaveComment')], Response::HTTP_BAD_REQUEST);
210        }
211
212        return $this->json([
213            'error' => 'Please add your name, your e-mail address and a comment!',
214        ], Response::HTTP_BAD_REQUEST);
215    }
216
217    /**
218     * @throws \Exception
219     */
220    private function isCommentAllowed(CurrentUser $currentUser): bool
221    {
222        return !(
223            !$this->configuration->get(item: 'records.allowCommentsForGuests')
224            && !$currentUser->perm->hasPermission($currentUser->getUserId(), PermissionType::COMMENT_ADD->value)
225        );
226    }
227}