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
← All changes | app/Services/CommentService.php +452 -109 1.23 → 2.2.0 View file →
@@ -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,17 +8,42 @@
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 - public function getComments($id, $per_page, $filter)
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)
15 25 {
16 - $task = Task::findOrFail($id);
26 + $comment = Comment::findOrFail($commentId);
27 + (new TaskService())->findTaskOnBoard($comment->task_id, $boardId);
17 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 +
18 44 $commentsQuery = $task->comments()->whereNull('parent_id')
19 - ->with(['user']);
45 + ->with(['user', 'images', 'replies.user', 'replies.images']);
20 46
21 47 if ($filter == 'oldest') {
22 48 $commentsQuery = $commentsQuery->oldest();
23 49 } else { // latest or newest
@@ -25,19 +51,17 @@
25 51 }
26 52 $comments = $commentsQuery->paginate($per_page);
27 53
28 54 foreach ($comments as $comment) {
29 - $comment->replies = $this->getReplies($comment);
30 55 $comment->replies_count = count($comment->replies);
31 - $comment->load('images');
32 56 }
33 57
34 58 return $comments;
35 59 }
36 60
37 - public function getTotal($id)
61 + public function getTotal($id, $boardId = null)
38 62 {
39 - $task = Task::findOrFail($id);
63 + $task = $boardId ? (new TaskService())->findTaskOnBoard($id, $boardId) : Task::findOrFail($id);
40 64 $totalComment = Comment::where('task_id', $task->id)
41 65 ->type('comment')
42 66 ->count();
43 67 $totalReply = Comment::where('task_id', $task->id)
@@ -52,128 +76,451 @@
52 76 $replies = Comment::where('parent_id', $comment->id)->with(['user'])->get();
53 77 return $replies;
54 78 }
55 79
56 - public function create($commentData, $id)
80 + public function create($commentData, $id, $boardId = null)
57 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 +
58 94 $comment = Comment::create($commentData);
59 95 do_action('fluent_boards/comment_created', $comment);
60 96 return $comment;
61 97 }
62 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 +
63 134 private function startsWithAt($word) {
64 - return strpos($word, '@') === 0;
135 + return mb_strpos($word, '@') === 0;
65 136 }
66 137
67 - public function processMentionAndLink($commentDescription, $mentionData)
138 + private function isValidUrl($url)
68 139 {
69 - // Splitting a string by either a space (" ") or a new line ("\n")
70 - $lines = preg_split('/\R/', $commentDescription); // \R matches any kind of line break
140 + try {
141 + if (empty($url)) {
142 + return false;
143 + }
71 144
72 - $mentionedUsernames = [];
73 - foreach ($mentionData as $mentionedId) {
74 - $user = get_userdata($mentionedId);
75 - $mentionedUsernames[$user->user_login] = ['user_id' => $user->ID, 'display_name' => $user->display_name];
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;
76 173 }
174 + }
77 175
78 - foreach ($lines as &$line) {
79 - $words = preg_split('/[ ]+/', $line);
80 - foreach ($words as $index => $word) {
81 - if ($this->startsWithAt($word)) {
82 - $username = substr($word, 1);
83 - if (array_key_exists($username, $mentionedUsernames)) {
84 - $words[$index] = '<a class="fbs_mention" href="' . fluent_boards_page_url() . 'member/' . $mentionedUsernames[$username]['user_id'] . '/tasks">' . $mentionedUsernames[$username]['display_name'] . '</a>';
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 + );
85 234 }
86 - } elseif ($this->isValidUrl(wp_kses_post($word))) {
87 - $words[$index] = '<a class="fbs_link" target="_blank" href="'. esc_url($word). '">'. esc_url($word). '</a>';
88 235 }
89 236 }
90 - // Rejoin the words in this line
91 - $line = implode(' ', $words);
92 - }
93 237
94 -// Rejoin the lines, adding back the new line character
95 - return implode("\n", $lines);
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 + }
96 251
97 - }
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 + }
98 268
99 - private function isValidUrl($url) {
100 - if (filter_var($url, FILTER_VALIDATE_URL) === false) {
101 - return false;
102 - }
269 + // Apply replacements
270 + $originalText = $commentDescription;
103 271
104 - // Additional validation with a regular expression
105 - $regex = "/\b(?:https?|ftp):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|]/i";
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 + }
106 283
107 - return preg_match($regex, $url);
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 + }
108 296 }
109 297
110 298 public function checkIfCommentHaveLinks($comment)
111 299 {
112 - $lines = preg_split('/\R/', $comment); // Split by any kind of line break
113 - $commentHasLinks = false;
300 + if (empty($comment)) {
301 + return '';
302 + }
114 303
115 - foreach ($lines as &$line) {
116 - $words = preg_split('/[ ]+/', $line); // Split by spaces within each line
117 - foreach ($words as $index => $word) {
118 - $cleanWord = wp_kses_post($word);
119 - if ($this->isValidUrl($cleanWord)) {
120 - $commentHasLinks = true;
121 - $words[$index] = '<a class="fbs_link" target="_blank" href="' . esc_url($cleanWord) . '">' . esc_url($cleanWord) . '</a>';
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 + }
122 323 }
123 324 }
124 - // Rejoin words in this line
125 - $line = implode(' ', $words);
126 - }
127 325
128 - if ($commentHasLinks) {
129 - return implode("\n", $lines); // Rejoin lines with new lines preserved
130 - } else {
131 - return $comment; // Return original comment if no links were found
326 + return $hasLinks ? $comment : $comment;
327 +
328 + } catch (\Exception $e) {
329 + return $comment; // Return original text if processing fails
132 330 }
133 331 }
134 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 + */
135 337 public function attachCommentImages($comment, $imageIds)
136 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();
137 344
138 - foreach ($imageIds as $imageId)
139 - {
140 - $attachmentObject = CommentImage::findOrFail($imageId);
141 - if($attachmentObject) {
142 - if ($attachmentObject->object_id == $comment->id && $attachmentObject->object_type == Constant::COMMENT_IMAGE) {
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) {
143 364 continue;
144 365 }
145 - $attachmentObject->object_id = $comment->id;
146 - $attachmentObject->object_type = Constant::COMMENT_IMAGE;
147 - $attachmentObject->save();
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 + }
148 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 [];
149 404 }
150 - //if(in_array("banana", $imageIds))
151 - $commentImages = CommentImage::where('object_id', $comment->id)->where('object_type', Constant::COMMENT_IMAGE)->get();
152 405
406 + $commentImages = CommentImage::where('object_id', $comment->id)
407 + ->where('object_type', Constant::COMMENT_IMAGE)
408 + ->get();
409 +
153 410 foreach ($commentImages as $commentImage) {
154 - if(!in_array($commentImage->id, $imageIds)) {
155 - $commentImage->delete();
411 + $key = array_search((int) $commentImage->id, $imageIds, true);
412 + if ($key !== false) {
413 + unset($imageIds[$key]);
156 414 }
157 415 }
416 +
417 + return $this->assertCommentImagesAttachable(
418 + array_values($imageIds),
419 + $comment->board_id,
420 + $comment->task_id
421 + );
158 422 }
159 423
160 - public function update($commentData, $comment_id, $mentionData)
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)
161 428 {
162 - $comment = Comment::findOrFail($comment_id);
429 + $imageIds = $this->normalizeCommentImageIds($imageIds);
163 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 +
164 505 if ($comment->created_by != get_current_user_id()) {
165 506 return false;
166 507 }
167 508
168 - $allMentionedIds = array_merge($comment->settings['mentioned_id'] ?? [], $mentionData ?? []);
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);
169 515
170 - if ($allMentionedIds) {
171 - $processedDescription = $this->processMentionAndLink($commentData['description'], $allMentionedIds);
172 - } else {
173 - $processedDescription = $this->checkIfCommentHaveLinks($commentData['description']);
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);
174 518 }
175 519
520 + $commentData['description'] = $this->sanitizeContent($commentData['description']);
521 + $processedDescription = $this->renderContent($commentData['description'], $allMentionedIds);
522 +
176 523 $oldComment = $comment->settings['raw_description'] ?? $comment->description;
177 524 $comment->description = $processedDescription;
178 525
179 526 if($comment->settings != null)
@@ -183,9 +530,9 @@
183 530 $tempSettings['mentioned_id'] = $allMentionedIds;
184 531 $comment->settings = $tempSettings;
185 532 } else {
186 533 $comment->settings = [
187 - 'raw_description' => $commentData['raw_description'],
534 + 'raw_description' => $commentData['description'],
188 535 'mentioned_id' => $allMentionedIds
189 536 ];
190 537 }
191 538 $comment->save();
@@ -190,36 +537,29 @@
190 537 }
191 538 $comment->save();
192 539
193 540 if(!$comment->parent_id) {
194 - do_action('fluent_boards/comment_updated', $comment->task_id, $oldComment, $comment->settings['raw_description']);
541 + do_action('fluent_boards/comment_updated', $comment, $oldComment);
195 542 }
196 543
197 544 return $comment;
198 545 }
199 546
200 - public function delete($comment_id)
547 + public function delete($comment_id, $boardId = null)
201 548 {
202 - $comment = Comment::findOrFail($comment_id);
203 - $taskId = $comment->task_id;
549 + $comment = $boardId ? $this->findCommentOnBoard($comment_id, $boardId) : Comment::findOrFail($comment_id);
204 550
205 551 if ($comment->created_by != get_current_user_id()) {
206 552 return false;
207 553 }
208 554
209 - $commentDescription = strip_tags($comment->description);
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();
210 560
211 - $deleted = $comment->delete();
212 -
213 - if ($deleted) {
214 - $this->relatedReplyDelete($comment_id);
215 - $comment->images()->delete();
216 - $task = Task::findOrFail($taskId);
217 - $task->comments_count = $task->comments_count - 1;
218 - $task->save();
219 - }
220 -
221 - do_action('fluent_boards/comment_deleted', $taskId, $commentDescription);
561 + do_action('fluent_boards/comment_deleted', $comment);
222 562 }
223 563
224 564 public function relatedReplyDelete($comment_id)
225 565 {
@@ -226,15 +566,16 @@
226 566 $replies = Comment::where('parent_id', $comment_id)
227 567 ->type('reply')
228 568 ->get();
229 569 foreach ($replies as $reply) {
570 + // Delete reply (model deleting event will handle images)
230 571 $reply->delete();
231 572 }
232 573 }
233 574
234 - public function updateReply($replyData, $id)
575 + public function updateReply($replyData, $id, $boardId = null)
235 576 {
236 - $reply = Comment::findOrFail($id);
577 + $reply = $boardId ? $this->findCommentOnBoard($id, $boardId) : Comment::findOrFail($id);
237 578
238 579 if ($reply->created_by != get_current_user_id()) {
239 580 return false;
240 581 }
@@ -246,11 +587,11 @@
246 587
247 588 return $reply;
248 589 }
249 590
250 - public function deleteReply($id)
591 + public function deleteReply($id, $boardId = null)
251 592 {
252 - $reply = Comment::findOrFail($id);
593 + $reply = $boardId ? $this->findCommentOnBoard($id, $boardId) : Comment::findOrFail($id);
253 594 // $taskId = $reply->task_id;
254 595
255 596 if ($reply->created_by != get_current_user_id()) {
256 597 return false;
@@ -255,8 +596,9 @@
255 596 if ($reply->created_by != get_current_user_id()) {
256 597 return false;
257 598 }
258 599
600 + // Delete reply (model deleting event will handle images)
259 601 $reply->delete();
260 602
261 603 // do_action('fluent_boards/comment_deleted', $taskId);
262 604 }
@@ -261,18 +603,14 @@
261 603 // do_action('fluent_boards/comment_deleted', $taskId);
262 604 }
263 605
264 606 /**
265 - * Adds a task attachment to the specified task.
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.
266 609 *
267 - * @param int $taskId The ID of the task to which the attachment is added.
268 - * @param string $title The title of the attachment.
269 - * @param string $url The URL of the attachment.
270 - *
271 - * @return Attachment The updated list of task attachments.
272 - * @throws \Exception
610 + * @return CommentImage
273 611 */
274 - public function createCommentImage($data, $boardId)
612 + public function createCommentImage($data, $boardId, $taskId = null)
275 613 {
276 614 /*
277 615 * I will refactor this function later- within March 2024 Last Week
278 616 */
@@ -295,26 +633,31 @@
295 633 $attachment->title = $this->setTitle($attachData['type'], $attachData['name'], $UrlMeta);
296 634 $attachment->file_path = $attachData['type'] != 'url' ? $attachData['file'] : null;
297 635 $attachment->full_url = esc_url($attachData['url']);
298 636 $attachment->file_size = $attachData['size'];
299 - $attachment->settings = $attachData['type'] == 'url' ? [
637 + $settings = $attachData['type'] == 'url' ? [
300 638 'meta' => $UrlMeta
301 - ] : '';
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);
302 646 $attachment->driver = 'local';
303 647 $attachment->save();
304 648
305 - $attachment->public_url = $this->createPublicUrl($attachment, $boardId);
306 -
307 649 return $attachment;
308 650 }
309 651
310 - private function createPublicUrl($attachment, $boardId)
652 + public function createPublicUrl($attachment, $boardId)
311 653 {
654 + $boardId = absint($boardId);
655 +
312 656 return add_query_arg([
313 657 'fbs' => 1,
314 658 'fbs_type' => 'public_url',
315 - 'fbs_bid' => $boardId,
316 - 'fbs_comment_image' => $attachment->file_hash
659 + 'fbs_comment_image' => $attachment->file_hash,
317 660 ], site_url('/index.php'));
318 661 }
319 662
320 663 private function setTitle($type, $title, $UrlMeta)