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 +220 -35 1.952.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,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,17 @@
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 {
36 41 $task = $boardId ? (new TaskService())->findTaskOnBoard($id, $boardId) : Task::findOrFail($id);
37 42
38 43 $commentsQuery = $task->comments()->whereNull('parent_id')
39 - ->with(['user']);
44 + ->with(['user', 'images', 'replies.user', 'replies.images']);
40 45
41 46 if ($filter == 'oldest') {
42 47 $commentsQuery = $commentsQuery->oldest();
43 48 } else { // latest or newest
@@ -45,11 +50,9 @@
45 50 }
46 51 $comments = $commentsQuery->paginate($per_page);
47 52
48 53 foreach ($comments as $comment) {
49 - $comment->replies = $this->getReplies($comment);
50 54 $comment->replies_count = count($comment->replies);
51 - $comment->load('images');
52 55 }
53 56
54 57 return $comments;
55 58 }
@@ -91,8 +94,40 @@
91 94 do_action('fluent_boards/comment_created', $comment);
92 95 return $comment;
93 96 }
94 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 +
95 130 private function startsWithAt($word) {
96 131 return mb_strpos($word, '@') === 0;
97 132 }
98 133
@@ -172,9 +207,9 @@
172 207 }
173 208
174 209 public function processMentionAndLink($commentDescription, $mentionData = [])
175 210 {
176 - if (empty($commentDescription)) {
211 + if ($commentDescription === '' || $commentDescription === null) {
177 212 return '';
178 213 }
179 214
180 215 try {
@@ -290,35 +325,176 @@
290 325 return $comment; // Return original text if processing fails
291 326 }
292 327 }
293 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 + */
294 333 public function attachCommentImages($comment, $imageIds)
295 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();
296 340
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) {
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) {
302 360 continue;
303 361 }
304 - $attachmentObject->object_id = $comment->id;
305 - $attachmentObject->object_type = Constant::COMMENT_IMAGE;
306 - $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 + }
307 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 [];
308 400 }
309 - //if(in_array("banana", $imageIds))
310 - $commentImages = CommentImage::where('object_id', $comment->id)->where('object_type', Constant::COMMENT_IMAGE)->get();
311 401
402 + $commentImages = CommentImage::where('object_id', $comment->id)
403 + ->where('object_type', Constant::COMMENT_IMAGE)
404 + ->get();
405 +
312 406 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);
407 + $key = array_search((int) $commentImage->id, $imageIds, true);
408 + if ($key !== false) {
409 + unset($imageIds[$key]);
317 410 }
318 411 }
412 +
413 + return $this->assertCommentImagesAttachable(
414 + array_values($imageIds),
415 + $comment->board_id,
416 + $comment->task_id
417 + );
319 418 }
320 419
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)
424 + {
425 + $imageIds = $this->normalizeCommentImageIds($imageIds);
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 +
321 497 public function update($commentData, $comment_id, $mentionData, $boardId = null)
322 498 {
323 499 $comment = $boardId ? $this->findCommentOnBoard($comment_id, $boardId) : Comment::findOrFail($comment_id);
324 500
@@ -325,16 +501,22 @@
325 501 if ($comment->created_by != get_current_user_id()) {
326 502 return false;
327 503 }
328 504
329 - $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);
330 511
331 - if ($allMentionedIds) {
332 - $processedDescription = $this->processMentionAndLink($commentData['description'], $allMentionedIds);
333 - } elseif(!$allMentionedIds) {
334 - $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);
335 514 }
336 515
516 + $commentData['description'] = $this->sanitizeContent($commentData['description']);
517 + $processedDescription = $this->renderContent($commentData['description'], $allMentionedIds);
518 +
337 519 $oldComment = $comment->settings['raw_description'] ?? $comment->description;
338 520 $comment->description = $processedDescription;
339 521
340 522 if($comment->settings != null)
@@ -417,18 +599,14 @@
417 599 // do_action('fluent_boards/comment_deleted', $taskId);
418 600 }
419 601
420 602 /**
421 - * 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.
422 605 *
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
606 + * @return CommentImage
429 607 */
430 - public function createCommentImage($data, $boardId)
608 + public function createCommentImage($data, $boardId, $taskId = null)
431 609 {
432 610 /*
433 611 * I will refactor this function later- within March 2024 Last Week
434 612 */
@@ -451,11 +629,17 @@
451 629 $attachment->title = $this->setTitle($attachData['type'], $attachData['name'], $UrlMeta);
452 630 $attachment->file_path = $attachData['type'] != 'url' ? $attachData['file'] : null;
453 631 $attachment->full_url = esc_url($attachData['url']);
454 632 $attachment->file_size = $attachData['size'];
455 - $attachment->settings = $attachData['type'] == 'url' ? [
633 + $settings = $attachData['type'] == 'url' ? [
456 634 'meta' => $UrlMeta
457 - ] : '';
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);
458 642 $attachment->driver = 'local';
459 643 $attachment->save();
460 644
461 645 return $attachment;
@@ -462,13 +646,14 @@
462 646 }
463 647
464 648 public function createPublicUrl($attachment, $boardId)
465 649 {
650 + $boardId = absint($boardId);
651 +
466 652 return add_query_arg([
467 653 'fbs' => 1,
468 654 'fbs_type' => 'public_url',
469 - 'fbs_bid' => $boardId,
470 - 'fbs_comment_image' => $attachment->file_hash
655 + 'fbs_comment_image' => $attachment->file_hash,
471 656 ], site_url('/index.php'));
472 657 }
473 658
474 659 private function setTitle($type, $title, $UrlMeta)