| @@ -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 { |
| @@ -292,47 +326,67 @@ | ||
| 292 | 326 | } |
| 293 | 327 | } |
| 294 | 328 | |
| 295 | 329 | /** |
| 296 | - * Validate every new image before attaching uploads or removing existing images. | |
| 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. | |
| 297 | 332 | */ |
| 298 | 333 | public function attachCommentImages($comment, $imageIds) |
| 299 | 334 | { |
| 300 | 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(); | |
| 301 | 340 | |
| 302 | - $commentImages = CommentImage::where('object_id', $comment->id) | |
| 303 | - ->where('object_type', Constant::COMMENT_IMAGE) | |
| 304 | - ->get(); | |
| 341 | + if (!$commentId || !$boardId || !$userId) { | |
| 342 | + throw new RuntimeException(__('Invalid comment image selection.', 'fluent-boards')); | |
| 343 | + } | |
| 305 | 344 | |
| 306 | - $attachedImageIds = []; | |
| 307 | - foreach ($commentImages as $commentImage) { | |
| 308 | - if (in_array((int) $commentImage->id, $imageIds, true)) { | |
| 309 | - $attachedImageIds[] = (int) $commentImage->id; | |
| 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')); | |
| 310 | 356 | } |
| 311 | - } | |
| 312 | 357 | |
| 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 | - ); | |
| 358 | + foreach ($images as $image) { | |
| 359 | + if ((int) $image->object_id === $commentId) { | |
| 360 | + continue; | |
| 361 | + } | |
| 320 | 362 | |
| 321 | - foreach ($attachmentObjects as $attachmentObject) { | |
| 322 | - $attachmentObject->object_id = $comment->id; | |
| 323 | - $attachmentObject->object_type = Constant::COMMENT_IMAGE; | |
| 324 | - $attachmentObject->save(); | |
| 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 | + } | |
| 325 | 367 | } |
| 326 | - } | |
| 327 | 368 | |
| 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); | |
| 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 | + } | |
| 333 | 376 | } |
| 334 | - } | |
| 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 | + }); | |
| 335 | 389 | } |
| 336 | 390 | |
| 337 | 391 | /** |
| 338 | 392 | * Allow retained images on this comment and validate all newly supplied uploads. |
| @@ -458,13 +512,10 @@ | ||
| 458 | 512 | if (array_diff($newMentionedIds, $allMentionedIds)) { |
| 459 | 513 | throw new \Exception(esc_html__('One or more mentioned users are not members of this board', 'fluent-boards'), 403); |
| 460 | 514 | } |
| 461 | 515 | |
| 462 | - if ($allMentionedIds) { | |
| 463 | - $processedDescription = $this->processMentionAndLink($commentData['description'], $allMentionedIds); | |
| 464 | - } elseif(!$allMentionedIds) { | |
| 465 | - $processedDescription = $this->checkIfCommentHaveLinks($commentData['description']); | |
| 466 | - } | |
| 516 | + $commentData['description'] = $this->sanitizeContent($commentData['description']); | |
| 517 | + $processedDescription = $this->renderContent($commentData['description'], $allMentionedIds); | |
| 467 | 518 | |
| 468 | 519 | $oldComment = $comment->settings['raw_description'] ?? $comment->description; |
| 469 | 520 | $comment->description = $processedDescription; |
| 470 | 521 | |
| @@ -582,9 +633,12 @@ | ||
| 582 | 633 | $settings = $attachData['type'] == 'url' ? [ |
| 583 | 634 | 'meta' => $UrlMeta |
| 584 | 635 | ] : []; |
| 585 | 636 | $settings['board_id'] = absint($boardId); |
| 586 | - $attachment->settings = $settings; | |
| 637 | + $attachment->settings = $settings + [ | |
| 638 | + Constant::ATTACHMENT_UPLOAD_BOARD_ID => absint($boardId), | |
| 639 | + Constant::ATTACHMENT_UPLOAD_USER_ID => get_current_user_id(), | |
| 640 | + ]; | |
| 587 | 641 | $this->applyCommentImageScope($attachment, $boardId, $taskId); |
| 588 | 642 | $attachment->driver = 'local'; |
| 589 | 643 | $attachment->save(); |
| 590 | 644 | |
| @@ -592,12 +646,14 @@ | ||
| 592 | 646 | } |
| 593 | 647 | |
| 594 | 648 | public function createPublicUrl($attachment, $boardId) |
| 595 | 649 | { |
| 650 | + $boardId = absint($boardId); | |
| 651 | + | |
| 596 | 652 | return add_query_arg([ |
| 597 | 653 | 'fbs' => 1, |
| 598 | 654 | 'fbs_type' => 'public_url', |
| 599 | - 'fbs_comment_image' => $attachment->file_hash | |
| 655 | + 'fbs_comment_image' => $attachment->file_hash, | |
| 600 | 656 | ], site_url('/index.php')); |
| 601 | 657 | } |
| 602 | 658 | |
| 603 | 659 | private function setTitle($type, $title, $UrlMeta) |