| @@ -7,8 +7,9 @@ | ||
| 7 | 7 | use FluentBoards\App\Models\CommentImage; |
| 8 | 8 | use FluentBoards\App\Models\Task; |
| 9 | 9 | use FluentBoards\App\Models\TaskImage; |
| 10 | 10 | use FluentBoards\App\Services\Libs\FileSystem; |
| 11 | +use Exception; | |
| 11 | 12 | |
| 12 | 13 | class AttachmentFileService |
| 13 | 14 | { |
| 14 | 15 | protected $createdFiles = []; |
| @@ -70,12 +71,22 @@ | ||
| 70 | 71 | |
| 71 | 72 | $images = CommentImage::whereIn('object_id', $commentIds) |
| 72 | 73 | ->where('object_type', Constant::COMMENT_IMAGE) |
| 73 | 74 | ->get(); |
| 75 | + $commentCreators = Comment::whereIn('id', $commentIds) | |
| 76 | + ->pluck('created_by', 'id') | |
| 77 | + ->toArray(); | |
| 74 | 78 | $sharedFullUrls = $this->getSharedFullUrlLookup($this->combineAttachmentCollections($images)); |
| 79 | + $commentService = new CommentService(); | |
| 75 | 80 | |
| 76 | - CommentImage::withoutTimestamps(function () use ($images, $sourceBoardId, $targetBoardId, $sharedFullUrls) { | |
| 81 | + CommentImage::withoutTimestamps(function () use ($images, $sourceBoardId, $targetBoardId, $taskId, $sharedFullUrls, $commentCreators, $commentService) { | |
| 77 | 82 | foreach ($images as $image) { |
| 83 | + $commentService->applyCommentImageScope( | |
| 84 | + $image, | |
| 85 | + $targetBoardId, | |
| 86 | + $taskId, | |
| 87 | + $commentCreators[$image->object_id] ?? null | |
| 88 | + ); | |
| 78 | 89 | $this->moveAttachmentToBoard($image, $sourceBoardId, $targetBoardId, $sharedFullUrls); |
| 79 | 90 | } |
| 80 | 91 | }); |
| 81 | 92 | } |
| @@ -288,8 +299,11 @@ | ||
| 288 | 299 | |
| 289 | 300 | return array_fill_keys($sharedUrls, true); |
| 290 | 301 | } |
| 291 | 302 | |
| 303 | + /** | |
| 304 | + * Copy a local file into a protected board directory and track it for rollback. | |
| 305 | + */ | |
| 292 | 306 | protected function copyAttachmentFileData(Attachment $source, Attachment $target, $sourceBoardId, $targetBoardId) |
| 293 | 307 | { |
| 294 | 308 | $result = [ |
| 295 | 309 | 'old_path' => null, |
| @@ -309,8 +323,12 @@ | ||
| 309 | 323 | if (!$sourcePath || !is_file($sourcePath) || !is_readable($sourcePath)) { |
| 310 | 324 | return $result; |
| 311 | 325 | } |
| 312 | 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 | + | |
| 313 | 331 | $targetDir = $this->getBoardDir($targetBoardId); |
| 314 | 332 | if (!is_dir($targetDir)) { |
| 315 | 333 | wp_mkdir_p($targetDir); |
| 316 | 334 | } |
| @@ -341,21 +359,55 @@ | ||
| 341 | 359 | if (!$urlMap || empty($task->description)) { |
| 342 | 360 | return; |
| 343 | 361 | } |
| 344 | 362 | |
| 345 | - $description = $task->description; | |
| 346 | - | |
| 363 | + $replacements = []; | |
| 347 | 364 | foreach ($urlMap as $oldUrl => $newUrl) { |
| 348 | - $description = str_replace($oldUrl, $newUrl, $description); | |
| 349 | - $description = str_replace(esc_url($oldUrl), esc_url($newUrl), $description); | |
| 350 | - $description = str_replace(esc_attr($oldUrl), esc_attr($newUrl), $description); | |
| 365 | + $identity = $this->getPublicAttachmentUrlIdentity($oldUrl); | |
| 366 | + if ($identity !== null) { | |
| 367 | + $replacements[$identity] = $newUrl; | |
| 368 | + } | |
| 351 | 369 | } |
| 352 | 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 | + | |
| 353 | 383 | if ($description !== $task->description) { |
| 354 | 384 | $task->description = $description; |
| 355 | 385 | } |
| 356 | 386 | } |
| 357 | 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 | + | |
| 358 | 410 | protected function updateTaskCoverFromFileResults(Task $task, array $fileResults, $targetBoardId, $sourceCoverImageId = null) |
| 359 | 411 | { |
| 360 | 412 | $settings = $task->settings; |
| 361 | 413 | if (empty($settings['cover']) || !is_array($settings['cover'])) { |
| @@ -387,36 +439,9 @@ | ||
| 387 | 439 | } |
| 388 | 440 | |
| 389 | 441 | protected function resolveLocalPath(Attachment $attachment, $boardId) |
| 390 | 442 | { |
| 391 | - if (!empty($attachment->file_path) && is_file($attachment->file_path)) { | |
| 392 | - return $attachment->file_path; | |
| 393 | - } | |
| 394 | - | |
| 395 | - if (!empty($attachment->full_url)) { | |
| 396 | - $uploadDir = wp_upload_dir(); | |
| 397 | - $pathFromUrl = rawurldecode(str_replace($uploadDir['baseurl'], $uploadDir['basedir'], $attachment->full_url)); | |
| 398 | - if (is_file($pathFromUrl)) { | |
| 399 | - return $pathFromUrl; | |
| 400 | - } | |
| 401 | - | |
| 402 | - if ($boardId) { | |
| 403 | - $urlPath = wp_parse_url($attachment->full_url, PHP_URL_PATH); | |
| 404 | - $pathFromBoardUrl = $urlPath ? $this->getBoardDir($boardId) . DIRECTORY_SEPARATOR . basename(rawurldecode($urlPath)) : null; | |
| 405 | - if ($pathFromBoardUrl && is_file($pathFromBoardUrl)) { | |
| 406 | - return $pathFromBoardUrl; | |
| 407 | - } | |
| 408 | - } | |
| 409 | - } | |
| 410 | - | |
| 411 | - if ($boardId && !empty($attachment->file_path)) { | |
| 412 | - $pathFromBoard = $this->getBoardDir($boardId) . DIRECTORY_SEPARATOR . basename(rawurldecode($attachment->file_path)); | |
| 413 | - if (is_file($pathFromBoard)) { | |
| 414 | - return $pathFromBoard; | |
| 415 | - } | |
| 416 | - } | |
| 417 | - | |
| 418 | - return null; | |
| 443 | + return FileSystem::resolveLocalAttachmentPath($attachment->file_path, $boardId); | |
| 419 | 444 | } |
| 420 | 445 | |
| 421 | 446 | protected function getAttachmentFilename(Attachment $attachment) |
| 422 | 447 | { |