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 +224 -35 1.95.3 → 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 /**
@@ -30,14 +32,18 @@
30 32
31 33 return $comment;
32 34 }
33 35
36 + /**
37 + * Get paginated parent comments with users, images, and replies preloaded.
38 + */
34 39 public function getComments($id, $per_page, $filter, $boardId = null)
35 40 {
41 + $per_page = Sanitize::perPage($per_page);
36 42 $task = $boardId ? (new TaskService())->findTaskOnBoard($id, $boardId) : Task::findOrFail($id);
37 43
38 44 $commentsQuery = $task->comments()->whereNull('parent_id')
39 - ->with(['user']);
45 + ->with(['user', 'images', 'replies.user', 'replies.images']);
40 46
41 47 if ($filter == 'oldest') {
42 48 $commentsQuery = $commentsQuery->oldest();
43 49 } else { // latest or newest
@@ -45,11 +51,9 @@
45 51 }
46 52 $comments = $commentsQuery->paginate($per_page);
47 53
48 54 foreach ($comments as $comment) {
49 - $comment->replies = $this->getReplies($comment);
50 55 $comment->replies_count = count($comment->replies);
51 - $comment->load('images');
52 56 }
53 57
54 58 return $comments;
55 59 }
@@ -91,8 +95,43 @@
91 95 do_action('fluent_boards/comment_created', $comment);
92 96 return $comment;
93 97 }
94 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 +
95 134 private function startsWithAt($word) {
96 135 return mb_strpos($word, '@') === 0;
97 136 }
98 137
@@ -172,9 +211,9 @@
172 211 }
173 212
174 213 public function processMentionAndLink($commentDescription, $mentionData = [])
175 214 {
176 - if (empty($commentDescription)) {
215 + if ($commentDescription === '' || $commentDescription === null) {
177 216 return '';
178 217 }
179 218
180 219 try {
@@ -290,35 +329,176 @@
290 329 return $comment; // Return original text if processing fails
291 330 }
292 331 }
293 332
333 + /**
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.
336 + */
294 337 public function attachCommentImages($comment, $imageIds)
295 338 {
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();
296 344
297 - foreach ($imageIds as $imageId)
298 - {
299 - $attachmentObject = CommentImage::findOrFail($imageId);
300 - if($attachmentObject) {
301 - if ($attachmentObject->object_id == $comment->id && $attachmentObject->object_type == Constant::COMMENT_IMAGE) {
345 + if (!$commentId || !$boardId || !$userId) {
346 + throw new RuntimeException(__('Invalid comment image selection.', 'fluent-boards'));
347 + }
348 +
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'));
360 + }
361 +
362 + foreach ($images as $image) {
363 + if ((int) $image->object_id === $commentId) {
302 364 continue;
303 365 }
304 - $attachmentObject->object_id = $comment->id;
305 - $attachmentObject->object_type = Constant::COMMENT_IMAGE;
306 - $attachmentObject->save();
366 +
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 + }
307 371 }
372 +
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 + }
380 + }
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 + });
393 + }
394 +
395 + /**
396 + * Allow retained images on this comment and validate all newly supplied uploads.
397 + */
398 + public function assertCommentImagesAttachableForComment($comment, $imageIds)
399 + {
400 + $imageIds = $this->normalizeCommentImageIds($imageIds);
401 +
402 + if (empty($imageIds)) {
403 + return [];
308 404 }
309 - //if(in_array("banana", $imageIds))
310 - $commentImages = CommentImage::where('object_id', $comment->id)->where('object_type', Constant::COMMENT_IMAGE)->get();
311 405
406 + $commentImages = CommentImage::where('object_id', $comment->id)
407 + ->where('object_type', Constant::COMMENT_IMAGE)
408 + ->get();
409 +
312 410 foreach ($commentImages as $commentImage) {
313 - if(!in_array($commentImage->id, $imageIds)) {
314 - $deletedImage = clone $commentImage;
315 - $commentImage->delete();
316 - //do_action('fluent_boards/comment_image_deleted', $deletedImage);
411 + $key = array_search((int) $commentImage->id, $imageIds, true);
412 + if ($key !== false) {
413 + unset($imageIds[$key]);
317 414 }
318 415 }
416 +
417 + return $this->assertCommentImagesAttachable(
418 + array_values($imageIds),
419 + $comment->board_id,
420 + $comment->task_id
421 + );
319 422 }
320 423
424 + /**
425 + * Reject the entire image list unless every upload is unbound and owned by this actor, board, and task.
426 + */
427 + public function assertCommentImagesAttachable($imageIds, $boardId, $taskId)
428 + {
429 + $imageIds = $this->normalizeCommentImageIds($imageIds);
430 +
431 + if (empty($imageIds)) {
432 + return [];
433 + }
434 +
435 + $currentUserId = get_current_user_id();
436 + if (!$currentUserId) {
437 + throw new \Exception(esc_html__('Invalid comment image attachment', 'fluent-boards'));
438 + }
439 +
440 + $attachments = CommentImage::whereIn('id', $imageIds)
441 + ->where('object_id', 0)
442 + ->where('object_type', Constant::COMMENT_IMAGE)
443 + ->get();
444 +
445 + if (count($attachments) !== count($imageIds)) {
446 + throw new \Exception(esc_html__('Invalid comment image attachment', 'fluent-boards'));
447 + }
448 +
449 + foreach ($attachments as $attachment) {
450 + if (!$this->commentImageScopeMatches($attachment, $boardId, $taskId, $currentUserId)) {
451 + throw new \Exception(esc_html__('Invalid comment image attachment', 'fluent-boards'));
452 + }
453 + }
454 +
455 + return $attachments;
456 + }
457 +
458 + /**
459 + * Fail closed for legacy uploads without recorded board, task, and uploader ownership.
460 + */
461 + private function commentImageScopeMatches($attachment, $boardId, $taskId, $userId)
462 + {
463 + $settings = is_array($attachment->settings) ? $attachment->settings : [];
464 + $scope = isset($settings['comment_image_scope']) && is_array($settings['comment_image_scope'])
465 + ? $settings['comment_image_scope']
466 + : [];
467 +
468 + return intval($scope['board_id'] ?? 0) === intval($boardId)
469 + && intval($scope['task_id'] ?? 0) === intval($taskId)
470 + && intval($scope['created_by'] ?? 0) === intval($userId);
471 + }
472 +
473 + /**
474 + * Record trusted upload ownership in attachment metadata before saving.
475 + */
476 + public function applyCommentImageScope($attachment, $boardId, $taskId, $createdBy = null)
477 + {
478 + $settings = is_array($attachment->settings) ? $attachment->settings : [];
479 + $settings['comment_image_scope'] = [
480 + 'board_id' => intval($boardId),
481 + 'task_id' => intval($taskId),
482 + 'created_by' => intval($createdBy === null ? get_current_user_id() : $createdBy),
483 + ];
484 + $attachment->settings = $settings;
485 +
486 + return $attachment;
487 + }
488 +
489 + /**
490 + * Normalize submitted image IDs and remove duplicates before validating the full list.
491 + */
492 + private function normalizeCommentImageIds($imageIds)
493 + {
494 + if (!is_array($imageIds)) {
495 + return [];
496 + }
497 +
498 + return array_values(array_filter(array_unique(array_map('intval', $imageIds))));
499 + }
500 +
321 501 public function update($commentData, $comment_id, $mentionData, $boardId = null)
322 502 {
323 503 $comment = $boardId ? $this->findCommentOnBoard($comment_id, $boardId) : Comment::findOrFail($comment_id);
324 504
@@ -325,16 +505,22 @@
325 505 if ($comment->created_by != get_current_user_id()) {
326 506 return false;
327 507 }
328 508
329 - $allMentionedIds = array_unique(array_merge($comment->settings['mentioned_id'] ?? [], is_array($mentionData) ? $mentionData : []));
509 + $effectiveBoardId = absint($comment->board_id ?: $boardId);
510 + $notificationService = new NotificationService();
511 + $existingMentionedIds = array_values(array_unique(array_filter(array_map('absint', (array) ($comment->settings['mentioned_id'] ?? [])))));
512 + $newMentionedIds = array_values(array_unique(array_filter(array_map('absint', (array) $mentionData))));
513 + $requestedMentionedIds = array_values(array_unique(array_merge($existingMentionedIds, $newMentionedIds)));
514 + $allMentionedIds = $notificationService->resolveBoardMentionUserIds($effectiveBoardId, $requestedMentionedIds);
330 515
331 - if ($allMentionedIds) {
332 - $processedDescription = $this->processMentionAndLink($commentData['description'], $allMentionedIds);
333 - } elseif(!$allMentionedIds) {
334 - $processedDescription = $this->checkIfCommentHaveLinks($commentData['description']);
516 + if (array_diff($newMentionedIds, $allMentionedIds)) {
517 + throw new \Exception(esc_html__('One or more mentioned users are not members of this board', 'fluent-boards'), 403);
335 518 }
336 519
520 + $commentData['description'] = $this->sanitizeContent($commentData['description']);
521 + $processedDescription = $this->renderContent($commentData['description'], $allMentionedIds);
522 +
337 523 $oldComment = $comment->settings['raw_description'] ?? $comment->description;
338 524 $comment->description = $processedDescription;
339 525
340 526 if($comment->settings != null)
@@ -417,18 +603,14 @@
417 603 // do_action('fluent_boards/comment_deleted', $taskId);
418 604 }
419 605
420 606 /**
421 - * Adds a task attachment to the specified task.
607 + * Persist an unbound comment upload with trusted board, task, and uploader metadata.
608 + * Legacy uploads without this scope cannot be newly attached to a comment.
422 609 *
423 - * @param int $taskId The ID of the task to which the attachment is added.
424 - * @param string $title The title of the attachment.
425 - * @param string $url The URL of the attachment.
426 - *
427 - * @return Attachment The updated list of task attachments.
428 - * @throws \Exception
610 + * @return CommentImage
429 611 */
430 - public function createCommentImage($data, $boardId)
612 + public function createCommentImage($data, $boardId, $taskId = null)
431 613 {
432 614 /*
433 615 * I will refactor this function later- within March 2024 Last Week
434 616 */
@@ -451,11 +633,17 @@
451 633 $attachment->title = $this->setTitle($attachData['type'], $attachData['name'], $UrlMeta);
452 634 $attachment->file_path = $attachData['type'] != 'url' ? $attachData['file'] : null;
453 635 $attachment->full_url = esc_url($attachData['url']);
454 636 $attachment->file_size = $attachData['size'];
455 - $attachment->settings = $attachData['type'] == 'url' ? [
637 + $settings = $attachData['type'] == 'url' ? [
456 638 'meta' => $UrlMeta
457 - ] : '';
639 + ] : [];
640 + $settings['board_id'] = absint($boardId);
641 + $attachment->settings = $settings + [
642 + Constant::ATTACHMENT_UPLOAD_BOARD_ID => absint($boardId),
643 + Constant::ATTACHMENT_UPLOAD_USER_ID => get_current_user_id(),
644 + ];
645 + $this->applyCommentImageScope($attachment, $boardId, $taskId);
458 646 $attachment->driver = 'local';
459 647 $attachment->save();
460 648
461 649 return $attachment;
@@ -462,13 +650,14 @@
462 650 }
463 651
464 652 public function createPublicUrl($attachment, $boardId)
465 653 {
654 + $boardId = absint($boardId);
655 +
466 656 return add_query_arg([
467 657 'fbs' => 1,
468 658 'fbs_type' => 'public_url',
469 - 'fbs_bid' => $boardId,
470 - 'fbs_comment_image' => $attachment->file_hash
659 + 'fbs_comment_image' => $attachment->file_hash,
471 660 ], site_url('/index.php'));
472 661 }
473 662
474 663 private function setTitle($type, $title, $UrlMeta)