PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.1.0
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.1.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 1.41 All 42 releases
← All changes | app/Services/CommentService.php +265 -48 1.91.62.1.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,17 +8,41 @@
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 - public function getComments($id, $per_page, $filter)
16 + /**
17 + * Resolve a comment only when its task belongs to the requested board.
18 + *
19 + * @param int $commentId
20 + * @param int $boardId
21 + * @return Comment
22 + * @throws \Exception
23 + */
24 + public function findCommentOnBoard($commentId, $boardId)
15 25 {
16 - $task = Task::findOrFail($id);
26 + $comment = Comment::findOrFail($commentId);
27 + (new TaskService())->findTaskOnBoard($comment->task_id, $boardId);
17 28
29 + if ($comment->board_id && (int) $comment->board_id !== absint($boardId)) {
30 + throw new \Exception(esc_html__('Comment not found', 'fluent-boards'));
31 + }
32 +
33 + return $comment;
34 + }
35 +
36 + /**
37 + * Get paginated parent comments with users, images, and replies preloaded.
38 + */
39 + public function getComments($id, $per_page, $filter, $boardId = null)
40 + {
41 + $task = $boardId ? (new TaskService())->findTaskOnBoard($id, $boardId) : Task::findOrFail($id);
42 +
18 43 $commentsQuery = $task->comments()->whereNull('parent_id')
19 - ->with(['user']);
44 + ->with(['user', 'images', 'replies.user', 'replies.images']);
20 45
21 46 if ($filter == 'oldest') {
22 47 $commentsQuery = $commentsQuery->oldest();
23 48 } else { // latest or newest
@@ -25,19 +50,17 @@
25 50 }
26 51 $comments = $commentsQuery->paginate($per_page);
27 52
28 53 foreach ($comments as $comment) {
29 - $comment->replies = $this->getReplies($comment);
30 54 $comment->replies_count = count($comment->replies);
31 - $comment->load('images');
32 55 }
33 56
34 57 return $comments;
35 58 }
36 59
37 - public function getTotal($id)
60 + public function getTotal($id, $boardId = null)
38 61 {
39 - $task = Task::findOrFail($id);
62 + $task = $boardId ? (new TaskService())->findTaskOnBoard($id, $boardId) : Task::findOrFail($id);
40 63 $totalComment = Comment::where('task_id', $task->id)
41 64 ->type('comment')
42 65 ->count();
43 66 $totalReply = Comment::where('task_id', $task->id)
@@ -52,15 +75,59 @@
52 75 $replies = Comment::where('parent_id', $comment->id)->with(['user'])->get();
53 76 return $replies;
54 77 }
55 78
56 - public function create($commentData, $id)
79 + public function create($commentData, $id, $boardId = null)
57 80 {
81 + if ($boardId) {
82 + (new TaskService())->findTaskOnBoard($id, $boardId);
83 +
84 + if (!empty($commentData['parent_id'])) {
85 + $parentComment = $this->findCommentOnBoard($commentData['parent_id'], $boardId);
86 +
87 + if ((int) $parentComment->task_id !== (int) $id) {
88 + throw new \Exception(esc_html__('Comment not found', 'fluent-boards'));
89 + }
90 + }
91 + }
92 +
58 93 $comment = Comment::create($commentData);
59 94 do_action('fluent_boards/comment_created', $comment);
60 95 return $comment;
61 96 }
62 97
98 + /** Allow only paragraph content and the comment editor's five inline formats. */
99 + public function sanitizeContent($content)
100 + {
101 + if (!is_string($content) || trim(wp_strip_all_tags($content)) === '') {
102 + return '';
103 + }
104 +
105 + return trim(wp_kses($content, [
106 + 'p' => [], 'br' => [], 'strong' => [], 'b' => [],
107 + 'em' => [], 'i' => [], 'del' => [], 's' => [], 'code' => [],
108 + 'a' => ['href' => true, 'title' => true],
109 + ]));
110 + }
111 +
112 + /** Resolve mentions and bare URLs in text without rewriting link attributes or code. */
113 + public function renderContent($content, $mentionData = [])
114 + {
115 + $parts = wp_html_split($this->sanitizeContent($content));
116 + $skipDepth = 0;
117 + foreach ($parts as &$part) {
118 + if (preg_match('~^</?(a|code)\b~i', $part)) {
119 + $skipDepth += strpos($part, '</') === 0 ? -1 : 1;
120 + $skipDepth = max(0, $skipDepth);
121 + } elseif ($skipDepth === 0 && $part !== '' && $part[0] !== '<') {
122 + $part = $this->processMentionAndLink($part, $mentionData);
123 + }
124 + }
125 + unset($part);
126 +
127 + return wp_kses_post(implode('', $parts));
128 + }
129 +
63 130 private function startsWithAt($word) {
64 131 return mb_strpos($word, '@') === 0;
65 132 }
66 133
@@ -140,9 +207,9 @@
140 207 }
141 208
142 209 public function processMentionAndLink($commentDescription, $mentionData = [])
143 210 {
144 - if (empty($commentDescription)) {
211 + if ($commentDescription === '' || $commentDescription === null) {
145 212 return '';
146 213 }
147 214
148 215 try {
@@ -258,51 +325,198 @@
258 325 return $comment; // Return original text if processing fails
259 326 }
260 327 }
261 328
329 + /**
330 + * Retain this comment's images or claim the current user's pending uploads on its board.
331 + * Validate the complete list before changing attachments or removing omitted images.
332 + */
262 333 public function attachCommentImages($comment, $imageIds)
263 334 {
335 + $imageIds = $this->normalizeCommentImageIds($imageIds);
336 + $commentId = absint($comment->id);
337 + $boardId = absint($comment->board_id);
338 + $taskId = absint($comment->task_id);
339 + $userId = get_current_user_id();
264 340
265 - foreach ($imageIds as $imageId)
266 - {
267 - $attachmentObject = CommentImage::findOrFail($imageId);
268 - if($attachmentObject) {
269 - if ($attachmentObject->object_id == $comment->id && $attachmentObject->object_type == Constant::COMMENT_IMAGE) {
341 + if (!$commentId || !$boardId || !$userId) {
342 + throw new RuntimeException(__('Invalid comment image selection.', 'fluent-boards'));
343 + }
344 +
345 + App::getInstance('db')->transaction(function () use ($imageIds, $commentId, $boardId, $taskId, $userId) {
346 + // Serialize edits to this comment and prevent concurrent claims of the same upload.
347 + Comment::withoutGlobalScopes()->where('id', $commentId)->where('board_id', $boardId)->lockForUpdate()->firstOrFail();
348 + $images = CommentImage::whereIn('id', $imageIds)
349 + ->where('object_type', Constant::COMMENT_IMAGE)
350 + ->orderBy('id')
351 + ->lockForUpdate()
352 + ->get();
353 +
354 + if ($images->count() !== count($imageIds)) {
355 + throw new RuntimeException(__('Invalid comment image selection.', 'fluent-boards'));
356 + }
357 +
358 + foreach ($images as $image) {
359 + if ((int) $image->object_id === $commentId) {
270 360 continue;
271 361 }
272 - $attachmentObject->object_id = $comment->id;
273 - $attachmentObject->object_type = Constant::COMMENT_IMAGE;
274 - $attachmentObject->save();
362 +
363 + if ((int) $image->object_id !== 0
364 + || !$this->commentImageScopeMatches($image, $boardId, $taskId, $userId)) {
365 + throw new RuntimeException(__('Invalid comment image selection.', 'fluent-boards'));
366 + }
275 367 }
368 +
369 + foreach ($images as $image) {
370 + if ((int) $image->object_id === 0) {
371 + $image->object_id = $commentId;
372 + if (!$image->save()) {
373 + throw new RuntimeException(__('Could not attach comment image.', 'fluent-boards'));
374 + }
375 + }
376 + }
377 +
378 + $removedImages = CommentImage::where('object_id', $commentId)
379 + ->where('object_type', Constant::COMMENT_IMAGE)
380 + ->whereNotIn('id', $imageIds)
381 + ->get();
382 +
383 + foreach ($removedImages as $image) {
384 + if (!$image->delete()) {
385 + throw new RuntimeException(__('Could not remove comment image.', 'fluent-boards'));
386 + }
387 + }
388 + });
389 + }
390 +
391 + /**
392 + * Allow retained images on this comment and validate all newly supplied uploads.
393 + */
394 + public function assertCommentImagesAttachableForComment($comment, $imageIds)
395 + {
396 + $imageIds = $this->normalizeCommentImageIds($imageIds);
397 +
398 + if (empty($imageIds)) {
399 + return [];
276 400 }
277 - //if(in_array("banana", $imageIds))
278 - $commentImages = CommentImage::where('object_id', $comment->id)->where('object_type', Constant::COMMENT_IMAGE)->get();
279 401
402 + $commentImages = CommentImage::where('object_id', $comment->id)
403 + ->where('object_type', Constant::COMMENT_IMAGE)
404 + ->get();
405 +
280 406 foreach ($commentImages as $commentImage) {
281 - if(!in_array($commentImage->id, $imageIds)) {
282 - $deletedImage = clone $commentImage;
283 - $commentImage->delete();
284 - //do_action('fluent_boards/comment_image_deleted', $deletedImage);
407 + $key = array_search((int) $commentImage->id, $imageIds, true);
408 + if ($key !== false) {
409 + unset($imageIds[$key]);
285 410 }
286 411 }
412 +
413 + return $this->assertCommentImagesAttachable(
414 + array_values($imageIds),
415 + $comment->board_id,
416 + $comment->task_id
417 + );
287 418 }
288 419
289 - public function update($commentData, $comment_id, $mentionData)
420 + /**
421 + * Reject the entire image list unless every upload is unbound and owned by this actor, board, and task.
422 + */
423 + public function assertCommentImagesAttachable($imageIds, $boardId, $taskId)
290 424 {
291 - $comment = Comment::findOrFail($comment_id);
425 + $imageIds = $this->normalizeCommentImageIds($imageIds);
292 426
427 + if (empty($imageIds)) {
428 + return [];
429 + }
430 +
431 + $currentUserId = get_current_user_id();
432 + if (!$currentUserId) {
433 + throw new \Exception(esc_html__('Invalid comment image attachment', 'fluent-boards'));
434 + }
435 +
436 + $attachments = CommentImage::whereIn('id', $imageIds)
437 + ->where('object_id', 0)
438 + ->where('object_type', Constant::COMMENT_IMAGE)
439 + ->get();
440 +
441 + if (count($attachments) !== count($imageIds)) {
442 + throw new \Exception(esc_html__('Invalid comment image attachment', 'fluent-boards'));
443 + }
444 +
445 + foreach ($attachments as $attachment) {
446 + if (!$this->commentImageScopeMatches($attachment, $boardId, $taskId, $currentUserId)) {
447 + throw new \Exception(esc_html__('Invalid comment image attachment', 'fluent-boards'));
448 + }
449 + }
450 +
451 + return $attachments;
452 + }
453 +
454 + /**
455 + * Fail closed for legacy uploads without recorded board, task, and uploader ownership.
456 + */
457 + private function commentImageScopeMatches($attachment, $boardId, $taskId, $userId)
458 + {
459 + $settings = is_array($attachment->settings) ? $attachment->settings : [];
460 + $scope = isset($settings['comment_image_scope']) && is_array($settings['comment_image_scope'])
461 + ? $settings['comment_image_scope']
462 + : [];
463 +
464 + return intval($scope['board_id'] ?? 0) === intval($boardId)
465 + && intval($scope['task_id'] ?? 0) === intval($taskId)
466 + && intval($scope['created_by'] ?? 0) === intval($userId);
467 + }
468 +
469 + /**
470 + * Record trusted upload ownership in attachment metadata before saving.
471 + */
472 + public function applyCommentImageScope($attachment, $boardId, $taskId, $createdBy = null)
473 + {
474 + $settings = is_array($attachment->settings) ? $attachment->settings : [];
475 + $settings['comment_image_scope'] = [
476 + 'board_id' => intval($boardId),
477 + 'task_id' => intval($taskId),
478 + 'created_by' => intval($createdBy === null ? get_current_user_id() : $createdBy),
479 + ];
480 + $attachment->settings = $settings;
481 +
482 + return $attachment;
483 + }
484 +
485 + /**
486 + * Normalize submitted image IDs and remove duplicates before validating the full list.
487 + */
488 + private function normalizeCommentImageIds($imageIds)
489 + {
490 + if (!is_array($imageIds)) {
491 + return [];
492 + }
493 +
494 + return array_values(array_filter(array_unique(array_map('intval', $imageIds))));
495 + }
496 +
497 + public function update($commentData, $comment_id, $mentionData, $boardId = null)
498 + {
499 + $comment = $boardId ? $this->findCommentOnBoard($comment_id, $boardId) : Comment::findOrFail($comment_id);
500 +
293 501 if ($comment->created_by != get_current_user_id()) {
294 502 return false;
295 503 }
296 504
297 - $allMentionedIds = array_unique(array_merge($comment->settings['mentioned_id'] ?? [], is_array($mentionData) ? $mentionData : []));
505 + $effectiveBoardId = absint($comment->board_id ?: $boardId);
506 + $notificationService = new NotificationService();
507 + $existingMentionedIds = array_values(array_unique(array_filter(array_map('absint', (array) ($comment->settings['mentioned_id'] ?? [])))));
508 + $newMentionedIds = array_values(array_unique(array_filter(array_map('absint', (array) $mentionData))));
509 + $requestedMentionedIds = array_values(array_unique(array_merge($existingMentionedIds, $newMentionedIds)));
510 + $allMentionedIds = $notificationService->resolveBoardMentionUserIds($effectiveBoardId, $requestedMentionedIds);
298 511
299 - if ($allMentionedIds) {
300 - $processedDescription = $this->processMentionAndLink($commentData['description'], $allMentionedIds);
301 - } elseif(!$allMentionedIds) {
302 - $processedDescription = $this->checkIfCommentHaveLinks($commentData['description']);
512 + if (array_diff($newMentionedIds, $allMentionedIds)) {
513 + throw new \Exception(esc_html__('One or more mentioned users are not members of this board', 'fluent-boards'), 403);
303 514 }
304 515
516 + $commentData['description'] = $this->sanitizeContent($commentData['description']);
517 + $processedDescription = $this->renderContent($commentData['description'], $allMentionedIds);
518 +
305 519 $oldComment = $comment->settings['raw_description'] ?? $comment->description;
306 520 $comment->description = $processedDescription;
307 521
308 522 if($comment->settings != null)
@@ -325,11 +539,11 @@
325 539
326 540 return $comment;
327 541 }
328 542
329 - public function delete($comment_id)
543 + public function delete($comment_id, $boardId = null)
330 544 {
331 - $comment = Comment::findOrFail($comment_id);
545 + $comment = $boardId ? $this->findCommentOnBoard($comment_id, $boardId) : Comment::findOrFail($comment_id);
332 546
333 547 if ($comment->created_by != get_current_user_id()) {
334 548 return false;
335 549 }
@@ -353,11 +567,11 @@
353 567 $reply->delete();
354 568 }
355 569 }
356 570
357 - public function updateReply($replyData, $id)
571 + public function updateReply($replyData, $id, $boardId = null)
358 572 {
359 - $reply = Comment::findOrFail($id);
573 + $reply = $boardId ? $this->findCommentOnBoard($id, $boardId) : Comment::findOrFail($id);
360 574
361 575 if ($reply->created_by != get_current_user_id()) {
362 576 return false;
363 577 }
@@ -369,11 +583,11 @@
369 583
370 584 return $reply;
371 585 }
372 586
373 - public function deleteReply($id)
587 + public function deleteReply($id, $boardId = null)
374 588 {
375 - $reply = Comment::findOrFail($id);
589 + $reply = $boardId ? $this->findCommentOnBoard($id, $boardId) : Comment::findOrFail($id);
376 590 // $taskId = $reply->task_id;
377 591
378 592 if ($reply->created_by != get_current_user_id()) {
379 593 return false;
@@ -385,18 +599,14 @@
385 599 // do_action('fluent_boards/comment_deleted', $taskId);
386 600 }
387 601
388 602 /**
389 - * Adds a task attachment to the specified task.
603 + * Persist an unbound comment upload with trusted board, task, and uploader metadata.
604 + * Legacy uploads without this scope cannot be newly attached to a comment.
390 605 *
391 - * @param int $taskId The ID of the task to which the attachment is added.
392 - * @param string $title The title of the attachment.
393 - * @param string $url The URL of the attachment.
394 - *
395 - * @return Attachment The updated list of task attachments.
396 - * @throws \Exception
606 + * @return CommentImage
397 607 */
398 - public function createCommentImage($data, $boardId)
608 + public function createCommentImage($data, $boardId, $taskId = null)
399 609 {
400 610 /*
401 611 * I will refactor this function later- within March 2024 Last Week
402 612 */
@@ -419,11 +629,17 @@
419 629 $attachment->title = $this->setTitle($attachData['type'], $attachData['name'], $UrlMeta);
420 630 $attachment->file_path = $attachData['type'] != 'url' ? $attachData['file'] : null;
421 631 $attachment->full_url = esc_url($attachData['url']);
422 632 $attachment->file_size = $attachData['size'];
423 - $attachment->settings = $attachData['type'] == 'url' ? [
633 + $settings = $attachData['type'] == 'url' ? [
424 634 'meta' => $UrlMeta
425 - ] : '';
635 + ] : [];
636 + $settings['board_id'] = absint($boardId);
637 + $attachment->settings = $settings + [
638 + Constant::ATTACHMENT_UPLOAD_BOARD_ID => absint($boardId),
639 + Constant::ATTACHMENT_UPLOAD_USER_ID => get_current_user_id(),
640 + ];
641 + $this->applyCommentImageScope($attachment, $boardId, $taskId);
426 642 $attachment->driver = 'local';
427 643 $attachment->save();
428 644
429 645 return $attachment;
@@ -430,13 +646,14 @@
430 646 }
431 647
432 648 public function createPublicUrl($attachment, $boardId)
433 649 {
650 + $boardId = absint($boardId);
651 +
434 652 return add_query_arg([
435 653 'fbs' => 1,
436 654 'fbs_type' => 'public_url',
437 - 'fbs_bid' => $boardId,
438 - 'fbs_comment_image' => $attachment->file_hash
655 + 'fbs_comment_image' => $attachment->file_hash,
439 656 ], site_url('/index.php'));
440 657 }
441 658
442 659 private function setTitle($type, $title, $UrlMeta)