PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.2.0
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.2.0
2.2.0 2.1.0 2.0.15 2.0.12 2.0.10 2.0.4 2.0.1 2.0.0 1.95.3 1.95.2 1.95 1.91.6 trunk 1.11 1.12 1.13 1.20 1.21 1.22 1.23 1.30 1.31 1.32 1.35 1.40 All 43 releases
← All changes | app/Services/CommentService.php +95 -35 2.0.15 → 2.2.0 View file →
@@ -1,8 +1,9 @@
1 1 <?php
2 2
3 3 namespace FluentBoards\App\Services;
4 4
5 +use FluentBoards\App\App;
5 6 use FluentBoards\App\Models\Comment;
6 7 use FluentBoards\App\Models\CommentImage;
7 8 use FluentBoards\App\Models\Task;
8 9 use FluentBoards\App\Models\TaskActivity;
@@ -7,8 +8,9 @@
7 8 use FluentBoards\App\Models\Task;
8 9 use FluentBoards\App\Models\TaskActivity;
9 10 use FluentBoardsPro\App\Services\AttachmentService;
10 11 use FluentBoardsPro\App\Services\RemoteUrlParser;
12 +use RuntimeException;
11 13
12 14 class CommentService
13 15 {
14 16 /**
@@ -35,8 +37,9 @@
35 37 * Get paginated parent comments with users, images, and replies preloaded.
36 38 */
37 39 public function getComments($id, $per_page, $filter, $boardId = null)
38 40 {
41 + $per_page = Sanitize::perPage($per_page);
39 42 $task = $boardId ? (new TaskService())->findTaskOnBoard($id, $boardId) : Task::findOrFail($id);
40 43
41 44 $commentsQuery = $task->comments()->whereNull('parent_id')
42 45 ->with(['user', 'images', 'replies.user', 'replies.images']);
@@ -92,8 +95,43 @@
92 95 do_action('fluent_boards/comment_created', $comment);
93 96 return $comment;
94 97 }
95 98
99 + /** Preserve comment formatting while stripping executable HTML and unsafe attributes. */
100 + public function sanitizeContent($content)
101 + {
102 + if (!is_string($content) || trim(wp_strip_all_tags($content)) === '') {
103 + return '';
104 + }
105 +
106 + return trim(wp_kses($content, [
107 + 'p' => [], 'br' => [], 'strong' => [], 'b' => [],
108 + 'em' => [], 'i' => [], 'del' => [], 's' => [], 'code' => [],
109 + 'a' => ['href' => true, 'title' => true],
110 + 'ul' => [], 'ol' => ['start' => true], 'li' => [],
111 + 'h1' => [], 'h2' => [], 'h3' => [], 'h4' => [], 'h5' => [], 'h6' => [],
112 + 'blockquote' => [], 'pre' => ['data-language' => true],
113 + ]));
114 + }
115 +
116 + /** Resolve mentions and bare URLs in text without rewriting link attributes or code. */
117 + public function renderContent($content, $mentionData = [])
118 + {
119 + $parts = wp_html_split($this->sanitizeContent($content));
120 + $skipDepth = 0;
121 + foreach ($parts as &$part) {
122 + if (preg_match('~^</?(a|code|pre)\b~i', $part)) {
123 + $skipDepth += strpos($part, '</') === 0 ? -1 : 1;
124 + $skipDepth = max(0, $skipDepth);
125 + } elseif ($skipDepth === 0 && $part !== '' && $part[0] !== '<') {
126 + $part = $this->processMentionAndLink($part, $mentionData);
127 + }
128 + }
129 + unset($part);
130 +
131 + return wp_kses_post(implode('', $parts));
132 + }
133 +
96 134 private function startsWithAt($word) {
97 135 return mb_strpos($word, '@') === 0;
98 136 }
99 137
@@ -173,9 +211,9 @@
173 211 }
174 212
175 213 public function processMentionAndLink($commentDescription, $mentionData = [])
176 214 {
177 - if (empty($commentDescription)) {
215 + if ($commentDescription === '' || $commentDescription === null) {
178 216 return '';
179 217 }
180 218
181 219 try {
@@ -292,47 +330,67 @@
292 330 }
293 331 }
294 332
295 333 /**
296 - * Validate every new image before attaching uploads or removing existing images.
334 + * Retain this comment's images or claim the current user's pending uploads on its board.
335 + * Validate the complete list before changing attachments or removing omitted images.
297 336 */
298 337 public function attachCommentImages($comment, $imageIds)
299 338 {
300 339 $imageIds = $this->normalizeCommentImageIds($imageIds);
340 + $commentId = absint($comment->id);
341 + $boardId = absint($comment->board_id);
342 + $taskId = absint($comment->task_id);
343 + $userId = get_current_user_id();
301 344
302 - $commentImages = CommentImage::where('object_id', $comment->id)
303 - ->where('object_type', Constant::COMMENT_IMAGE)
304 - ->get();
345 + if (!$commentId || !$boardId || !$userId) {
346 + throw new RuntimeException(__('Invalid comment image selection.', 'fluent-boards'));
347 + }
305 348
306 - $attachedImageIds = [];
307 - foreach ($commentImages as $commentImage) {
308 - if (in_array((int) $commentImage->id, $imageIds, true)) {
309 - $attachedImageIds[] = (int) $commentImage->id;
349 + App::getInstance('db')->transaction(function () use ($imageIds, $commentId, $boardId, $taskId, $userId) {
350 + // Serialize edits to this comment and prevent concurrent claims of the same upload.
351 + Comment::withoutGlobalScopes()->where('id', $commentId)->where('board_id', $boardId)->lockForUpdate()->firstOrFail();
352 + $images = CommentImage::whereIn('id', $imageIds)
353 + ->where('object_type', Constant::COMMENT_IMAGE)
354 + ->orderBy('id')
355 + ->lockForUpdate()
356 + ->get();
357 +
358 + if ($images->count() !== count($imageIds)) {
359 + throw new RuntimeException(__('Invalid comment image selection.', 'fluent-boards'));
310 360 }
311 - }
312 361
313 - $newImageIds = array_values(array_diff($imageIds, $attachedImageIds));
314 - if (!empty($newImageIds)) {
315 - $attachmentObjects = $this->assertCommentImagesAttachable(
316 - $newImageIds,
317 - $comment->board_id,
318 - $comment->task_id
319 - );
362 + foreach ($images as $image) {
363 + if ((int) $image->object_id === $commentId) {
364 + continue;
365 + }
320 366
321 - foreach ($attachmentObjects as $attachmentObject) {
322 - $attachmentObject->object_id = $comment->id;
323 - $attachmentObject->object_type = Constant::COMMENT_IMAGE;
324 - $attachmentObject->save();
367 + if ((int) $image->object_id !== 0
368 + || !$this->commentImageScopeMatches($image, $boardId, $taskId, $userId)) {
369 + throw new RuntimeException(__('Invalid comment image selection.', 'fluent-boards'));
370 + }
325 371 }
326 - }
327 372
328 - foreach ($commentImages as $commentImage) {
329 - if (!in_array((int) $commentImage->id, $imageIds, true)) {
330 - $deletedImage = clone $commentImage;
331 - $commentImage->delete();
332 - //do_action('fluent_boards/comment_image_deleted', $deletedImage);
373 + foreach ($images as $image) {
374 + if ((int) $image->object_id === 0) {
375 + $image->object_id = $commentId;
376 + if (!$image->save()) {
377 + throw new RuntimeException(__('Could not attach comment image.', 'fluent-boards'));
378 + }
379 + }
333 380 }
334 - }
381 +
382 + $removedImages = CommentImage::where('object_id', $commentId)
383 + ->where('object_type', Constant::COMMENT_IMAGE)
384 + ->whereNotIn('id', $imageIds)
385 + ->get();
386 +
387 + foreach ($removedImages as $image) {
388 + if (!$image->delete()) {
389 + throw new RuntimeException(__('Could not remove comment image.', 'fluent-boards'));
390 + }
391 + }
392 + });
335 393 }
336 394
337 395 /**
338 396 * Allow retained images on this comment and validate all newly supplied uploads.
@@ -458,13 +516,10 @@
458 516 if (array_diff($newMentionedIds, $allMentionedIds)) {
459 517 throw new \Exception(esc_html__('One or more mentioned users are not members of this board', 'fluent-boards'), 403);
460 518 }
461 519
462 - if ($allMentionedIds) {
463 - $processedDescription = $this->processMentionAndLink($commentData['description'], $allMentionedIds);
464 - } elseif(!$allMentionedIds) {
465 - $processedDescription = $this->checkIfCommentHaveLinks($commentData['description']);
466 - }
520 + $commentData['description'] = $this->sanitizeContent($commentData['description']);
521 + $processedDescription = $this->renderContent($commentData['description'], $allMentionedIds);
467 522
468 523 $oldComment = $comment->settings['raw_description'] ?? $comment->description;
469 524 $comment->description = $processedDescription;
470 525
@@ -582,9 +637,12 @@
582 637 $settings = $attachData['type'] == 'url' ? [
583 638 'meta' => $UrlMeta
584 639 ] : [];
585 640 $settings['board_id'] = absint($boardId);
586 - $attachment->settings = $settings;
641 + $attachment->settings = $settings + [
642 + Constant::ATTACHMENT_UPLOAD_BOARD_ID => absint($boardId),
643 + Constant::ATTACHMENT_UPLOAD_USER_ID => get_current_user_id(),
644 + ];
587 645 $this->applyCommentImageScope($attachment, $boardId, $taskId);
588 646 $attachment->driver = 'local';
589 647 $attachment->save();
590 648
@@ -592,12 +650,14 @@
592 650 }
593 651
594 652 public function createPublicUrl($attachment, $boardId)
595 653 {
654 + $boardId = absint($boardId);
655 +
596 656 return add_query_arg([
597 657 'fbs' => 1,
598 658 'fbs_type' => 'public_url',
599 - 'fbs_comment_image' => $attachment->file_hash
659 + 'fbs_comment_image' => $attachment->file_hash,
600 660 ], site_url('/index.php'));
601 661 }
602 662
603 663 private function setTitle($type, $title, $UrlMeta)