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

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

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