PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / trunk
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration vtrunk
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 1.45 All 41 releases
← All changes | app/Services/CommentService.php +504 -32 1.13trunk View file →
@@ -2,19 +2,45 @@
2 2
3 3 namespace FluentBoards\App\Services;
4 4
5 5 use FluentBoards\App\Models\Comment;
6 +use FluentBoards\App\Models\CommentImage;
6 7 use FluentBoards\App\Models\Task;
7 8 use FluentBoards\App\Models\TaskActivity;
9 +use FluentBoardsPro\App\Services\AttachmentService;
10 +use FluentBoardsPro\App\Services\RemoteUrlParser;
8 11
9 12 class CommentService
10 13 {
11 - public function getComments($id, $per_page, $filter)
14 + /**
15 + * Resolve a comment only when its task belongs to the requested board.
16 + *
17 + * @param int $commentId
18 + * @param int $boardId
19 + * @return Comment
20 + * @throws \Exception
21 + */
22 + public function findCommentOnBoard($commentId, $boardId)
12 23 {
13 - $task = Task::findOrFail($id);
24 + $comment = Comment::findOrFail($commentId);
25 + (new TaskService())->findTaskOnBoard($comment->task_id, $boardId);
14 26
27 + if ($comment->board_id && (int) $comment->board_id !== absint($boardId)) {
28 + throw new \Exception(esc_html__('Comment not found', 'fluent-boards'));
29 + }
30 +
31 + return $comment;
32 + }
33 +
34 + /**
35 + * Get paginated parent comments with users, images, and replies preloaded.
36 + */
37 + public function getComments($id, $per_page, $filter, $boardId = null)
38 + {
39 + $task = $boardId ? (new TaskService())->findTaskOnBoard($id, $boardId) : Task::findOrFail($id);
40 +
15 41 $commentsQuery = $task->comments()->whereNull('parent_id')
16 - ->with(['user']);
42 + ->with(['user', 'images', 'replies.user', 'replies.images']);
17 43
18 44 if ($filter == 'oldest') {
19 45 $commentsQuery = $commentsQuery->oldest();
20 46 } else { // latest or newest
@@ -22,9 +48,8 @@
22 48 }
23 49 $comments = $commentsQuery->paginate($per_page);
24 50
25 51 foreach ($comments as $comment) {
26 - $comment->replies = $this->getReplies($comment);
27 52 $comment->replies_count = count($comment->replies);
28 53 }
29 54
30 55 return $comments;
@@ -29,11 +54,11 @@
29 54
30 55 return $comments;
31 56 }
32 57
33 - public function getTotal($id)
58 + public function getTotal($id, $boardId = null)
34 59 {
35 - $task = Task::findOrFail($id);
60 + $task = $boardId ? (new TaskService())->findTaskOnBoard($id, $boardId) : Task::findOrFail($id);
36 61 $totalComment = Comment::where('task_id', $task->id)
37 62 ->type('comment')
38 63 ->count();
39 64 $totalReply = Comment::where('task_id', $task->id)
@@ -48,55 +73,440 @@
48 73 $replies = Comment::where('parent_id', $comment->id)->with(['user'])->get();
49 74 return $replies;
50 75 }
51 76
52 - public function create($commentData, $id)
77 + public function create($commentData, $id, $boardId = null)
53 78 {
79 + if ($boardId) {
80 + (new TaskService())->findTaskOnBoard($id, $boardId);
81 +
82 + if (!empty($commentData['parent_id'])) {
83 + $parentComment = $this->findCommentOnBoard($commentData['parent_id'], $boardId);
84 +
85 + if ((int) $parentComment->task_id !== (int) $id) {
86 + throw new \Exception(esc_html__('Comment not found', 'fluent-boards'));
87 + }
88 + }
89 + }
90 +
54 91 $comment = Comment::create($commentData);
55 - do_action('fluent_boards/task_comment_added', $comment);
92 + do_action('fluent_boards/comment_created', $comment);
56 93 return $comment;
57 94 }
58 95
59 - public function update($commentData, $comment_id)
96 + private function startsWithAt($word) {
97 + return mb_strpos($word, '@') === 0;
98 + }
99 +
100 + private function isValidUrl($url)
60 101 {
61 - $comment = Comment::findOrFail($comment_id);
102 + try {
103 + if (empty($url)) {
104 + return false;
105 + }
62 106
63 - if ($comment->created_by != get_current_user_id()) {
107 + $url = trim($url);
108 +
109 + // Early return for obviously invalid formats
110 + if ($url === 'http://' || $url === 'https://') {
111 + return false;
112 + }
113 +
114 + // Handle www. URLs
115 + if (strpos($url, 'www.') === 0) {
116 + $url = 'http://' . $url;
117 + }
118 + // If it's not already a URL, make it one
119 + elseif (!preg_match('~^(?:f|ht)tps?://~i', $url)) {
120 + $url = 'https://' . $url;
121 + }
122 +
123 + $components = wp_parse_url($url);
124 +
125 + if (empty($components) || !isset($components['host'])) {
126 + return false;
127 + }
128 +
129 + // Additional validation with filter_var
130 + $isValid = filter_var($url, FILTER_VALIDATE_URL) !== false;
131 + return $isValid;
132 +
133 + } catch (\Exception $e) {
64 134 return false;
65 135 }
136 + }
66 137
67 - $oldComment = $comment->description;
68 - $comment->description = $commentData['description'];
69 - $comment->save();
70 - do_action('fluent_boards/task_comment_updated', $comment->task_id, $oldComment, $comment->description);
138 + private function extractUrls($text) {
139 + try {
140 + // More permissive URL pattern that handles international domains and various formats
141 + $urlPattern = '%\b(?:(?:https?|ftp):\/\/|www\.)[^\s<>\[\]{}"\']+'
142 + . '(?:\([^\s<>\[\]{}"\')]*\)|[^\s<>\[\]{}"\'\)])*%iu';
143 +
144 + if (preg_match_all($urlPattern, $text, $matches)) {
145 + $urls = array_filter($matches[0], function($url) {
146 + $trimmed = trim($url);
147 + return !empty($trimmed);
148 + });
149 + return array_values($urls); // Re-index array
150 + }
151 + return [];
152 + } catch (\Exception $e) {
153 + return [];
154 + }
155 + }
71 156
72 - return $comment;
157 + private function extractMentions($text) {
158 + try {
159 + // Pattern that combines zero-width delimiters with international username support
160 + $pattern = '/@\x{200B}([\p{L}\p{N}_. -@]+)\x{200C}/u';
161 +
162 + if (preg_match_all($pattern, $text, $matches)) {
163 + $mentions = array_filter($matches[1], function($mention) {
164 + $trimmed = trim($mention);
165 + return !empty($trimmed);
166 + });
167 + return array_values($mentions); // Re-index array
168 + }
169 + return [];
170 + } catch (\Exception $e) {
171 + return [];
172 + }
73 173 }
74 174
75 - public function delete($comment_id)
175 + public function processMentionAndLink($commentDescription, $mentionData = [])
76 176 {
77 - $comment = Comment::findOrFail($comment_id);
78 - $taskId = $comment->task_id;
177 + if (empty($commentDescription)) {
178 + return '';
179 + }
79 180
181 + try {
182 + // Ensure UTF-8 encoding with error handling
183 + $commentDescription = mb_convert_encoding($commentDescription, 'UTF-8', 'auto');
184 +
185 + // Extract all URLs from the text
186 + $urls = $this->extractUrls($commentDescription);
187 + $urlReplacements = [];
188 +
189 + if (!empty($urls)) {
190 + foreach ($urls as $url) {
191 + if ($this->isValidUrl($url)) {
192 + $urlReplacements[$url] = sprintf(
193 + '<a class="fbs_link" target="_blank" rel="noopener noreferrer" href="%1$s">%1$s</a>',
194 + esc_url($url)
195 + );
196 + }
197 + }
198 + }
199 +
200 + // Process mentions
201 + $mentionedUsernames = [];
202 + if (!empty($mentionData) && is_array($mentionData)) {
203 + foreach ($mentionData as $mentionedId) {
204 + $user = get_userdata($mentionedId);
205 + if ($user) {
206 + $mentionedUsernames[$user->user_login] = [
207 + 'user_id' => $user->ID,
208 + 'display_name' => htmlspecialchars($user->display_name, ENT_QUOTES, 'UTF-8')
209 + ];
210 + }
211 + }
212 + }
213 +
214 + // Extract all mentions from the text
215 + $mentions = $this->extractMentions($commentDescription);
216 + $mentionReplacements = [];
217 +
218 + if (!empty($mentions)) {
219 + foreach ($mentions as $mention) {
220 + if (array_key_exists($mention, $mentionedUsernames)) {
221 + $mentionReplacements['@' . "\u{200B}" . $mention . "\u{200C}"] = sprintf(
222 + '<a class="fbs_mention" href="%smember/%d/tasks">%s</a>',
223 + esc_url(fluent_boards_page_url()),
224 + $mentionedUsernames[$mention]['user_id'],
225 + $mentionedUsernames[$mention]['display_name']
226 + );
227 + }
228 + }
229 + }
230 +
231 + // Apply replacements
232 + $originalText = $commentDescription;
233 +
234 + // First replace URLs (longer strings first to avoid partial replacements)
235 + if (!empty($urls)) {
236 + usort($urls, function($a, $b) {
237 + return strlen($b) - strlen($a);
238 + });
239 + foreach ($urls as $url) {
240 + if (isset($urlReplacements[$url])) {
241 + $commentDescription = str_replace($url, $urlReplacements[$url], $commentDescription);
242 + }
243 + }
244 + }
245 +
246 + // Then replace mentions
247 + if (!empty($mentionReplacements)) {
248 + foreach ($mentionReplacements as $mention => $replacement) {
249 + $commentDescription = str_replace($mention, $replacement, $commentDescription);
250 + }
251 + }
252 +
253 + return $commentDescription;
254 +
255 + } catch (\Exception $e) {
256 + return $commentDescription; // Return original text if processing fails
257 + }
258 + }
259 +
260 + public function checkIfCommentHaveLinks($comment)
261 + {
262 + if (empty($comment)) {
263 + return '';
264 + }
265 +
266 + try {
267 + // Ensure UTF-8 encoding
268 + $comment = mb_convert_encoding($comment, 'UTF-8', 'auto');
269 +
270 + // Extract all URLs from the text
271 + $urls = $this->extractUrls($comment);
272 + $hasLinks = false;
273 +
274 + // Replace URLs with links
275 + if (!empty($urls)) {
276 + foreach ($urls as $url) {
277 + if ($this->isValidUrl($url)) {
278 + $hasLinks = true;
279 + $replacement = sprintf(
280 + '<a class="fbs_link" target="_blank" rel="noopener noreferrer" href="%1$s">%1$s</a>',
281 + esc_url($url)
282 + );
283 + $comment = str_replace($url, $replacement, $comment);
284 + }
285 + }
286 + }
287 +
288 + return $hasLinks ? $comment : $comment;
289 +
290 + } catch (\Exception $e) {
291 + return $comment; // Return original text if processing fails
292 + }
293 + }
294 +
295 + /**
296 + * Validate every new image before attaching uploads or removing existing images.
297 + */
298 + public function attachCommentImages($comment, $imageIds)
299 + {
300 + $imageIds = $this->normalizeCommentImageIds($imageIds);
301 +
302 + $commentImages = CommentImage::where('object_id', $comment->id)
303 + ->where('object_type', Constant::COMMENT_IMAGE)
304 + ->get();
305 +
306 + $attachedImageIds = [];
307 + foreach ($commentImages as $commentImage) {
308 + if (in_array((int) $commentImage->id, $imageIds, true)) {
309 + $attachedImageIds[] = (int) $commentImage->id;
310 + }
311 + }
312 +
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 + );
320 +
321 + foreach ($attachmentObjects as $attachmentObject) {
322 + $attachmentObject->object_id = $comment->id;
323 + $attachmentObject->object_type = Constant::COMMENT_IMAGE;
324 + $attachmentObject->save();
325 + }
326 + }
327 +
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);
333 + }
334 + }
335 + }
336 +
337 + /**
338 + * Allow retained images on this comment and validate all newly supplied uploads.
339 + */
340 + public function assertCommentImagesAttachableForComment($comment, $imageIds)
341 + {
342 + $imageIds = $this->normalizeCommentImageIds($imageIds);
343 +
344 + if (empty($imageIds)) {
345 + return [];
346 + }
347 +
348 + $commentImages = CommentImage::where('object_id', $comment->id)
349 + ->where('object_type', Constant::COMMENT_IMAGE)
350 + ->get();
351 +
352 + foreach ($commentImages as $commentImage) {
353 + $key = array_search((int) $commentImage->id, $imageIds, true);
354 + if ($key !== false) {
355 + unset($imageIds[$key]);
356 + }
357 + }
358 +
359 + return $this->assertCommentImagesAttachable(
360 + array_values($imageIds),
361 + $comment->board_id,
362 + $comment->task_id
363 + );
364 + }
365 +
366 + /**
367 + * Reject the entire image list unless every upload is unbound and owned by this actor, board, and task.
368 + */
369 + public function assertCommentImagesAttachable($imageIds, $boardId, $taskId)
370 + {
371 + $imageIds = $this->normalizeCommentImageIds($imageIds);
372 +
373 + if (empty($imageIds)) {
374 + return [];
375 + }
376 +
377 + $currentUserId = get_current_user_id();
378 + if (!$currentUserId) {
379 + throw new \Exception(esc_html__('Invalid comment image attachment', 'fluent-boards'));
380 + }
381 +
382 + $attachments = CommentImage::whereIn('id', $imageIds)
383 + ->where('object_id', 0)
384 + ->where('object_type', Constant::COMMENT_IMAGE)
385 + ->get();
386 +
387 + if (count($attachments) !== count($imageIds)) {
388 + throw new \Exception(esc_html__('Invalid comment image attachment', 'fluent-boards'));
389 + }
390 +
391 + foreach ($attachments as $attachment) {
392 + if (!$this->commentImageScopeMatches($attachment, $boardId, $taskId, $currentUserId)) {
393 + throw new \Exception(esc_html__('Invalid comment image attachment', 'fluent-boards'));
394 + }
395 + }
396 +
397 + return $attachments;
398 + }
399 +
400 + /**
401 + * Fail closed for legacy uploads without recorded board, task, and uploader ownership.
402 + */
403 + private function commentImageScopeMatches($attachment, $boardId, $taskId, $userId)
404 + {
405 + $settings = is_array($attachment->settings) ? $attachment->settings : [];
406 + $scope = isset($settings['comment_image_scope']) && is_array($settings['comment_image_scope'])
407 + ? $settings['comment_image_scope']
408 + : [];
409 +
410 + return intval($scope['board_id'] ?? 0) === intval($boardId)
411 + && intval($scope['task_id'] ?? 0) === intval($taskId)
412 + && intval($scope['created_by'] ?? 0) === intval($userId);
413 + }
414 +
415 + /**
416 + * Record trusted upload ownership in attachment metadata before saving.
417 + */
418 + public function applyCommentImageScope($attachment, $boardId, $taskId, $createdBy = null)
419 + {
420 + $settings = is_array($attachment->settings) ? $attachment->settings : [];
421 + $settings['comment_image_scope'] = [
422 + 'board_id' => intval($boardId),
423 + 'task_id' => intval($taskId),
424 + 'created_by' => intval($createdBy === null ? get_current_user_id() : $createdBy),
425 + ];
426 + $attachment->settings = $settings;
427 +
428 + return $attachment;
429 + }
430 +
431 + /**
432 + * Normalize submitted image IDs and remove duplicates before validating the full list.
433 + */
434 + private function normalizeCommentImageIds($imageIds)
435 + {
436 + if (!is_array($imageIds)) {
437 + return [];
438 + }
439 +
440 + return array_values(array_filter(array_unique(array_map('intval', $imageIds))));
441 + }
442 +
443 + public function update($commentData, $comment_id, $mentionData, $boardId = null)
444 + {
445 + $comment = $boardId ? $this->findCommentOnBoard($comment_id, $boardId) : Comment::findOrFail($comment_id);
446 +
80 447 if ($comment->created_by != get_current_user_id()) {
81 448 return false;
82 449 }
83 450
84 - $commentDescription = strip_tags($comment->description);
451 + $effectiveBoardId = absint($comment->board_id ?: $boardId);
452 + $notificationService = new NotificationService();
453 + $existingMentionedIds = array_values(array_unique(array_filter(array_map('absint', (array) ($comment->settings['mentioned_id'] ?? [])))));
454 + $newMentionedIds = array_values(array_unique(array_filter(array_map('absint', (array) $mentionData))));
455 + $requestedMentionedIds = array_values(array_unique(array_merge($existingMentionedIds, $newMentionedIds)));
456 + $allMentionedIds = $notificationService->resolveBoardMentionUserIds($effectiveBoardId, $requestedMentionedIds);
85 457
86 - $deleted = $comment->delete();
458 + if (array_diff($newMentionedIds, $allMentionedIds)) {
459 + throw new \Exception(esc_html__('One or more mentioned users are not members of this board', 'fluent-boards'), 403);
460 + }
87 461
88 - if ($deleted) {
89 - $this->relatedReplyDelete($comment_id);
462 + if ($allMentionedIds) {
463 + $processedDescription = $this->processMentionAndLink($commentData['description'], $allMentionedIds);
464 + } elseif(!$allMentionedIds) {
465 + $processedDescription = $this->checkIfCommentHaveLinks($commentData['description']);
466 + }
90 467
91 - $task = Task::findOrFail($taskId);
92 - $task->comments_count = $task->comments_count - 1;
93 - $task->save();
468 + $oldComment = $comment->settings['raw_description'] ?? $comment->description;
469 + $comment->description = $processedDescription;
470 +
471 + if($comment->settings != null)
472 + {
473 + $tempSettings = $comment->settings;
474 + $tempSettings['raw_description'] = $commentData['description'];
475 + $tempSettings['mentioned_id'] = $allMentionedIds;
476 + $comment->settings = $tempSettings;
477 + } else {
478 + $comment->settings = [
479 + 'raw_description' => $commentData['description'],
480 + 'mentioned_id' => $allMentionedIds
481 + ];
94 482 }
483 + $comment->save();
95 484
96 - do_action('fluent_boards/task_comment_deleted', $taskId, $commentDescription);
485 + if(!$comment->parent_id) {
486 + do_action('fluent_boards/comment_updated', $comment, $oldComment);
487 + }
488 +
489 + return $comment;
97 490 }
98 491
492 + public function delete($comment_id, $boardId = null)
493 + {
494 + $comment = $boardId ? $this->findCommentOnBoard($comment_id, $boardId) : Comment::findOrFail($comment_id);
495 +
496 + if ($comment->created_by != get_current_user_id()) {
497 + return false;
498 + }
499 +
500 + // Delete related replies first (model event will handle their images)
501 + $this->relatedReplyDelete($comment_id);
502 +
503 + // Delete the comment (model deleting event will handle images and comments_count)
504 + $comment->delete();
505 +
506 + do_action('fluent_boards/comment_deleted', $comment);
507 + }
508 +
99 509 public function relatedReplyDelete($comment_id)
100 510 {
101 511 $replies = Comment::where('parent_id', $comment_id)
102 512 ->type('reply')
@@ -101,15 +511,16 @@
101 511 $replies = Comment::where('parent_id', $comment_id)
102 512 ->type('reply')
103 513 ->get();
104 514 foreach ($replies as $reply) {
515 + // Delete reply (model deleting event will handle images)
105 516 $reply->delete();
106 517 }
107 518 }
108 519
109 - public function updateReply($replyData, $id)
520 + public function updateReply($replyData, $id, $boardId = null)
110 521 {
111 - $reply = Comment::findOrFail($id);
522 + $reply = $boardId ? $this->findCommentOnBoard($id, $boardId) : Comment::findOrFail($id);
112 523
113 524 if ($reply->created_by != get_current_user_id()) {
114 525 return false;
115 526 }
@@ -121,11 +532,11 @@
121 532
122 533 return $reply;
123 534 }
124 535
125 - public function deleteReply($id)
536 + public function deleteReply($id, $boardId = null)
126 537 {
127 - $reply = Comment::findOrFail($id);
538 + $reply = $boardId ? $this->findCommentOnBoard($id, $boardId) : Comment::findOrFail($id);
128 539 // $taskId = $reply->task_id;
129 540
130 541 if ($reply->created_by != get_current_user_id()) {
131 542 return false;
@@ -130,9 +541,70 @@
130 541 if ($reply->created_by != get_current_user_id()) {
131 542 return false;
132 543 }
133 544
545 + // Delete reply (model deleting event will handle images)
134 546 $reply->delete();
135 547
136 -// do_action('fluent_boards/task_comment_deleted', $taskId);
548 +// do_action('fluent_boards/comment_deleted', $taskId);
549 + }
550 +
551 + /**
552 + * Persist an unbound comment upload with trusted board, task, and uploader metadata.
553 + * Legacy uploads without this scope cannot be newly attached to a comment.
554 + *
555 + * @return CommentImage
556 + */
557 + public function createCommentImage($data, $boardId, $taskId = null)
558 + {
559 + /*
560 + * I will refactor this function later- within March 2024 Last Week
561 + */
562 + $initialDataData = [
563 + 'type' => 'url',
564 + 'url' => '',
565 + 'name' => '',
566 + 'size' => 0,
567 + ];
568 +
569 + $attachData = array_merge($initialDataData, $data);
570 + $UrlMeta = [];
571 + if($attachData['type'] == 'url') {
572 + $UrlMeta = RemoteUrlParser::parse($attachData['url']);
573 + }
574 + $attachment = new CommentImage();
575 + $attachment->object_id = 0;
576 + $attachment->object_type = Constant::COMMENT_IMAGE;
577 + $attachment->attachment_type = $attachData['type'];
578 + $attachment->title = $this->setTitle($attachData['type'], $attachData['name'], $UrlMeta);
579 + $attachment->file_path = $attachData['type'] != 'url' ? $attachData['file'] : null;
580 + $attachment->full_url = esc_url($attachData['url']);
581 + $attachment->file_size = $attachData['size'];
582 + $settings = $attachData['type'] == 'url' ? [
583 + 'meta' => $UrlMeta
584 + ] : [];
585 + $settings['board_id'] = absint($boardId);
586 + $attachment->settings = $settings;
587 + $this->applyCommentImageScope($attachment, $boardId, $taskId);
588 + $attachment->driver = 'local';
589 + $attachment->save();
590 +
591 + return $attachment;
592 + }
593 +
594 + public function createPublicUrl($attachment, $boardId)
595 + {
596 + return add_query_arg([
597 + 'fbs' => 1,
598 + 'fbs_type' => 'public_url',
599 + 'fbs_comment_image' => $attachment->file_hash
600 + ], site_url('/index.php'));
601 + }
602 +
603 + private function setTitle($type, $title, $UrlMeta)
604 + {
605 + if($type != 'url') {
606 + return sanitize_file_name($title);
607 + }
608 + return $title ?? $UrlMeta['title'] ?? '';
137 609 }
138 610 }