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/AttachmentFileService.php +92 -31 1.952.1.0 View file →
@@ -2,8 +2,10 @@
2 2
3 3 namespace FluentBoards\App\Services;
4 4
5 5 use FluentBoards\App\Models\Attachment;
6 +use FluentBoards\App\Models\Comment;
7 +use FluentBoards\App\Models\CommentImage;
6 8 use FluentBoards\App\Models\Task;
7 9 use FluentBoards\App\Models\TaskImage;
8 10 use FluentBoards\App\Services\Libs\FileSystem;
9 11
@@ -43,8 +45,52 @@
43 45
44 46 return $task;
45 47 }
46 48
49 + /**
50 + * Move every comment and reply image for a task to another board.
51 + */
52 + public function moveCommentImagesToBoard($taskId, $sourceBoardId, $targetBoardId)
53 + {
54 + $taskId = absint($taskId);
55 + $sourceBoardId = absint($sourceBoardId);
56 + $targetBoardId = absint($targetBoardId);
57 +
58 + if (!$taskId || !$sourceBoardId || !$targetBoardId || $sourceBoardId === $targetBoardId) {
59 + return;
60 + }
61 +
62 + $commentIds = Comment::where('task_id', $taskId)
63 + ->pluck('id')
64 + ->toArray();
65 + $commentIds = array_filter(array_map('intval', $commentIds));
66 +
67 + if (!$commentIds) {
68 + return;
69 + }
70 +
71 + $images = CommentImage::whereIn('object_id', $commentIds)
72 + ->where('object_type', Constant::COMMENT_IMAGE)
73 + ->get();
74 + $commentCreators = Comment::whereIn('id', $commentIds)
75 + ->pluck('created_by', 'id')
76 + ->toArray();
77 + $sharedFullUrls = $this->getSharedFullUrlLookup($this->combineAttachmentCollections($images));
78 + $commentService = new CommentService();
79 +
80 + CommentImage::withoutTimestamps(function () use ($images, $sourceBoardId, $targetBoardId, $taskId, $sharedFullUrls, $commentCreators, $commentService) {
81 + foreach ($images as $image) {
82 + $commentService->applyCommentImageScope(
83 + $image,
84 + $targetBoardId,
85 + $taskId,
86 + $commentCreators[$image->object_id] ?? null
87 + );
88 + $this->moveAttachmentToBoard($image, $sourceBoardId, $targetBoardId, $sharedFullUrls);
89 + }
90 + });
91 + }
92 +
47 93 public function cloneTaskFilesToBoard(Task $sourceTask, Task $targetTask, $targetBoardId, array $options = [])
48 94 {
49 95 $options = array_merge([
50 96 'description_images' => true,
@@ -181,9 +227,9 @@
181 227 }
182 228
183 229 protected function cloneTaskAttachmentsToBoard(Task $sourceTask, Task $targetTask, $targetBoardId)
184 230 {
185 - if (!class_exists('\FluentBoardsPro\App\Models\TaskAttachment')) {
231 + if (!defined('FLUENT_BOARDS_PRO_VERSION')) {
186 232 return;
187 233 }
188 234
189 235 $attachments = \FluentBoardsPro\App\Models\TaskAttachment::where('object_id', $sourceTask->id)
@@ -203,9 +249,9 @@
203 249 }
204 250
205 251 protected function getTaskAttachmentsForMove(Task $task)
206 252 {
207 - if (!class_exists('\FluentBoardsPro\App\Models\TaskAttachment')) {
253 + if (!defined('FLUENT_BOARDS_PRO_VERSION')) {
208 254 return [];
209 255 }
210 256
211 257 return \FluentBoardsPro\App\Models\TaskAttachment::where('object_id', $task->id)
@@ -269,10 +315,10 @@
269 315 return $result;
270 316 }
271 317
272 318 $sourcePath = $this->resolveLocalPath($source, $sourceBoardId);
273 - if (!$sourcePath || !file_exists($sourcePath)) {
274 - throw new \Exception(esc_html__('Attachment file could not be found.', 'fluent-boards'));
319 + if (!$sourcePath || !is_file($sourcePath) || !is_readable($sourcePath)) {
320 + return $result;
275 321 }
276 322
277 323 $targetDir = $this->getBoardDir($targetBoardId);
278 324 if (!is_dir($targetDir)) {
@@ -305,21 +351,55 @@
305 351 if (!$urlMap || empty($task->description)) {
306 352 return;
307 353 }
308 354
309 - $description = $task->description;
310 -
355 + $replacements = [];
311 356 foreach ($urlMap as $oldUrl => $newUrl) {
312 - $description = str_replace($oldUrl, $newUrl, $description);
313 - $description = str_replace(esc_url($oldUrl), esc_url($newUrl), $description);
314 - $description = str_replace(esc_attr($oldUrl), esc_attr($newUrl), $description);
357 + $identity = $this->getPublicAttachmentUrlIdentity($oldUrl);
358 + if ($identity !== null) {
359 + $replacements[$identity] = $newUrl;
360 + }
315 361 }
316 362
363 + // Keep HTML and Markdown delimiters outside the URL being replaced.
364 + $description = preg_replace_callback('~https?://[^\s<>"\')\]]+~i', function ($matches) use ($replacements) {
365 + $url = html_entity_decode($matches[0], ENT_QUOTES | ENT_HTML5, 'UTF-8');
366 + $identity = $this->getPublicAttachmentUrlIdentity($url);
367 + if ($identity === null || !isset($replacements[$identity])) {
368 + return $matches[0];
369 + }
370 +
371 + $newUrl = $replacements[$identity];
372 + return $url === $matches[0] ? $newUrl : esc_attr($newUrl);
373 + }, $task->description);
374 +
317 375 if ($description !== $task->description) {
318 376 $task->description = $description;
319 377 }
320 378 }
321 379
380 + /**
381 + * Match stored public image URLs by endpoint and query, excluding renewable credentials.
382 + */
383 + protected function getPublicAttachmentUrlIdentity($url)
384 + {
385 + $parts = wp_parse_url($url);
386 + if (!$parts || empty($parts['query'])) {
387 + return null;
388 + }
389 +
390 + parse_str($parts['query'], $query);
391 + if (($query['fbs_type'] ?? null) !== 'public_url' || empty($query['fbs_comment_image'])) {
392 + return null;
393 + }
394 +
395 + unset($query[Constant::ATTACHMENT_LEGACY_SIGNATURE_QUERY_KEY], $query[Constant::ATTACHMENT_LEGACY_EXPIRES_QUERY_KEY]);
396 + ksort($query);
397 + $parts['query'] = $query;
398 +
399 + return serialize($parts);
400 + }
401 +
322 402 protected function updateTaskCoverFromFileResults(Task $task, array $fileResults, $targetBoardId, $sourceCoverImageId = null)
323 403 {
324 404 $settings = $task->settings;
325 405 if (empty($settings['cover']) || !is_array($settings['cover'])) {
@@ -338,9 +418,9 @@
338 418 }
339 419
340 420 protected function refreshTaskAttachmentCount(Task $task)
341 421 {
342 - if (!class_exists('\FluentBoardsPro\App\Models\TaskAttachment')) {
422 + if (!defined('FLUENT_BOARDS_PRO_VERSION')) {
343 423 return;
344 424 }
345 425
346 426 $settings = $task->settings ?: [];
@@ -351,28 +431,9 @@
351 431 }
352 432
353 433 protected function resolveLocalPath(Attachment $attachment, $boardId)
354 434 {
355 - if (!empty($attachment->file_path) && file_exists($attachment->file_path)) {
356 - return $attachment->file_path;
357 - }
358 -
359 - if (!empty($attachment->full_url)) {
360 - $uploadDir = wp_upload_dir();
361 - $pathFromUrl = str_replace($uploadDir['baseurl'], $uploadDir['basedir'], $attachment->full_url);
362 - if (file_exists($pathFromUrl)) {
363 - return $pathFromUrl;
364 - }
365 - }
366 -
367 - if ($boardId && !empty($attachment->file_path)) {
368 - $pathFromBoard = $this->getBoardDir($boardId) . DIRECTORY_SEPARATOR . basename($attachment->file_path);
369 - if (file_exists($pathFromBoard)) {
370 - return $pathFromBoard;
371 - }
372 - }
373 -
374 - return null;
435 + return FileSystem::resolveLocalAttachmentPath($attachment->file_path, $boardId);
375 436 }
376 437
377 438 protected function getAttachmentFilename(Attachment $attachment)
378 439 {
@@ -417,9 +478,9 @@
417 478 }
418 479
419 480 protected function shouldStoreAbsolutePath(Attachment $attachment)
420 481 {
421 - return !empty($attachment->file_path) && file_exists($attachment->file_path);
482 + return !empty($attachment->file_path) && is_file($attachment->file_path);
422 483 }
423 484
424 485 protected function getCoverImageId(Task $task)
425 486 {