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.1 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 All 44 releases
← All changes | app/Services/CommentService.php +450 -105 1.32 → 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();
@@ -196,27 +543,22 @@
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 - $deleted = $comment->delete();
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 - 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 561 do_action('fluent_boards/comment_deleted', $comment);
220 562 }
221 563
222 564 public function relatedReplyDelete($comment_id)
@@ -224,15 +566,16 @@
224 566 $replies = Comment::where('parent_id', $comment_id)
225 567 ->type('reply')
226 568 ->get();
227 569 foreach ($replies as $reply) {
570 + // Delete reply (model deleting event will handle images)
228 571 $reply->delete();
229 572 }
230 573 }
231 574
232 - public function updateReply($replyData, $id)
575 + public function updateReply($replyData, $id, $boardId = null)
233 576 {
234 - $reply = Comment::findOrFail($id);
577 + $reply = $boardId ? $this->findCommentOnBoard($id, $boardId) : Comment::findOrFail($id);
235 578
236 579 if ($reply->created_by != get_current_user_id()) {
237 580 return false;
238 581 }
@@ -244,11 +587,11 @@
244 587
245 588 return $reply;
246 589 }
247 590
248 - public function deleteReply($id)
591 + public function deleteReply($id, $boardId = null)
249 592 {
250 - $reply = Comment::findOrFail($id);
593 + $reply = $boardId ? $this->findCommentOnBoard($id, $boardId) : Comment::findOrFail($id);
251 594 // $taskId = $reply->task_id;
252 595
253 596 if ($reply->created_by != get_current_user_id()) {
254 597 return false;
@@ -253,8 +596,9 @@
253 596 if ($reply->created_by != get_current_user_id()) {
254 597 return false;
255 598 }
256 599
600 + // Delete reply (model deleting event will handle images)
257 601 $reply->delete();
258 602
259 603 // do_action('fluent_boards/comment_deleted', $taskId);
260 604 }
@@ -259,18 +603,14 @@
259 603 // do_action('fluent_boards/comment_deleted', $taskId);
260 604 }
261 605
262 606 /**
263 - * 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.
264 609 *
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
610 + * @return CommentImage
271 611 */
272 - public function createCommentImage($data, $boardId)
612 + public function createCommentImage($data, $boardId, $taskId = null)
273 613 {
274 614 /*
275 615 * I will refactor this function later- within March 2024 Last Week
276 616 */
@@ -293,26 +633,31 @@
293 633 $attachment->title = $this->setTitle($attachData['type'], $attachData['name'], $UrlMeta);
294 634 $attachment->file_path = $attachData['type'] != 'url' ? $attachData['file'] : null;
295 635 $attachment->full_url = esc_url($attachData['url']);
296 636 $attachment->file_size = $attachData['size'];
297 - $attachment->settings = $attachData['type'] == 'url' ? [
637 + $settings = $attachData['type'] == 'url' ? [
298 638 'meta' => $UrlMeta
299 - ] : '';
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);
300 646 $attachment->driver = 'local';
301 647 $attachment->save();
302 648
303 - $attachment->public_url = $this->createPublicUrl($attachment, $boardId);
304 -
305 649 return $attachment;
306 650 }
307 651
308 - private function createPublicUrl($attachment, $boardId)
652 + public function createPublicUrl($attachment, $boardId)
309 653 {
654 + $boardId = absint($boardId);
655 +
310 656 return add_query_arg([
311 657 'fbs' => 1,
312 658 'fbs_type' => 'public_url',
313 - 'fbs_bid' => $boardId,
314 - 'fbs_comment_image' => $attachment->file_hash
659 + 'fbs_comment_image' => $attachment->file_hash,
315 660 ], site_url('/index.php'));
316 661 }
317 662
318 663 private function setTitle($type, $title, $UrlMeta)