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
← All changes | app/Services/CommentService.php +446 -105 1.322.1.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,41 @@
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 + $task = $boardId ? (new TaskService())->findTaskOnBoard($id, $boardId) : Task::findOrFail($id);
42 +
18 43 $commentsQuery = $task->comments()->whereNull('parent_id')
19 - ->with(['user']);
44 + ->with(['user', 'images', 'replies.user', 'replies.images']);
20 45
21 46 if ($filter == 'oldest') {
22 47 $commentsQuery = $commentsQuery->oldest();
23 48 } else { // latest or newest
@@ -25,19 +50,17 @@
25 50 }
26 51 $comments = $commentsQuery->paginate($per_page);
27 52
28 53 foreach ($comments as $comment) {
29 - $comment->replies = $this->getReplies($comment);
30 54 $comment->replies_count = count($comment->replies);
31 - $comment->load('images');
32 55 }
33 56
34 57 return $comments;
35 58 }
36 59
37 - public function getTotal($id)
60 + public function getTotal($id, $boardId = null)
38 61 {
39 - $task = Task::findOrFail($id);
62 + $task = $boardId ? (new TaskService())->findTaskOnBoard($id, $boardId) : Task::findOrFail($id);
40 63 $totalComment = Comment::where('task_id', $task->id)
41 64 ->type('comment')
42 65 ->count();
43 66 $totalReply = Comment::where('task_id', $task->id)
@@ -52,128 +75,448 @@
52 75 $replies = Comment::where('parent_id', $comment->id)->with(['user'])->get();
53 76 return $replies;
54 77 }
55 78
56 - public function create($commentData, $id)
79 + public function create($commentData, $id, $boardId = null)
57 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 +
58 93 $comment = Comment::create($commentData);
59 94 do_action('fluent_boards/comment_created', $comment);
60 95 return $comment;
61 96 }
62 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 +
63 130 private function startsWithAt($word) {
64 - return strpos($word, '@') === 0;
131 + return mb_strpos($word, '@') === 0;
65 132 }
66 133
67 - public function processMentionAndLink($commentDescription, $mentionData)
134 + private function isValidUrl($url)
68 135 {
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
136 + try {
137 + if (empty($url)) {
138 + return false;
139 + }
71 140
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];
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;
76 169 }
170 + }
77 171
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>';
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 + );
85 230 }
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 231 }
89 232 }
90 - // Rejoin the words in this line
91 - $line = implode(' ', $words);
92 - }
93 233
94 -// Rejoin the lines, adding back the new line character
95 - return implode("\n", $lines);
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 + }
96 247
97 - }
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 + }
98 264
99 - private function isValidUrl($url) {
100 - if (filter_var($url, FILTER_VALIDATE_URL) === false) {
101 - return false;
102 - }
265 + // Apply replacements
266 + $originalText = $commentDescription;
103 267
104 - // Additional validation with a regular expression
105 - $regex = "/\b(?:https?|ftp):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|]/i";
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 + }
106 279
107 - return preg_match($regex, $url);
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 + }
108 292 }
109 293
110 294 public function checkIfCommentHaveLinks($comment)
111 295 {
112 - $lines = preg_split('/\R/', $comment); // Split by any kind of line break
113 - $commentHasLinks = false;
296 + if (empty($comment)) {
297 + return '';
298 + }
114 299
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>';
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 + }
122 319 }
123 320 }
124 - // Rejoin words in this line
125 - $line = implode(' ', $words);
126 - }
127 321
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
322 + return $hasLinks ? $comment : $comment;
323 +
324 + } catch (\Exception $e) {
325 + return $comment; // Return original text if processing fails
132 326 }
133 327 }
134 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 + */
135 333 public function attachCommentImages($comment, $imageIds)
136 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();
137 340
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) {
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) {
143 360 continue;
144 361 }
145 - $attachmentObject->object_id = $comment->id;
146 - $attachmentObject->object_type = Constant::COMMENT_IMAGE;
147 - $attachmentObject->save();
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 + }
148 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 [];
149 400 }
150 - //if(in_array("banana", $imageIds))
151 - $commentImages = CommentImage::where('object_id', $comment->id)->where('object_type', Constant::COMMENT_IMAGE)->get();
152 401
402 + $commentImages = CommentImage::where('object_id', $comment->id)
403 + ->where('object_type', Constant::COMMENT_IMAGE)
404 + ->get();
405 +
153 406 foreach ($commentImages as $commentImage) {
154 - if(!in_array($commentImage->id, $imageIds)) {
155 - $commentImage->delete();
407 + $key = array_search((int) $commentImage->id, $imageIds, true);
408 + if ($key !== false) {
409 + unset($imageIds[$key]);
156 410 }
157 411 }
412 +
413 + return $this->assertCommentImagesAttachable(
414 + array_values($imageIds),
415 + $comment->board_id,
416 + $comment->task_id
417 + );
158 418 }
159 419
160 - public function update($commentData, $comment_id, $mentionData)
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)
161 424 {
162 - $comment = Comment::findOrFail($comment_id);
425 + $imageIds = $this->normalizeCommentImageIds($imageIds);
163 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 +
164 501 if ($comment->created_by != get_current_user_id()) {
165 502 return false;
166 503 }
167 504
168 - $allMentionedIds = array_merge($comment->settings['mentioned_id'] ?? [], $mentionData ?? []);
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);
169 511
170 - if ($allMentionedIds) {
171 - $processedDescription = $this->processMentionAndLink($commentData['description'], $allMentionedIds);
172 - } else {
173 - $processedDescription = $this->checkIfCommentHaveLinks($commentData['description']);
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);
174 514 }
175 515
516 + $commentData['description'] = $this->sanitizeContent($commentData['description']);
517 + $processedDescription = $this->renderContent($commentData['description'], $allMentionedIds);
518 +
176 519 $oldComment = $comment->settings['raw_description'] ?? $comment->description;
177 520 $comment->description = $processedDescription;
178 521
179 522 if($comment->settings != null)
@@ -183,9 +526,9 @@
183 526 $tempSettings['mentioned_id'] = $allMentionedIds;
184 527 $comment->settings = $tempSettings;
185 528 } else {
186 529 $comment->settings = [
187 - 'raw_description' => $commentData['raw_description'],
530 + 'raw_description' => $commentData['description'],
188 531 'mentioned_id' => $allMentionedIds
189 532 ];
190 533 }
191 534 $comment->save();
@@ -196,27 +539,22 @@
196 539
197 540 return $comment;
198 541 }
199 542
200 - public function delete($comment_id)
543 + public function delete($comment_id, $boardId = null)
201 544 {
202 - $comment = Comment::findOrFail($comment_id);
203 - $taskId = $comment->task_id;
545 + $comment = $boardId ? $this->findCommentOnBoard($comment_id, $boardId) : Comment::findOrFail($comment_id);
204 546
205 547 if ($comment->created_by != get_current_user_id()) {
206 548 return false;
207 549 }
208 550
209 - $deleted = $comment->delete();
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();
210 556
211 - if ($deleted) {
212 - $this->relatedReplyDelete($comment_id);
213 - $comment->images()->delete();
214 - $task = Task::findOrFail($taskId);
215 - $task->comments_count = $task->comments_count - 1;
216 - $task->save();
217 - }
218 -
219 557 do_action('fluent_boards/comment_deleted', $comment);
220 558 }
221 559
222 560 public function relatedReplyDelete($comment_id)
@@ -224,15 +562,16 @@
224 562 $replies = Comment::where('parent_id', $comment_id)
225 563 ->type('reply')
226 564 ->get();
227 565 foreach ($replies as $reply) {
566 + // Delete reply (model deleting event will handle images)
228 567 $reply->delete();
229 568 }
230 569 }
231 570
232 - public function updateReply($replyData, $id)
571 + public function updateReply($replyData, $id, $boardId = null)
233 572 {
234 - $reply = Comment::findOrFail($id);
573 + $reply = $boardId ? $this->findCommentOnBoard($id, $boardId) : Comment::findOrFail($id);
235 574
236 575 if ($reply->created_by != get_current_user_id()) {
237 576 return false;
238 577 }
@@ -244,11 +583,11 @@
244 583
245 584 return $reply;
246 585 }
247 586
248 - public function deleteReply($id)
587 + public function deleteReply($id, $boardId = null)
249 588 {
250 - $reply = Comment::findOrFail($id);
589 + $reply = $boardId ? $this->findCommentOnBoard($id, $boardId) : Comment::findOrFail($id);
251 590 // $taskId = $reply->task_id;
252 591
253 592 if ($reply->created_by != get_current_user_id()) {
254 593 return false;
@@ -253,8 +592,9 @@
253 592 if ($reply->created_by != get_current_user_id()) {
254 593 return false;
255 594 }
256 595
596 + // Delete reply (model deleting event will handle images)
257 597 $reply->delete();
258 598
259 599 // do_action('fluent_boards/comment_deleted', $taskId);
260 600 }
@@ -259,18 +599,14 @@
259 599 // do_action('fluent_boards/comment_deleted', $taskId);
260 600 }
261 601
262 602 /**
263 - * Adds a task attachment to the specified task.
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.
264 605 *
265 - * @param int $taskId The ID of the task to which the attachment is added.
266 - * @param string $title The title of the attachment.
267 - * @param string $url The URL of the attachment.
268 - *
269 - * @return Attachment The updated list of task attachments.
270 - * @throws \Exception
606 + * @return CommentImage
271 607 */
272 - public function createCommentImage($data, $boardId)
608 + public function createCommentImage($data, $boardId, $taskId = null)
273 609 {
274 610 /*
275 611 * I will refactor this function later- within March 2024 Last Week
276 612 */
@@ -293,26 +629,31 @@
293 629 $attachment->title = $this->setTitle($attachData['type'], $attachData['name'], $UrlMeta);
294 630 $attachment->file_path = $attachData['type'] != 'url' ? $attachData['file'] : null;
295 631 $attachment->full_url = esc_url($attachData['url']);
296 632 $attachment->file_size = $attachData['size'];
297 - $attachment->settings = $attachData['type'] == 'url' ? [
633 + $settings = $attachData['type'] == 'url' ? [
298 634 'meta' => $UrlMeta
299 - ] : '';
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);
300 642 $attachment->driver = 'local';
301 643 $attachment->save();
302 644
303 - $attachment->public_url = $this->createPublicUrl($attachment, $boardId);
304 -
305 645 return $attachment;
306 646 }
307 647
308 - private function createPublicUrl($attachment, $boardId)
648 + public function createPublicUrl($attachment, $boardId)
309 649 {
650 + $boardId = absint($boardId);
651 +
310 652 return add_query_arg([
311 653 'fbs' => 1,
312 654 'fbs_type' => 'public_url',
313 - 'fbs_bid' => $boardId,
314 - 'fbs_comment_image' => $attachment->file_hash
655 + 'fbs_comment_image' => $attachment->file_hash,
315 656 ], site_url('/index.php'));
316 657 }
317 658
318 659 private function setTitle($type, $title, $UrlMeta)