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
fluent-boards / app / Services / AttachmentFileService.php

AttachmentFileService.php in FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration 2.2.0, at app/Services/AttachmentFileService.php

523 lines 18.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentBoards\App\Services;
4
5 use FluentBoards\App\Models\Attachment;
6 use FluentBoards\App\Models\Comment;
7 use FluentBoards\App\Models\CommentImage;
8 use FluentBoards\App\Models\Task;
9 use FluentBoards\App\Models\TaskImage;
10 use FluentBoards\App\Services\Libs\FileSystem;
11 use Exception;
12
13 class AttachmentFileService
14 {
15 protected $createdFiles = [];
16 protected $createdAttachments = [];
17 protected $movedOriginalFiles = [];
18
19 public function moveTaskFilesToBoard(Task $task, $sourceBoardId, $targetBoardId)
20 {
21 if ((int) $sourceBoardId === (int) $targetBoardId) {
22 return $task;
23 }
24
25 $descriptionUrlMap = [];
26 $movedImages = [];
27
28 $images = TaskImage::where('object_id', $task->id)
29 ->where('object_type', Constant::TASK_DESCRIPTION)
30 ->get();
31 $taskAttachments = $this->getTaskAttachmentsForMove($task);
32 $sharedFullUrls = $this->getSharedFullUrlLookup($this->combineAttachmentCollections($images, $taskAttachments));
33
34 foreach ($images as $image) {
35 $result = $this->moveAttachmentToBoard($image, $sourceBoardId, $targetBoardId, $sharedFullUrls);
36 $movedImages[$image->id] = $result;
37
38 if (!empty($result['old_public_url']) && !empty($result['new_public_url'])) {
39 $descriptionUrlMap[$result['old_public_url']] = $result['new_public_url'];
40 }
41 }
42
43 $this->moveTaskAttachmentsToBoard($taskAttachments, $sourceBoardId, $targetBoardId, $sharedFullUrls);
44 $this->rewriteTaskDescriptionUrls($task, $descriptionUrlMap);
45 $this->updateTaskCoverFromFileResults($task, $movedImages, $targetBoardId);
46
47 return $task;
48 }
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
94 public function cloneTaskFilesToBoard(Task $sourceTask, Task $targetTask, $targetBoardId, array $options = [])
95 {
96 $options = array_merge([
97 'description_images' => true,
98 'cover' => true,
99 'task_attachments' => true,
100 ], $options);
101
102 $descriptionUrlMap = [];
103 $clonedImages = [];
104 $coverImageId = $this->getCoverImageId($sourceTask);
105
106 if ($options['description_images'] || ($options['cover'] && $coverImageId)) {
107 $imageQuery = TaskImage::where('object_id', $sourceTask->id)
108 ->where('object_type', Constant::TASK_DESCRIPTION);
109
110 if (!$options['description_images'] && $coverImageId) {
111 $imageQuery->where('id', $coverImageId);
112 }
113
114 $images = $imageQuery->get();
115
116 foreach ($images as $image) {
117 $result = $this->cloneAttachmentToBoard($image, $targetTask->id, $targetBoardId, Constant::TASK_DESCRIPTION, $sourceTask->board_id);
118 $clonedImages[$image->id] = $result;
119
120 if (!empty($result['old_public_url']) && !empty($result['new_public_url'])) {
121 $descriptionUrlMap[$result['old_public_url']] = $result['new_public_url'];
122 }
123 }
124 }
125
126 if ($options['task_attachments']) {
127 $this->cloneTaskAttachmentsToBoard($sourceTask, $targetTask, $targetBoardId);
128 }
129
130 $this->rewriteTaskDescriptionUrls($targetTask, $descriptionUrlMap);
131 $this->updateTaskCoverFromFileResults($targetTask, $clonedImages, $targetBoardId, $coverImageId);
132 $this->refreshTaskAttachmentCount($targetTask);
133 $targetTask->save();
134
135 return $targetTask;
136 }
137
138 public function cloneAttachmentToBoard(Attachment $source, $targetObjectId, $targetBoardId, $objectType = null, $sourceBoardId = null)
139 {
140 $clonedAttachment = $source->replicate();
141 $clonedAttachment->object_id = (int) $targetObjectId;
142 $clonedAttachment->object_type = $objectType ?: $source->object_type;
143 $clonedAttachment->file_hash = $this->generateFileHash();
144
145 $result = $this->copyAttachmentFileData($source, $clonedAttachment, $sourceBoardId, $targetBoardId);
146 if ($this->isLocalFileAttachment($source) && empty($result['copied'])) {
147 return $result;
148 }
149
150 $clonedAttachment->save();
151 $this->createdAttachments[] = [
152 'class' => get_class($clonedAttachment),
153 'id' => $clonedAttachment->id,
154 ];
155
156 $result['attachment'] = $clonedAttachment;
157 $result['new_attachment_id'] = $clonedAttachment->id;
158 $result['new_public_url'] = $this->createPublicUrl($clonedAttachment, $targetBoardId);
159
160 return $result;
161 }
162
163 public function moveAttachmentToBoard(Attachment $attachment, $sourceBoardId, $targetBoardId, array $sharedFullUrls = [])
164 {
165 $oldFullUrl = $attachment->full_url;
166 $oldPublicUrl = $this->createPublicUrl($attachment, $sourceBoardId);
167
168 $result = $this->copyAttachmentFileData($attachment, $attachment, $sourceBoardId, $targetBoardId);
169 if ($this->isLocalFileAttachment($attachment) && empty($result['copied'])) {
170 $result['attachment'] = $attachment;
171 $result['old_public_url'] = $oldPublicUrl;
172 $result['new_public_url'] = $oldPublicUrl;
173
174 return $result;
175 }
176
177 $attachment->file_hash = $this->generateFileHash();
178 $attachment->save();
179
180 $result['attachment'] = $attachment;
181 $result['old_public_url'] = $oldPublicUrl;
182 $result['new_public_url'] = $this->createPublicUrl($attachment, $targetBoardId);
183
184 if ($this->isLocalFileAttachment($attachment) && !empty($result['old_path']) && file_exists($result['old_path'])) {
185 if (empty($sharedFullUrls[$oldFullUrl]) && $result['old_path'] !== $result['new_path']) {
186 $this->movedOriginalFiles[] = $result['old_path'];
187 }
188 }
189
190 return $result;
191 }
192
193 public function commitMovedOriginalFiles()
194 {
195 foreach (array_unique($this->movedOriginalFiles) as $file) {
196 if (file_exists($file)) {
197 wp_delete_file($file);
198 }
199 }
200
201 $this->createdFiles = [];
202 $this->createdAttachments = [];
203 $this->movedOriginalFiles = [];
204 }
205
206 public function rollbackCreatedFiles()
207 {
208 foreach (array_reverse($this->createdAttachments) as $createdAttachment) {
209 if (!class_exists($createdAttachment['class'])) {
210 continue;
211 }
212
213 $attachment = $createdAttachment['class']::find($createdAttachment['id']);
214 if ($attachment) {
215 $attachment->delete();
216 }
217 }
218
219 foreach (array_reverse($this->createdFiles) as $file) {
220 if (file_exists($file)) {
221 wp_delete_file($file);
222 }
223 }
224
225 $this->createdFiles = [];
226 $this->createdAttachments = [];
227 $this->movedOriginalFiles = [];
228 }
229
230 protected function cloneTaskAttachmentsToBoard(Task $sourceTask, Task $targetTask, $targetBoardId)
231 {
232 if (!defined('FLUENT_BOARDS_PRO_VERSION')) {
233 return;
234 }
235
236 $attachments = \FluentBoardsPro\App\Models\TaskAttachment::where('object_id', $sourceTask->id)
237 ->where('object_type', Constant::TASK_ATTACHMENT)
238 ->get();
239
240 foreach ($attachments as $attachment) {
241 $this->cloneAttachmentToBoard($attachment, $targetTask->id, $targetBoardId, Constant::TASK_ATTACHMENT, $sourceTask->board_id);
242 }
243 }
244
245 protected function moveTaskAttachmentsToBoard($attachments, $sourceBoardId, $targetBoardId, array $sharedFullUrls)
246 {
247 foreach ($attachments as $attachment) {
248 $this->moveAttachmentToBoard($attachment, $sourceBoardId, $targetBoardId, $sharedFullUrls);
249 }
250 }
251
252 protected function getTaskAttachmentsForMove(Task $task)
253 {
254 if (!defined('FLUENT_BOARDS_PRO_VERSION')) {
255 return [];
256 }
257
258 return \FluentBoardsPro\App\Models\TaskAttachment::where('object_id', $task->id)
259 ->where('object_type', Constant::TASK_ATTACHMENT)
260 ->get();
261 }
262
263 protected function combineAttachmentCollections(...$collections)
264 {
265 $attachments = [];
266
267 foreach ($collections as $collection) {
268 foreach ($collection as $attachment) {
269 $attachments[] = $attachment;
270 }
271 }
272
273 return $attachments;
274 }
275
276 protected function getSharedFullUrlLookup(array $attachments)
277 {
278 $fullUrls = [];
279 $attachmentIds = [];
280
281 foreach ($attachments as $attachment) {
282 if (!empty($attachment->full_url)) {
283 $fullUrls[] = $attachment->full_url;
284 $attachmentIds[] = (int) $attachment->id;
285 }
286 }
287
288 $fullUrls = array_values(array_unique($fullUrls));
289 $attachmentIds = array_filter(array_unique($attachmentIds));
290
291 if (!$fullUrls) {
292 return [];
293 }
294
295 $sharedUrls = Attachment::whereIn('full_url', $fullUrls)
296 ->whereNotIn('id', $attachmentIds)
297 ->pluck('full_url')
298 ->toArray();
299
300 return array_fill_keys($sharedUrls, true);
301 }
302
303 /**
304 * Copy a local file into a protected board directory and track it for rollback.
305 */
306 protected function copyAttachmentFileData(Attachment $source, Attachment $target, $sourceBoardId, $targetBoardId)
307 {
308 $result = [
309 'old_path' => null,
310 'new_path' => null,
311 'old_full_url' => $source->full_url,
312 'new_full_url' => $source->full_url,
313 'old_public_url' => $sourceBoardId ? $this->createPublicUrl($source, $sourceBoardId) : null,
314 'new_public_url' => null,
315 'copied' => false,
316 ];
317
318 if (!$this->isLocalFileAttachment($source)) {
319 return $result;
320 }
321
322 $sourcePath = $this->resolveLocalPath($source, $sourceBoardId);
323 if (!$sourcePath || !is_file($sourcePath) || !is_readable($sourcePath)) {
324 return $result;
325 }
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
331 $targetDir = $this->getBoardDir($targetBoardId);
332 if (!is_dir($targetDir)) {
333 wp_mkdir_p($targetDir);
334 }
335
336 $filename = $this->uniqueFilename($targetDir, $this->getAttachmentFilename($source));
337 $targetPath = $targetDir . DIRECTORY_SEPARATOR . $filename;
338
339 if (!copy($sourcePath, $targetPath)) {
340 throw new \Exception(esc_html__('Could not copy attachment file.', 'fluent-boards'));
341 }
342
343 $this->createdFiles[] = $targetPath;
344
345 $target->file_path = $this->shouldStoreAbsolutePath($source) ? $targetPath : $filename;
346 $target->full_url = $this->getBoardUrl($targetBoardId) . '/' . rawurlencode($filename);
347 $target->driver = 'local';
348
349 $result['old_path'] = $sourcePath;
350 $result['new_path'] = $targetPath;
351 $result['new_full_url'] = $target->full_url;
352 $result['copied'] = true;
353
354 return $result;
355 }
356
357 protected function rewriteTaskDescriptionUrls(Task $task, array $urlMap)
358 {
359 if (!$urlMap || empty($task->description)) {
360 return;
361 }
362
363 $replacements = [];
364 foreach ($urlMap as $oldUrl => $newUrl) {
365 $identity = $this->getPublicAttachmentUrlIdentity($oldUrl);
366 if ($identity !== null) {
367 $replacements[$identity] = $newUrl;
368 }
369 }
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
383 if ($description !== $task->description) {
384 $task->description = $description;
385 }
386 }
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
410 protected function updateTaskCoverFromFileResults(Task $task, array $fileResults, $targetBoardId, $sourceCoverImageId = null)
411 {
412 $settings = $task->settings;
413 if (empty($settings['cover']) || !is_array($settings['cover'])) {
414 return;
415 }
416
417 $coverImageId = $sourceCoverImageId ?: $this->getCoverImageId($task);
418 if (!$coverImageId || empty($fileResults[$coverImageId]['attachment'])) {
419 return;
420 }
421
422 $newCoverAttachment = $fileResults[$coverImageId]['attachment'];
423 $settings['cover']['imageId'] = $newCoverAttachment->id;
424 $settings['cover']['backgroundImage'] = $this->createPublicUrl($newCoverAttachment, $targetBoardId);
425 $task->settings = $settings;
426 }
427
428 protected function refreshTaskAttachmentCount(Task $task)
429 {
430 if (!defined('FLUENT_BOARDS_PRO_VERSION')) {
431 return;
432 }
433
434 $settings = $task->settings ?: [];
435 $settings['attachment_count'] = \FluentBoardsPro\App\Models\TaskAttachment::where('object_id', $task->id)
436 ->where('object_type', Constant::TASK_ATTACHMENT)
437 ->count();
438 $task->settings = $settings;
439 }
440
441 protected function resolveLocalPath(Attachment $attachment, $boardId)
442 {
443 return FileSystem::resolveLocalAttachmentPath($attachment->file_path, $boardId);
444 }
445
446 protected function getAttachmentFilename(Attachment $attachment)
447 {
448 if (!empty($attachment->file_path)) {
449 return basename($attachment->file_path);
450 }
451
452 $urlPath = wp_parse_url($attachment->full_url, PHP_URL_PATH);
453 if ($urlPath) {
454 return basename($urlPath);
455 }
456
457 return sanitize_file_name($attachment->title ?: wp_generate_uuid4());
458 }
459
460 protected function uniqueFilename($dir, $filename)
461 {
462 $filename = sanitize_file_name($filename);
463
464 if (function_exists('wp_unique_filename')) {
465 return wp_unique_filename($dir, $filename);
466 }
467
468 $candidate = $filename;
469 $index = 1;
470 $info = pathinfo($filename);
471 $name = $info['filename'] ?? $filename;
472 $extension = !empty($info['extension']) ? '.' . $info['extension'] : '';
473
474 while (file_exists($dir . DIRECTORY_SEPARATOR . $candidate)) {
475 $candidate = $name . '-' . $index . $extension;
476 $index++;
477 }
478
479 return $candidate;
480 }
481
482 protected function isLocalFileAttachment(Attachment $attachment)
483 {
484 return $attachment->attachment_type !== 'url'
485 && ($attachment->driver === 'local' || empty($attachment->driver));
486 }
487
488 protected function shouldStoreAbsolutePath(Attachment $attachment)
489 {
490 return !empty($attachment->file_path) && is_file($attachment->file_path);
491 }
492
493 protected function getCoverImageId(Task $task)
494 {
495 $settings = $task->settings;
496 return !empty($settings['cover']['imageId']) ? (int) $settings['cover']['imageId'] : null;
497 }
498
499 protected function createPublicUrl(Attachment $attachment, $boardId)
500 {
501 return (new CommentService())->createPublicUrl($attachment, $boardId);
502 }
503
504 protected function getBoardDir($boardId)
505 {
506 return FileSystem::setSubDir('board_' . absint($boardId))->getDir();
507 }
508
509 protected function getBoardUrl($boardId)
510 {
511 $uploadDir = wp_upload_dir();
512 $fbsUploadDir = apply_filters('fluent_boards/upload_folder_name', FLUENT_BOARDS_UPLOAD_DIR);
513
514 return $uploadDir['baseurl'] . '/' . trim($fbsUploadDir, '/') . '/board_' . absint($boardId);
515 }
516
517 protected function generateFileHash()
518 {
519 $uid = wp_generate_uuid4();
520 return md5($uid . wp_rand(0, 1000));
521 }
522 }
523