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