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
fluent-boards / app / Services / CommentService.php

CommentService.php in FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration 2.2.0, at app/Services/CommentService.php

671 lines 23.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentBoards\App\Services;
4
5 use FluentBoards\App\App;
6 use FluentBoards\App\Models\Comment;
7 use FluentBoards\App\Models\CommentImage;
8 use FluentBoards\App\Models\Task;
9 use FluentBoards\App\Models\TaskActivity;
10 use FluentBoardsPro\App\Services\AttachmentService;
11 use FluentBoardsPro\App\Services\RemoteUrlParser;
12 use RuntimeException;
13
14 class CommentService
15 {
16 /**
17 * Resolve a comment only when its task belongs to the requested board.
18 *
19 * @param int $commentId
20 * @param int $boardId
21 * @return Comment
22 * @throws \Exception
23 */
24 public function findCommentOnBoard($commentId, $boardId)
25 {
26 $comment = Comment::findOrFail($commentId);
27 (new TaskService())->findTaskOnBoard($comment->task_id, $boardId);
28
29 if ($comment->board_id && (int) $comment->board_id !== absint($boardId)) {
30 throw new \Exception(esc_html__('Comment not found', 'fluent-boards'));
31 }
32
33 return $comment;
34 }
35
36 /**
37 * Get paginated parent comments with users, images, and replies preloaded.
38 */
39 public function getComments($id, $per_page, $filter, $boardId = null)
40 {
41 $per_page = Sanitize::perPage($per_page);
42 $task = $boardId ? (new TaskService())->findTaskOnBoard($id, $boardId) : Task::findOrFail($id);
43
44 $commentsQuery = $task->comments()->whereNull('parent_id')
45 ->with(['user', 'images', 'replies.user', 'replies.images']);
46
47 if ($filter == 'oldest') {
48 $commentsQuery = $commentsQuery->oldest();
49 } else { // latest or newest
50 $commentsQuery = $commentsQuery->latest();
51 }
52 $comments = $commentsQuery->paginate($per_page);
53
54 foreach ($comments as $comment) {
55 $comment->replies_count = count($comment->replies);
56 }
57
58 return $comments;
59 }
60
61 public function getTotal($id, $boardId = null)
62 {
63 $task = $boardId ? (new TaskService())->findTaskOnBoard($id, $boardId) : Task::findOrFail($id);
64 $totalComment = Comment::where('task_id', $task->id)
65 ->type('comment')
66 ->count();
67 $totalReply = Comment::where('task_id', $task->id)
68 ->type('reply')
69 ->count();
70
71 return $totalComment + $totalReply;
72 }
73
74 public function getReplies($comment)
75 {
76 $replies = Comment::where('parent_id', $comment->id)->with(['user'])->get();
77 return $replies;
78 }
79
80 public function create($commentData, $id, $boardId = null)
81 {
82 if ($boardId) {
83 (new TaskService())->findTaskOnBoard($id, $boardId);
84
85 if (!empty($commentData['parent_id'])) {
86 $parentComment = $this->findCommentOnBoard($commentData['parent_id'], $boardId);
87
88 if ((int) $parentComment->task_id !== (int) $id) {
89 throw new \Exception(esc_html__('Comment not found', 'fluent-boards'));
90 }
91 }
92 }
93
94 $comment = Comment::create($commentData);
95 do_action('fluent_boards/comment_created', $comment);
96 return $comment;
97 }
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
134 private function startsWithAt($word) {
135 return mb_strpos($word, '@') === 0;
136 }
137
138 private function isValidUrl($url)
139 {
140 try {
141 if (empty($url)) {
142 return false;
143 }
144
145 $url = trim($url);
146
147 // Early return for obviously invalid formats
148 if ($url === 'http://' || $url === 'https://') {
149 return false;
150 }
151
152 // Handle www. URLs
153 if (strpos($url, 'www.') === 0) {
154 $url = 'http://' . $url;
155 }
156 // If it's not already a URL, make it one
157 elseif (!preg_match('~^(?:f|ht)tps?://~i', $url)) {
158 $url = 'https://' . $url;
159 }
160
161 $components = wp_parse_url($url);
162
163 if (empty($components) || !isset($components['host'])) {
164 return false;
165 }
166
167 // Additional validation with filter_var
168 $isValid = filter_var($url, FILTER_VALIDATE_URL) !== false;
169 return $isValid;
170
171 } catch (\Exception $e) {
172 return false;
173 }
174 }
175
176 private function extractUrls($text) {
177 try {
178 // More permissive URL pattern that handles international domains and various formats
179 $urlPattern = '%\b(?:(?:https?|ftp):\/\/|www\.)[^\s<>\[\]{}"\']+'
180 . '(?:\([^\s<>\[\]{}"\')]*\)|[^\s<>\[\]{}"\'\)])*%iu';
181
182 if (preg_match_all($urlPattern, $text, $matches)) {
183 $urls = array_filter($matches[0], function($url) {
184 $trimmed = trim($url);
185 return !empty($trimmed);
186 });
187 return array_values($urls); // Re-index array
188 }
189 return [];
190 } catch (\Exception $e) {
191 return [];
192 }
193 }
194
195 private function extractMentions($text) {
196 try {
197 // Pattern that combines zero-width delimiters with international username support
198 $pattern = '/@\x{200B}([\p{L}\p{N}_. -@]+)\x{200C}/u';
199
200 if (preg_match_all($pattern, $text, $matches)) {
201 $mentions = array_filter($matches[1], function($mention) {
202 $trimmed = trim($mention);
203 return !empty($trimmed);
204 });
205 return array_values($mentions); // Re-index array
206 }
207 return [];
208 } catch (\Exception $e) {
209 return [];
210 }
211 }
212
213 public function processMentionAndLink($commentDescription, $mentionData = [])
214 {
215 if ($commentDescription === '' || $commentDescription === null) {
216 return '';
217 }
218
219 try {
220 // Ensure UTF-8 encoding with error handling
221 $commentDescription = mb_convert_encoding($commentDescription, 'UTF-8', 'auto');
222
223 // Extract all URLs from the text
224 $urls = $this->extractUrls($commentDescription);
225 $urlReplacements = [];
226
227 if (!empty($urls)) {
228 foreach ($urls as $url) {
229 if ($this->isValidUrl($url)) {
230 $urlReplacements[$url] = sprintf(
231 '<a class="fbs_link" target="_blank" rel="noopener noreferrer" href="%1$s">%1$s</a>',
232 esc_url($url)
233 );
234 }
235 }
236 }
237
238 // Process mentions
239 $mentionedUsernames = [];
240 if (!empty($mentionData) && is_array($mentionData)) {
241 foreach ($mentionData as $mentionedId) {
242 $user = get_userdata($mentionedId);
243 if ($user) {
244 $mentionedUsernames[$user->user_login] = [
245 'user_id' => $user->ID,
246 'display_name' => htmlspecialchars($user->display_name, ENT_QUOTES, 'UTF-8')
247 ];
248 }
249 }
250 }
251
252 // Extract all mentions from the text
253 $mentions = $this->extractMentions($commentDescription);
254 $mentionReplacements = [];
255
256 if (!empty($mentions)) {
257 foreach ($mentions as $mention) {
258 if (array_key_exists($mention, $mentionedUsernames)) {
259 $mentionReplacements['@' . "\u{200B}" . $mention . "\u{200C}"] = sprintf(
260 '<a class="fbs_mention" href="%smember/%d/tasks">%s</a>',
261 esc_url(fluent_boards_page_url()),
262 $mentionedUsernames[$mention]['user_id'],
263 $mentionedUsernames[$mention]['display_name']
264 );
265 }
266 }
267 }
268
269 // Apply replacements
270 $originalText = $commentDescription;
271
272 // First replace URLs (longer strings first to avoid partial replacements)
273 if (!empty($urls)) {
274 usort($urls, function($a, $b) {
275 return strlen($b) - strlen($a);
276 });
277 foreach ($urls as $url) {
278 if (isset($urlReplacements[$url])) {
279 $commentDescription = str_replace($url, $urlReplacements[$url], $commentDescription);
280 }
281 }
282 }
283
284 // Then replace mentions
285 if (!empty($mentionReplacements)) {
286 foreach ($mentionReplacements as $mention => $replacement) {
287 $commentDescription = str_replace($mention, $replacement, $commentDescription);
288 }
289 }
290
291 return $commentDescription;
292
293 } catch (\Exception $e) {
294 return $commentDescription; // Return original text if processing fails
295 }
296 }
297
298 public function checkIfCommentHaveLinks($comment)
299 {
300 if (empty($comment)) {
301 return '';
302 }
303
304 try {
305 // Ensure UTF-8 encoding
306 $comment = mb_convert_encoding($comment, 'UTF-8', 'auto');
307
308 // Extract all URLs from the text
309 $urls = $this->extractUrls($comment);
310 $hasLinks = false;
311
312 // Replace URLs with links
313 if (!empty($urls)) {
314 foreach ($urls as $url) {
315 if ($this->isValidUrl($url)) {
316 $hasLinks = true;
317 $replacement = sprintf(
318 '<a class="fbs_link" target="_blank" rel="noopener noreferrer" href="%1$s">%1$s</a>',
319 esc_url($url)
320 );
321 $comment = str_replace($url, $replacement, $comment);
322 }
323 }
324 }
325
326 return $hasLinks ? $comment : $comment;
327
328 } catch (\Exception $e) {
329 return $comment; // Return original text if processing fails
330 }
331 }
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 */
337 public function attachCommentImages($comment, $imageIds)
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();
344
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) {
364 continue;
365 }
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 }
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 [];
404 }
405
406 $commentImages = CommentImage::where('object_id', $comment->id)
407 ->where('object_type', Constant::COMMENT_IMAGE)
408 ->get();
409
410 foreach ($commentImages as $commentImage) {
411 $key = array_search((int) $commentImage->id, $imageIds, true);
412 if ($key !== false) {
413 unset($imageIds[$key]);
414 }
415 }
416
417 return $this->assertCommentImagesAttachable(
418 array_values($imageIds),
419 $comment->board_id,
420 $comment->task_id
421 );
422 }
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
501 public function update($commentData, $comment_id, $mentionData, $boardId = null)
502 {
503 $comment = $boardId ? $this->findCommentOnBoard($comment_id, $boardId) : Comment::findOrFail($comment_id);
504
505 if ($comment->created_by != get_current_user_id()) {
506 return false;
507 }
508
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);
515
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);
518 }
519
520 $commentData['description'] = $this->sanitizeContent($commentData['description']);
521 $processedDescription = $this->renderContent($commentData['description'], $allMentionedIds);
522
523 $oldComment = $comment->settings['raw_description'] ?? $comment->description;
524 $comment->description = $processedDescription;
525
526 if($comment->settings != null)
527 {
528 $tempSettings = $comment->settings;
529 $tempSettings['raw_description'] = $commentData['description'];
530 $tempSettings['mentioned_id'] = $allMentionedIds;
531 $comment->settings = $tempSettings;
532 } else {
533 $comment->settings = [
534 'raw_description' => $commentData['description'],
535 'mentioned_id' => $allMentionedIds
536 ];
537 }
538 $comment->save();
539
540 if(!$comment->parent_id) {
541 do_action('fluent_boards/comment_updated', $comment, $oldComment);
542 }
543
544 return $comment;
545 }
546
547 public function delete($comment_id, $boardId = null)
548 {
549 $comment = $boardId ? $this->findCommentOnBoard($comment_id, $boardId) : Comment::findOrFail($comment_id);
550
551 if ($comment->created_by != get_current_user_id()) {
552 return false;
553 }
554
555 // Delete related replies first (model event will handle their images)
556 $this->relatedReplyDelete($comment_id);
557
558 // Delete the comment (model deleting event will handle images and comments_count)
559 $comment->delete();
560
561 do_action('fluent_boards/comment_deleted', $comment);
562 }
563
564 public function relatedReplyDelete($comment_id)
565 {
566 $replies = Comment::where('parent_id', $comment_id)
567 ->type('reply')
568 ->get();
569 foreach ($replies as $reply) {
570 // Delete reply (model deleting event will handle images)
571 $reply->delete();
572 }
573 }
574
575 public function updateReply($replyData, $id, $boardId = null)
576 {
577 $reply = $boardId ? $this->findCommentOnBoard($id, $boardId) : Comment::findOrFail($id);
578
579 if ($reply->created_by != get_current_user_id()) {
580 return false;
581 }
582
583 $oldReply = $reply->description;
584 $reply->description = $replyData['description'];
585 $reply->save();
586 // do_action('fluent_boards/task_comment_updated', $comment->task_id, $oldComment, $comment->description);
587
588 return $reply;
589 }
590
591 public function deleteReply($id, $boardId = null)
592 {
593 $reply = $boardId ? $this->findCommentOnBoard($id, $boardId) : Comment::findOrFail($id);
594 // $taskId = $reply->task_id;
595
596 if ($reply->created_by != get_current_user_id()) {
597 return false;
598 }
599
600 // Delete reply (model deleting event will handle images)
601 $reply->delete();
602
603 // do_action('fluent_boards/comment_deleted', $taskId);
604 }
605
606 /**
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.
609 *
610 * @return CommentImage
611 */
612 public function createCommentImage($data, $boardId, $taskId = null)
613 {
614 /*
615 * I will refactor this function later- within March 2024 Last Week
616 */
617 $initialDataData = [
618 'type' => 'url',
619 'url' => '',
620 'name' => '',
621 'size' => 0,
622 ];
623
624 $attachData = array_merge($initialDataData, $data);
625 $UrlMeta = [];
626 if($attachData['type'] == 'url') {
627 $UrlMeta = RemoteUrlParser::parse($attachData['url']);
628 }
629 $attachment = new CommentImage();
630 $attachment->object_id = 0;
631 $attachment->object_type = Constant::COMMENT_IMAGE;
632 $attachment->attachment_type = $attachData['type'];
633 $attachment->title = $this->setTitle($attachData['type'], $attachData['name'], $UrlMeta);
634 $attachment->file_path = $attachData['type'] != 'url' ? $attachData['file'] : null;
635 $attachment->full_url = esc_url($attachData['url']);
636 $attachment->file_size = $attachData['size'];
637 $settings = $attachData['type'] == 'url' ? [
638 'meta' => $UrlMeta
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);
646 $attachment->driver = 'local';
647 $attachment->save();
648
649 return $attachment;
650 }
651
652 public function createPublicUrl($attachment, $boardId)
653 {
654 $boardId = absint($boardId);
655
656 return add_query_arg([
657 'fbs' => 1,
658 'fbs_type' => 'public_url',
659 'fbs_comment_image' => $attachment->file_hash,
660 ], site_url('/index.php'));
661 }
662
663 private function setTitle($type, $title, $UrlMeta)
664 {
665 if($type != 'url') {
666 return sanitize_file_name($title);
667 }
668 return $title ?? $UrlMeta['title'] ?? '';
669 }
670 }
671