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

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

611 lines 21.0 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\Models\Comment;
6 use FluentBoards\App\Models\CommentImage;
7 use FluentBoards\App\Models\Task;
8 use FluentBoards\App\Models\TaskActivity;
9 use FluentBoardsPro\App\Services\AttachmentService;
10 use FluentBoardsPro\App\Services\RemoteUrlParser;
11
12 class CommentService
13 {
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)
23 {
24 $comment = Comment::findOrFail($commentId);
25 (new TaskService())->findTaskOnBoard($comment->task_id, $boardId);
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
41 $commentsQuery = $task->comments()->whereNull('parent_id')
42 ->with(['user', 'images', 'replies.user', 'replies.images']);
43
44 if ($filter == 'oldest') {
45 $commentsQuery = $commentsQuery->oldest();
46 } else { // latest or newest
47 $commentsQuery = $commentsQuery->latest();
48 }
49 $comments = $commentsQuery->paginate($per_page);
50
51 foreach ($comments as $comment) {
52 $comment->replies_count = count($comment->replies);
53 }
54
55 return $comments;
56 }
57
58 public function getTotal($id, $boardId = null)
59 {
60 $task = $boardId ? (new TaskService())->findTaskOnBoard($id, $boardId) : Task::findOrFail($id);
61 $totalComment = Comment::where('task_id', $task->id)
62 ->type('comment')
63 ->count();
64 $totalReply = Comment::where('task_id', $task->id)
65 ->type('reply')
66 ->count();
67
68 return $totalComment + $totalReply;
69 }
70
71 public function getReplies($comment)
72 {
73 $replies = Comment::where('parent_id', $comment->id)->with(['user'])->get();
74 return $replies;
75 }
76
77 public function create($commentData, $id, $boardId = null)
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
91 $comment = Comment::create($commentData);
92 do_action('fluent_boards/comment_created', $comment);
93 return $comment;
94 }
95
96 private function startsWithAt($word) {
97 return mb_strpos($word, '@') === 0;
98 }
99
100 private function isValidUrl($url)
101 {
102 try {
103 if (empty($url)) {
104 return false;
105 }
106
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) {
134 return false;
135 }
136 }
137
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 }
156
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 }
173 }
174
175 public function processMentionAndLink($commentDescription, $mentionData = [])
176 {
177 if (empty($commentDescription)) {
178 return '';
179 }
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
447 if ($comment->created_by != get_current_user_id()) {
448 return false;
449 }
450
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);
457
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 }
461
462 if ($allMentionedIds) {
463 $processedDescription = $this->processMentionAndLink($commentData['description'], $allMentionedIds);
464 } elseif(!$allMentionedIds) {
465 $processedDescription = $this->checkIfCommentHaveLinks($commentData['description']);
466 }
467
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 ];
482 }
483 $comment->save();
484
485 if(!$comment->parent_id) {
486 do_action('fluent_boards/comment_updated', $comment, $oldComment);
487 }
488
489 return $comment;
490 }
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
509 public function relatedReplyDelete($comment_id)
510 {
511 $replies = Comment::where('parent_id', $comment_id)
512 ->type('reply')
513 ->get();
514 foreach ($replies as $reply) {
515 // Delete reply (model deleting event will handle images)
516 $reply->delete();
517 }
518 }
519
520 public function updateReply($replyData, $id, $boardId = null)
521 {
522 $reply = $boardId ? $this->findCommentOnBoard($id, $boardId) : Comment::findOrFail($id);
523
524 if ($reply->created_by != get_current_user_id()) {
525 return false;
526 }
527
528 $oldReply = $reply->description;
529 $reply->description = $replyData['description'];
530 $reply->save();
531 // do_action('fluent_boards/task_comment_updated', $comment->task_id, $oldComment, $comment->description);
532
533 return $reply;
534 }
535
536 public function deleteReply($id, $boardId = null)
537 {
538 $reply = $boardId ? $this->findCommentOnBoard($id, $boardId) : Comment::findOrFail($id);
539 // $taskId = $reply->task_id;
540
541 if ($reply->created_by != get_current_user_id()) {
542 return false;
543 }
544
545 // Delete reply (model deleting event will handle images)
546 $reply->delete();
547
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'] ?? '';
609 }
610 }
611