| @@ -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 { |
| @@ -292,47 +330,67 @@ | ||
| 292 | 330 | } |
| 293 | 331 | } |
| 294 | 332 | |
| 295 | 333 | /** |
| 296 | - * Validate every new image before attaching uploads or removing existing images. | |
| 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. | |
| 297 | 336 | */ |
| 298 | 337 | public function attachCommentImages($comment, $imageIds) |
| 299 | 338 | { |
| 300 | 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(); | |
| 301 | 344 | |
| 302 | - $commentImages = CommentImage::where('object_id', $comment->id) | |
| 303 | - ->where('object_type', Constant::COMMENT_IMAGE) | |
| 304 | - ->get(); | |
| 345 | + if (!$commentId || !$boardId || !$userId) { | |
| 346 | + throw new RuntimeException(__('Invalid comment image selection.', 'fluent-boards')); | |
| 347 | + } | |
| 305 | 348 | |
| 306 | - $attachedImageIds = []; | |
| 307 | - foreach ($commentImages as $commentImage) { | |
| 308 | - if (in_array((int) $commentImage->id, $imageIds, true)) { | |
| 309 | - $attachedImageIds[] = (int) $commentImage->id; | |
| 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')); | |
| 310 | 360 | } |
| 311 | - } | |
| 312 | 361 | |
| 313 | - $newImageIds = array_values(array_diff($imageIds, $attachedImageIds)); | |
| 314 | - if (!empty($newImageIds)) { | |
| 315 | - $attachmentObjects = $this->assertCommentImagesAttachable( | |
| 316 | - $newImageIds, | |
| 317 | - $comment->board_id, | |
| 318 | - $comment->task_id | |
| 319 | - ); | |
| 362 | + foreach ($images as $image) { | |
| 363 | + if ((int) $image->object_id === $commentId) { | |
| 364 | + continue; | |
| 365 | + } | |
| 320 | 366 | |
| 321 | - foreach ($attachmentObjects as $attachmentObject) { | |
| 322 | - $attachmentObject->object_id = $comment->id; | |
| 323 | - $attachmentObject->object_type = Constant::COMMENT_IMAGE; | |
| 324 | - $attachmentObject->save(); | |
| 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 | + } | |
| 325 | 371 | } |
| 326 | - } | |
| 327 | 372 | |
| 328 | - foreach ($commentImages as $commentImage) { | |
| 329 | - if (!in_array((int) $commentImage->id, $imageIds, true)) { | |
| 330 | - $deletedImage = clone $commentImage; | |
| 331 | - $commentImage->delete(); | |
| 332 | - //do_action('fluent_boards/comment_image_deleted', $deletedImage); | |
| 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 | + } | |
| 333 | 380 | } |
| 334 | - } | |
| 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 | + }); | |
| 335 | 393 | } |
| 336 | 394 | |
| 337 | 395 | /** |
| 338 | 396 | * Allow retained images on this comment and validate all newly supplied uploads. |
| @@ -458,13 +516,10 @@ | ||
| 458 | 516 | if (array_diff($newMentionedIds, $allMentionedIds)) { |
| 459 | 517 | throw new \Exception(esc_html__('One or more mentioned users are not members of this board', 'fluent-boards'), 403); |
| 460 | 518 | } |
| 461 | 519 | |
| 462 | - if ($allMentionedIds) { | |
| 463 | - $processedDescription = $this->processMentionAndLink($commentData['description'], $allMentionedIds); | |
| 464 | - } elseif(!$allMentionedIds) { | |
| 465 | - $processedDescription = $this->checkIfCommentHaveLinks($commentData['description']); | |
| 466 | - } | |
| 520 | + $commentData['description'] = $this->sanitizeContent($commentData['description']); | |
| 521 | + $processedDescription = $this->renderContent($commentData['description'], $allMentionedIds); | |
| 467 | 522 | |
| 468 | 523 | $oldComment = $comment->settings['raw_description'] ?? $comment->description; |
| 469 | 524 | $comment->description = $processedDescription; |
| 470 | 525 | |
| @@ -582,9 +637,12 @@ | ||
| 582 | 637 | $settings = $attachData['type'] == 'url' ? [ |
| 583 | 638 | 'meta' => $UrlMeta |
| 584 | 639 | ] : []; |
| 585 | 640 | $settings['board_id'] = absint($boardId); |
| 586 | - $attachment->settings = $settings; | |
| 641 | + $attachment->settings = $settings + [ | |
| 642 | + Constant::ATTACHMENT_UPLOAD_BOARD_ID => absint($boardId), | |
| 643 | + Constant::ATTACHMENT_UPLOAD_USER_ID => get_current_user_id(), | |
| 644 | + ]; | |
| 587 | 645 | $this->applyCommentImageScope($attachment, $boardId, $taskId); |
| 588 | 646 | $attachment->driver = 'local'; |
| 589 | 647 | $attachment->save(); |
| 590 | 648 | |
| @@ -592,12 +650,14 @@ | ||
| 592 | 650 | } |
| 593 | 651 | |
| 594 | 652 | public function createPublicUrl($attachment, $boardId) |
| 595 | 653 | { |
| 654 | + $boardId = absint($boardId); | |
| 655 | + | |
| 596 | 656 | return add_query_arg([ |
| 597 | 657 | 'fbs' => 1, |
| 598 | 658 | 'fbs_type' => 'public_url', |
| 599 | - 'fbs_comment_image' => $attachment->file_hash | |
| 659 | + 'fbs_comment_image' => $attachment->file_hash, | |
| 600 | 660 | ], site_url('/index.php')); |
| 601 | 661 | } |
| 602 | 662 | |
| 603 | 663 | private function setTitle($type, $title, $UrlMeta) |