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/AttachmentFileService.php +94 -33 2.0.4 → 2.2.0 View file →
@@ -2,11 +2,14 @@
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;
11 +use Exception;
9 12
10 13 class AttachmentFileService
11 14 {
12 15 protected $createdFiles = [];
@@ -43,8 +46,52 @@
43 46
44 47 return $task;
45 48 }
46 49
50 + /**
51 + * Move every comment and reply image for a task to another board.
52 + */
53 + public function moveCommentImagesToBoard($taskId, $sourceBoardId, $targetBoardId)
54 + {
55 + $taskId = absint($taskId);
56 + $sourceBoardId = absint($sourceBoardId);
57 + $targetBoardId = absint($targetBoardId);
58 +
59 + if (!$taskId || !$sourceBoardId || !$targetBoardId || $sourceBoardId === $targetBoardId) {
60 + return;
61 + }
62 +
63 + $commentIds = Comment::where('task_id', $taskId)
64 + ->pluck('id')
65 + ->toArray();
66 + $commentIds = array_filter(array_map('intval', $commentIds));
67 +
68 + if (!$commentIds) {
69 + return;
70 + }
71 +
72 + $images = CommentImage::whereIn('object_id', $commentIds)
73 + ->where('object_type', Constant::COMMENT_IMAGE)
74 + ->get();
75 + $commentCreators = Comment::whereIn('id', $commentIds)
76 + ->pluck('created_by', 'id')
77 + ->toArray();
78 + $sharedFullUrls = $this->getSharedFullUrlLookup($this->combineAttachmentCollections($images));
79 + $commentService = new CommentService();
80 +
81 + CommentImage::withoutTimestamps(function () use ($images, $sourceBoardId, $targetBoardId, $taskId, $sharedFullUrls, $commentCreators, $commentService) {
82 + foreach ($images as $image) {
83 + $commentService->applyCommentImageScope(
84 + $image,
85 + $targetBoardId,
86 + $taskId,
87 + $commentCreators[$image->object_id] ?? null
88 + );
89 + $this->moveAttachmentToBoard($image, $sourceBoardId, $targetBoardId, $sharedFullUrls);
90 + }
91 + });
92 + }
93 +
47 94 public function cloneTaskFilesToBoard(Task $sourceTask, Task $targetTask, $targetBoardId, array $options = [])
48 95 {
49 96 $options = array_merge([
50 97 'description_images' => true,
@@ -252,8 +299,11 @@
252 299
253 300 return array_fill_keys($sharedUrls, true);
254 301 }
255 302
303 + /**
304 + * Copy a local file into a protected board directory and track it for rollback.
305 + */
256 306 protected function copyAttachmentFileData(Attachment $source, Attachment $target, $sourceBoardId, $targetBoardId)
257 307 {
258 308 $result = [
259 309 'old_path' => null,
@@ -273,8 +323,12 @@
273 323 if (!$sourcePath || !is_file($sourcePath) || !is_readable($sourcePath)) {
274 324 return $result;
275 325 }
276 326
327 + if (!FileSystem::installUploadProtectionRules()) {
328 + throw new Exception(__('Could not prepare upload-directory protection. Please ask your hosting provider to block direct file access and check directory permissions.', 'fluent-boards'));
329 + }
330 +
277 331 $targetDir = $this->getBoardDir($targetBoardId);
278 332 if (!is_dir($targetDir)) {
279 333 wp_mkdir_p($targetDir);
280 334 }
@@ -305,21 +359,55 @@
305 359 if (!$urlMap || empty($task->description)) {
306 360 return;
307 361 }
308 362
309 - $description = $task->description;
310 -
363 + $replacements = [];
311 364 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);
365 + $identity = $this->getPublicAttachmentUrlIdentity($oldUrl);
366 + if ($identity !== null) {
367 + $replacements[$identity] = $newUrl;
368 + }
315 369 }
316 370
371 + // Keep HTML and Markdown delimiters outside the URL being replaced.
372 + $description = preg_replace_callback('~https?://[^\s<>"\')\]]+~i', function ($matches) use ($replacements) {
373 + $url = html_entity_decode($matches[0], ENT_QUOTES | ENT_HTML5, 'UTF-8');
374 + $identity = $this->getPublicAttachmentUrlIdentity($url);
375 + if ($identity === null || !isset($replacements[$identity])) {
376 + return $matches[0];
377 + }
378 +
379 + $newUrl = $replacements[$identity];
380 + return $url === $matches[0] ? $newUrl : esc_attr($newUrl);
381 + }, $task->description);
382 +
317 383 if ($description !== $task->description) {
318 384 $task->description = $description;
319 385 }
320 386 }
321 387
388 + /**
389 + * Match stored public image URLs by endpoint and query, excluding renewable credentials.
390 + */
391 + protected function getPublicAttachmentUrlIdentity($url)
392 + {
393 + $parts = wp_parse_url($url);
394 + if (!$parts || empty($parts['query'])) {
395 + return null;
396 + }
397 +
398 + parse_str($parts['query'], $query);
399 + if (($query['fbs_type'] ?? null) !== 'public_url' || empty($query['fbs_comment_image'])) {
400 + return null;
401 + }
402 +
403 + unset($query[Constant::ATTACHMENT_LEGACY_SIGNATURE_QUERY_KEY], $query[Constant::ATTACHMENT_LEGACY_EXPIRES_QUERY_KEY]);
404 + ksort($query);
405 + $parts['query'] = $query;
406 +
407 + return serialize($parts);
408 + }
409 +
322 410 protected function updateTaskCoverFromFileResults(Task $task, array $fileResults, $targetBoardId, $sourceCoverImageId = null)
323 411 {
324 412 $settings = $task->settings;
325 413 if (empty($settings['cover']) || !is_array($settings['cover'])) {
@@ -351,36 +439,9 @@
351 439 }
352 440
353 441 protected function resolveLocalPath(Attachment $attachment, $boardId)
354 442 {
355 - if (!empty($attachment->file_path) && is_file($attachment->file_path)) {
356 - return $attachment->file_path;
357 - }
358 -
359 - if (!empty($attachment->full_url)) {
360 - $uploadDir = wp_upload_dir();
361 - $pathFromUrl = rawurldecode(str_replace($uploadDir['baseurl'], $uploadDir['basedir'], $attachment->full_url));
362 - if (is_file($pathFromUrl)) {
363 - return $pathFromUrl;
364 - }
365 -
366 - if ($boardId) {
367 - $urlPath = wp_parse_url($attachment->full_url, PHP_URL_PATH);
368 - $pathFromBoardUrl = $urlPath ? $this->getBoardDir($boardId) . DIRECTORY_SEPARATOR . basename(rawurldecode($urlPath)) : null;
369 - if ($pathFromBoardUrl && is_file($pathFromBoardUrl)) {
370 - return $pathFromBoardUrl;
371 - }
372 - }
373 - }
374 -
375 - if ($boardId && !empty($attachment->file_path)) {
376 - $pathFromBoard = $this->getBoardDir($boardId) . DIRECTORY_SEPARATOR . basename(rawurldecode($attachment->file_path));
377 - if (is_file($pathFromBoard)) {
378 - return $pathFromBoard;
379 - }
380 - }
381 -
382 - return null;
443 + return FileSystem::resolveLocalAttachmentPath($attachment->file_path, $boardId);
383 444 }
384 445
385 446 protected function getAttachmentFilename(Attachment $attachment)
386 447 {