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 +220 -32 2.0.4 → 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 {
@@ -291,35 +329,176 @@
291 329 return $comment; // Return original text if processing fails
292 330 }
293 331 }
294 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 + */
295 337 public function attachCommentImages($comment, $imageIds)
296 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();
297 344
298 - foreach ($imageIds as $imageId)
299 - {
300 - $attachmentObject = CommentImage::findOrFail($imageId);
301 - if($attachmentObject) {
302 - 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) {
303 364 continue;
304 365 }
305 - $attachmentObject->object_id = $comment->id;
306 - $attachmentObject->object_type = Constant::COMMENT_IMAGE;
307 - $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 + }
308 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 [];
309 404 }
310 - //if(in_array("banana", $imageIds))
311 - $commentImages = CommentImage::where('object_id', $comment->id)->where('object_type', Constant::COMMENT_IMAGE)->get();
312 405
406 + $commentImages = CommentImage::where('object_id', $comment->id)
407 + ->where('object_type', Constant::COMMENT_IMAGE)
408 + ->get();
409 +
313 410 foreach ($commentImages as $commentImage) {
314 - if(!in_array($commentImage->id, $imageIds)) {
315 - $deletedImage = clone $commentImage;
316 - $commentImage->delete();
317 - //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]);
318 414 }
319 415 }
416 +
417 + return $this->assertCommentImagesAttachable(
418 + array_values($imageIds),
419 + $comment->board_id,
420 + $comment->task_id
421 + );
320 422 }
321 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 +
322 501 public function update($commentData, $comment_id, $mentionData, $boardId = null)
323 502 {
324 503 $comment = $boardId ? $this->findCommentOnBoard($comment_id, $boardId) : Comment::findOrFail($comment_id);
325 504
@@ -326,16 +505,22 @@
326 505 if ($comment->created_by != get_current_user_id()) {
327 506 return false;
328 507 }
329 508
330 - $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);
331 515
332 - if ($allMentionedIds) {
333 - $processedDescription = $this->processMentionAndLink($commentData['description'], $allMentionedIds);
334 - } elseif(!$allMentionedIds) {
335 - $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);
336 518 }
337 519
520 + $commentData['description'] = $this->sanitizeContent($commentData['description']);
521 + $processedDescription = $this->renderContent($commentData['description'], $allMentionedIds);
522 +
338 523 $oldComment = $comment->settings['raw_description'] ?? $comment->description;
339 524 $comment->description = $processedDescription;
340 525
341 526 if($comment->settings != null)
@@ -418,18 +603,14 @@
418 603 // do_action('fluent_boards/comment_deleted', $taskId);
419 604 }
420 605
421 606 /**
422 - * 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.
423 609 *
424 - * @param int $taskId The ID of the task to which the attachment is added.
425 - * @param string $title The title of the attachment.
426 - * @param string $url The URL of the attachment.
427 - *
428 - * @return Attachment The updated list of task attachments.
429 - * @throws \Exception
610 + * @return CommentImage
430 611 */
431 - public function createCommentImage($data, $boardId)
612 + public function createCommentImage($data, $boardId, $taskId = null)
432 613 {
433 614 /*
434 615 * I will refactor this function later- within March 2024 Last Week
435 616 */
@@ -452,11 +633,17 @@
452 633 $attachment->title = $this->setTitle($attachData['type'], $attachData['name'], $UrlMeta);
453 634 $attachment->file_path = $attachData['type'] != 'url' ? $attachData['file'] : null;
454 635 $attachment->full_url = esc_url($attachData['url']);
455 636 $attachment->file_size = $attachData['size'];
456 - $attachment->settings = $attachData['type'] == 'url' ? [
637 + $settings = $attachData['type'] == 'url' ? [
457 638 'meta' => $UrlMeta
458 - ] : '';
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);
459 646 $attachment->driver = 'local';
460 647 $attachment->save();
461 648
462 649 return $attachment;
@@ -463,13 +650,14 @@
463 650 }
464 651
465 652 public function createPublicUrl($attachment, $boardId)
466 653 {
654 + $boardId = absint($boardId);
655 +
467 656 return add_query_arg([
468 657 'fbs' => 1,
469 658 'fbs_type' => 'public_url',
470 - 'fbs_bid' => $boardId,
471 - 'fbs_comment_image' => $attachment->file_hash
659 + 'fbs_comment_image' => $attachment->file_hash,
472 660 ], site_url('/index.php'));
473 661 }
474 662
475 663 private function setTitle($type, $title, $UrlMeta)