| @@ -18,8 +18,17 @@ | ||
| 18 | 18 | protected $bucket_name; |
| 19 | 19 | protected $region = ''; |
| 20 | 20 | |
| 21 | 21 | /** |
| 22 | + * Absolute paths restored from cloud (by any integration) pending removal | |
| 23 | + * again — fed into the pre-update pipeline if a save happens this request, | |
| 24 | + * with a shutdown fallback otherwise. See track_restored_for_cleanup(). | |
| 25 | + * @since 1.4.0 | |
| 26 | + */ | |
| 27 | + protected $pending_restored_files = []; | |
| 28 | + protected $pending_cleanup_hooked = false; | |
| 29 | + | |
| 30 | + /** | |
| 22 | 31 | * Admin constructor. |
| 23 | 32 | * @since 1.0.0 |
| 24 | 33 | */ |
| 25 | 34 | public function __construct() { |
| @@ -46,14 +55,10 @@ | ||
| 46 | 55 | $this->service = isset($this->credentials['service']) && !empty($this->credentials['service']) |
| 47 | 56 | ? $this->credentials['service'] |
| 48 | 57 | : ''; |
| 49 | 58 | |
| 50 | - if (isset($this->bucketConfig['bucket_name'])) { | |
| 51 | - $this->bucket_name = $this->bucketConfig['bucket_name']; | |
| 52 | - } | |
| 53 | - if (isset($this->config['region'])) { | |
| 54 | - $this->region = $this->config['region']; | |
| 55 | - } | |
| 59 | + $this->bucket_name = isset($this->bucketConfig['bucket_name']) ? $this->bucketConfig['bucket_name'] : ''; | |
| 60 | + $this->region = isset($this->config['region']) ? $this->config['region'] : ''; | |
| 56 | 61 | } |
| 57 | 62 | |
| 58 | 63 | /** |
| 59 | 64 | * Add Item In database |
| @@ -90,14 +95,44 @@ | ||
| 90 | 95 | |
| 91 | 96 | // Do some pre-update actions |
| 92 | 97 | $this->pre_update_item($source_id, $data, [], $source_type); |
| 93 | 98 | |
| 94 | - if ($wpdb->insert(Db::get_table_name(), $data)) { | |
| 95 | - $item_id = $wpdb->insert_id; | |
| 99 | + // Upsert instead of a plain insert — two independent triggers (this plugin's bulk | |
| 100 | + // sync, WordPress's own metadata hook, Imagify's re-sync) can land on the same | |
| 101 | + // attachment around the same time; this converges on one row instead of erroring. | |
| 102 | + $table = Db::get_table_name(); | |
| 103 | + $inserted = $wpdb->query($wpdb->prepare( | |
| 104 | + "INSERT INTO {$table} | |
| 105 | + (provider, region, storage, source_id, source_path, source_type, url, `key`, original_source_path, original_key, is_private, extra) | |
| 106 | + VALUES (%s, %s, %s, %d, %s, %s, %s, %s, %s, %s, %d, %s) | |
| 107 | + ON DUPLICATE KEY UPDATE | |
| 108 | + id = LAST_INSERT_ID(id), | |
| 109 | + source_path = VALUES(source_path), | |
| 110 | + url = VALUES(url), | |
| 111 | + `key` = VALUES(`key`), | |
| 112 | + original_source_path = VALUES(original_source_path), | |
| 113 | + original_key = VALUES(original_key), | |
| 114 | + is_private = VALUES(is_private), | |
| 115 | + extra = VALUES(extra)", | |
| 116 | + $data['provider'], $data['region'], $data['storage'], $data['source_id'], $data['source_path'], | |
| 117 | + $data['source_type'], $data['url'], $data['key'], $data['original_source_path'], $data['original_key'], | |
| 118 | + $data['is_private'], $data['extra'] | |
| 119 | + )); | |
| 120 | + | |
| 121 | + if ($inserted !== false) { | |
| 122 | + // rows_affected: 1 = new row, 2 = existing row updated. Capture before any | |
| 123 | + // other query on $wpdb overwrites it. | |
| 124 | + $is_new_row = (int) $wpdb->rows_affected === 1; | |
| 125 | + | |
| 126 | + $item_id = (int) $wpdb->insert_id; | |
| 96 | 127 | $data['id'] = $item_id; |
| 97 | 128 | Integration::update_meta($source_id, 'item', $data, false, false, $source_type); |
| 98 | 129 | Cache::update_item_cache($source_id.'_item_'.$source_type, $data); |
| 99 | - Counter::add( 'uploaded', $source_type ); | |
| 130 | + | |
| 131 | + // Only count a genuinely new item, not a duplicate-collision update. | |
| 132 | + if ($is_new_row) { | |
| 133 | + Counter::add( 'uploaded', $source_type ); | |
| 134 | + } | |
| 100 | 135 | } |
| 101 | 136 | |
| 102 | 137 | // Do some post-update actions |
| 103 | 138 | $this->post_update_item($source_id, $data, $source_type); |
| @@ -191,15 +226,20 @@ | ||
| 191 | 226 | |
| 192 | 227 | /** |
| 193 | 228 | * Function to update item in data base |
| 194 | 229 | * @since 1.0.0 |
| 230 | + * @param array|null $target_identity Optional ['provider'=>, 'storage'=>, 'region'=>] to | |
| 231 | + * move the row to a different connection's identity. | |
| 232 | + * The WHERE clause still uses this instance's own | |
| 233 | + * (source) binding to locate the row — only the SET | |
| 234 | + * values change. @since 1.4.0 | |
| 195 | 235 | */ |
| 196 | - public function update($source_id, $data, $source_type = 'media_library') { | |
| 236 | + public function update($source_id, $data, $source_type = 'media_library', $target_identity = null) { | |
| 197 | 237 | global $wpdb; |
| 198 | 238 | if (isset($source_id) && !Utils::is_empty($source_id)) { |
| 199 | - $data['provider'] = $this->service; | |
| 200 | - $data['storage'] = $this->bucket_name; | |
| 201 | - $data['region'] = $this->region; | |
| 239 | + $data['provider'] = $target_identity['provider'] ?? $this->service; | |
| 240 | + $data['storage'] = $target_identity['storage'] ?? $this->bucket_name; | |
| 241 | + $data['region'] = $target_identity['region'] ?? $this->region; | |
| 202 | 242 | |
| 203 | 243 | $old_item = $this->get($source_id, $source_type); |
| 204 | 244 | |
| 205 | 245 | // update data from $old_item if not exists in $data |
| @@ -213,8 +253,32 @@ | ||
| 213 | 253 | |
| 214 | 254 | // Do some pre-update actions |
| 215 | 255 | $this->pre_update_item($source_id, $data, $old_item, $source_type); |
| 216 | 256 | |
| 257 | + // Moving to a different identity (e.g. bucket_to_bucket migration) can collide | |
| 258 | + // with a stale row already sitting at that target identity — e.g. an interrupted | |
| 259 | + // earlier migration attempt, or the same item independently tracked under a | |
| 260 | + // connection this site used previously. uidx_item_source is UNIQUE on | |
| 261 | + // (source_id, source_type, provider, storage, region), so the UPDATE below would | |
| 262 | + // otherwise fail outright. The row being updated here is the current, live one; | |
| 263 | + // a pre-existing row already at the target is stale by definition — clear it | |
| 264 | + // first rather than letting the whole update silently fail. | |
| 265 | + $is_identity_move = $target_identity !== null && ( | |
| 266 | + $data['provider'] !== $this->service || | |
| 267 | + $data['storage'] !== $this->bucket_name || | |
| 268 | + $data['region'] !== $this->region | |
| 269 | + ); | |
| 270 | + if ($is_identity_move) { | |
| 271 | + $target_where = array( | |
| 272 | + 'source_id' => $source_id, | |
| 273 | + 'source_type' => $source_type, | |
| 274 | + 'provider' => $data['provider'], | |
| 275 | + 'storage' => $data['storage'], | |
| 276 | + 'region' => $data['region'], | |
| 277 | + ); | |
| 278 | + $wpdb->delete(Db::get_table_name(), $target_where); | |
| 279 | + } | |
| 280 | + | |
| 217 | 281 | $where = array( |
| 218 | 282 | 'source_id' => $source_id, |
| 219 | 283 | 'source_type' => $source_type, |
| 220 | 284 | 'provider' => $this->service, |
| @@ -230,10 +294,14 @@ | ||
| 230 | 294 | |
| 231 | 295 | Integration::delete_meta($source_id, 'item', false, $source_type); |
| 232 | 296 | Cache::delete_item_cache($source_id.'_item_'.$source_type); |
| 233 | 297 | |
| 234 | - // Reset cache | |
| 235 | - $this->get($source_id, $source_type); | |
| 298 | + if ($target_identity === null) { | |
| 299 | + // Reset cache — skipped when moving to a different identity: this instance's | |
| 300 | + // get() would search under the now-stale source identity and cache a false | |
| 301 | + // negative; the delete_item_cache() above is sufficient on its own there. | |
| 302 | + $this->get($source_id, $source_type); | |
| 303 | + } | |
| 236 | 304 | // Do some post-update actions |
| 237 | 305 | $this->post_update_item($source_id, $data, $source_type); |
| 238 | 306 | |
| 239 | 307 | return true; |
| @@ -314,10 +382,13 @@ | ||
| 314 | 382 | if( |
| 315 | 383 | ($check_rewrite && (isset($this->settings['rewrite_url']) && $this->settings['rewrite_url'])) || |
| 316 | 384 | !$check_rewrite |
| 317 | 385 | ) { |
| 386 | + if ($check_rewrite) { | |
| 387 | + return (bool) apply_filters('wpmcs_is_available_from_provider', true, $attachment_id, $source_type); | |
| 388 | + } | |
| 318 | 389 | return true; |
| 319 | - } | |
| 390 | + } | |
| 320 | 391 | } |
| 321 | 392 | return false; |
| 322 | 393 | } |
| 323 | 394 | |
| @@ -365,9 +436,9 @@ | ||
| 365 | 436 | return []; |
| 366 | 437 | } |
| 367 | 438 | |
| 368 | 439 | // Normalize & deduplicate |
| 369 | - $paths = array_unique( array_map( 'esc_sql', $paths ) ); | |
| 440 | + $paths = array_unique( $paths ); | |
| 370 | 441 | |
| 371 | 442 | $table = Db::get_table_name(); |
| 372 | 443 | |
| 373 | 444 | // Build USE INDEX clause from field map |
| @@ -396,12 +467,15 @@ | ||
| 396 | 467 | */ |
| 397 | 468 | $conditions = []; |
| 398 | 469 | |
| 399 | 470 | if ( $exact_match ) { |
| 400 | - $in = "'" . implode( "','", $paths ) . "'"; | |
| 471 | + $placeholders = implode( ',', array_fill( 0, count( $paths ), '%s' ) ); | |
| 401 | 472 | |
| 402 | 473 | foreach ( array_keys( $fields ) as $column ) { |
| 403 | - $conditions[] = "`{$column}` IN ({$in})"; | |
| 474 | + $conditions[] = "`{$column}` IN ({$placeholders})"; | |
| 475 | + foreach ( $paths as $path ) { | |
| 476 | + $params[] = $path; | |
| 477 | + } | |
| 404 | 478 | } |
| 405 | 479 | } else { |
| 406 | 480 | foreach ( $paths as $path ) { |
| 407 | 481 | $ext = pathinfo( $path, PATHINFO_EXTENSION ); |
| @@ -409,9 +483,10 @@ | ||
| 409 | 483 | ? substr_replace( $path, '%', -strlen( $ext ) - 1 ) |
| 410 | 484 | : $path . '%'; |
| 411 | 485 | |
| 412 | 486 | foreach ( array_keys( $fields ) as $column ) { |
| 413 | - $conditions[] = "`{$column}` LIKE '{$base}'"; | |
| 487 | + $conditions[] = "`{$column}` LIKE %s"; | |
| 488 | + $params[] = $base; | |
| 414 | 489 | } |
| 415 | 490 | } |
| 416 | 491 | } |
| 417 | 492 | |
| @@ -517,11 +592,9 @@ | ||
| 517 | 592 | * @param |
| 518 | 593 | */ |
| 519 | 594 | public function get_url($source_id, $size = 'full', $source_type = 'media_library'){ |
| 520 | 595 | if ($data = $this->get($source_id, $source_type)) { |
| 521 | - $extras = $this->get_extras($source_id, false, $source_type) ?: []; | |
| 522 | - $key = ''; | |
| 523 | - $url = ''; | |
| 596 | + $key = ''; | |
| 524 | 597 | switch($size) { |
| 525 | 598 | case 'full': |
| 526 | 599 | $key = $data['key']; |
| 527 | 600 | break; |
| @@ -530,8 +603,10 @@ | ||
| 530 | 603 | $key = $data['original_key']; |
| 531 | 604 | } |
| 532 | 605 | break; |
| 533 | 606 | default: |
| 607 | + // Only named sizes need extras — skip fetching them for 'full'/'original'. | |
| 608 | + $extras = $this->get_extras($source_id, false, $source_type) ?: []; | |
| 534 | 609 | if( |
| 535 | 610 | isset($extras) && !empty($extras) && |
| 536 | 611 | isset($extras['sizes']) && !empty($extras['sizes']) && |
| 537 | 612 | isset($extras['sizes'][$size]) && !empty($extras['sizes'][$size]) |
| @@ -546,9 +621,12 @@ | ||
| 546 | 621 | if ($privateUrl === false) { |
| 547 | 622 | $new_url = Service::instance()->get_private_url($key); |
| 548 | 623 | |
| 549 | 624 | if (!Utils::is_empty($new_url)) { |
| 550 | - $privateUrl = Cdn::may_generate_cdn_url($new_url, $key); | |
| 625 | + // No hook (Pro inactive, or the current delivery provider hasn't | |
| 626 | + // implemented one) means passthrough — same URL, unmodified. Real | |
| 627 | + // per-CDN rewriting (e.g. CloudFront signed URLs) is a Pro concern. | |
| 628 | + $privateUrl = apply_filters( 'wpmcs_generate_private_url', $new_url, $key ); | |
| 551 | 629 | $expireMinutes = (int)(isset($this->settings['private_url_expire']) && !empty($this->settings['private_url_expire'])) |
| 552 | 630 | ? $this->settings['private_url_expire'] |
| 553 | 631 | : 20; |
| 554 | 632 | $expireSeconds = $expireMinutes * 60; |
| @@ -578,11 +656,15 @@ | ||
| 578 | 656 | * @param string $size size of the file, default is full |
| 579 | 657 | * @param string $source_type source type of item, default is media_library |
| 580 | 658 | * @param bool $all if true, it will move all files to server |
| 581 | 659 | * @param bool $backup if true, it will move backup file to server |
| 660 | + * @param string $log_type error-log bucket to write to on failure — lets a caller other | |
| 661 | + * than the "Restore to Server" job (e.g. "Remove from Cloud", | |
| 662 | + * which also restores as a safety step) attribute failures to | |
| 663 | + * its own error list instead of Restore to Server's. | |
| 582 | 664 | * @return array an array of server file paths |
| 583 | 665 | */ |
| 584 | - public function moveToServer($source_id, $size = 'full', $source_type = 'media_library', $all = false, $backup = false){ | |
| 666 | + public function moveToServer($source_id, $size = 'full', $source_type = 'media_library', $all = false, $backup = false, $log_type = 'restore_to_server'){ | |
| 585 | 667 | $server_files = []; |
| 586 | 668 | $server_file = false; |
| 587 | 669 | $source_id = (int)$source_id; |
| 588 | 670 | $item = $this->get($source_id, $source_type); |
| @@ -587,12 +669,12 @@ | ||
| 587 | 669 | $source_id = (int)$source_id; |
| 588 | 670 | $item = $this->get($source_id, $source_type); |
| 589 | 671 | |
| 590 | 672 | // Remove log if exists before move to server |
| 591 | - Logger::instance()->remove_log('restore_to_server', $source_id, $source_type); | |
| 673 | + Logger::instance()->remove_log($log_type, $source_id, $source_type); | |
| 592 | 674 | |
| 593 | 675 | if ( isset($item) && !empty($item) ) { |
| 594 | - $files = $this->moveToServerByItem($item, $size, $all); | |
| 676 | + $files = $this->moveToServerByItem($item, $size, $all, $log_type); | |
| 595 | 677 | if (isset($files) && !empty($files)) { |
| 596 | 678 | $server_files = $all ? array_merge($server_files, $files) : $files; |
| 597 | 679 | } |
| 598 | 680 | } |
| @@ -598,9 +680,9 @@ | ||
| 598 | 680 | } |
| 599 | 681 | if( $all && $backup ) { |
| 600 | 682 | $backupItem = $this->get_backup($source_id, $source_type); |
| 601 | 683 | if (isset($backupItem) && !empty($backupItem)) { |
| 602 | - $files = $this->moveToServerByItem($backupItem, $size, $all); | |
| 684 | + $files = $this->moveToServerByItem($backupItem, $size, $all, $log_type); | |
| 603 | 685 | if (isset($files) && !empty($files)) { |
| 604 | 686 | $server_files['backup'] = $files; |
| 605 | 687 | } |
| 606 | 688 | } |
| @@ -615,11 +697,12 @@ | ||
| 615 | 697 | * @since 1.0.0 |
| 616 | 698 | * @param array $item |
| 617 | 699 | * @param string $size |
| 618 | 700 | * @param bool $all |
| 701 | + * @param string $log_type error-log bucket to write to on failure | |
| 619 | 702 | * @return array|string |
| 620 | 703 | */ |
| 621 | - public function moveToServerByItem( $item = [], $size = 'full', $all = false ) { | |
| 704 | + public function moveToServerByItem( $item = [], $size = 'full', $all = false, $log_type = 'restore_to_server' ) { | |
| 622 | 705 | $source_id = (int) ( $item['source_id'] ?? 0 ); |
| 623 | 706 | // Validate source ID |
| 624 | 707 | if( $source_id <= 0 ) { |
| 625 | 708 | return false; |
| @@ -648,8 +731,17 @@ | ||
| 648 | 731 | ]; |
| 649 | 732 | } |
| 650 | 733 | } |
| 651 | 734 | |
| 735 | + if ( ! empty( $extras['additional_files'] ) ) { | |
| 736 | + foreach ( $extras['additional_files'] as $name => $data ) { | |
| 737 | + $files[ $name ] = [ | |
| 738 | + 'key' => $data['key'] ?? null, | |
| 739 | + 'path' => $data['source_path'] ?? null, | |
| 740 | + ]; | |
| 741 | + } | |
| 742 | + } | |
| 743 | + | |
| 652 | 744 | // ALL files |
| 653 | 745 | if ( $all ) { |
| 654 | 746 | $results = []; |
| 655 | 747 | |
| @@ -657,9 +749,10 @@ | ||
| 657 | 749 | if ( $file = $this->move_to_server_by_key_and_path( |
| 658 | 750 | $data['key'], |
| 659 | 751 | $data['path'], |
| 660 | 752 | $source_id, |
| 661 | - $source_type | |
| 753 | + $source_type, | |
| 754 | + $log_type | |
| 662 | 755 | ) ) { |
| 663 | 756 | $results[ $label ] = $file; |
| 664 | 757 | } |
| 665 | 758 | } |
| @@ -672,9 +765,10 @@ | ||
| 672 | 765 | return $this->move_to_server_by_key_and_path( |
| 673 | 766 | $files[ $size ]['key'], |
| 674 | 767 | $files[ $size ]['path'], |
| 675 | 768 | $source_id, |
| 676 | - $source_type | |
| 769 | + $source_type, | |
| 770 | + $log_type | |
| 677 | 771 | ); |
| 678 | 772 | } |
| 679 | 773 | |
| 680 | 774 | return false; |
| @@ -679,11 +773,61 @@ | ||
| 679 | 773 | |
| 680 | 774 | return false; |
| 681 | 775 | } |
| 682 | 776 | |
| 777 | + /** | |
| 778 | + * Whether a moveToServer(..., $all=true, $backup=true) result actually restored | |
| 779 | + * everything this item is expected to have (every size, the original if present, and | |
| 780 | + * the backup entry if one exists) — moveToServer()'s return silently drops any single | |
| 781 | + * file that failed, so a plain non-empty check on it isn't enough to safely delete the | |
| 782 | + * cloud copies afterward. | |
| 783 | + * @since 1.4.1 | |
| 784 | + */ | |
| 785 | + public function verify_full_restore( $source_id, $source_type, $moved ) { | |
| 786 | + $row = $this->get( $source_id, $source_type ); | |
| 787 | + if ( empty( $row ) ) { | |
| 788 | + return false; | |
| 789 | + } | |
| 683 | 790 | |
| 791 | + $expected = $this->expected_restore_labels( $row ); | |
| 792 | + $restored = array_diff( array_keys( (array) $moved ), [ 'backup' ] ); | |
| 793 | + if ( ! empty( array_diff( $expected, $restored ) ) ) { | |
| 794 | + return false; | |
| 795 | + } | |
| 684 | 796 | |
| 797 | + $backup_item = $this->get_backup( $source_id, $source_type ); | |
| 798 | + if ( empty( $backup_item ) ) { | |
| 799 | + return true; | |
| 800 | + } | |
| 801 | + | |
| 802 | + $expected_backup = $this->expected_restore_labels( $backup_item ); | |
| 803 | + $restored_backup = ! empty( $moved['backup'] ) ? array_keys( $moved['backup'] ) : []; | |
| 804 | + return empty( array_diff( $expected_backup, $restored_backup ) ); | |
| 805 | + } | |
| 806 | + | |
| 685 | 807 | /** |
| 808 | + * File labels (full, original, each named size, each additional file) a given item row is expected to have. | |
| 809 | + */ | |
| 810 | + private function expected_restore_labels( $item_row ) { | |
| 811 | + $expected = [ 'full' ]; | |
| 812 | + if ( ! empty( $item_row['original_key'] ) || ! empty( $item_row['original_source_path'] ) ) { | |
| 813 | + $expected[] = 'original'; | |
| 814 | + } | |
| 815 | + | |
| 816 | + $extras = ! empty( $item_row['extra'] ) ? Utils::maybe_unserialize( $item_row['extra'] ) : []; | |
| 817 | + if ( ! empty( $extras['sizes'] ) ) { | |
| 818 | + $expected = array_merge( $expected, array_keys( $extras['sizes'] ) ); | |
| 819 | + } | |
| 820 | + if ( ! empty( $extras['additional_files'] ) ) { | |
| 821 | + $expected = array_merge( $expected, array_keys( $extras['additional_files'] ) ); | |
| 822 | + } | |
| 823 | + | |
| 824 | + return $expected; | |
| 825 | + } | |
| 826 | + | |
| 827 | + | |
| 828 | + | |
| 829 | + /** | |
| 686 | 830 | * Get service path of item from database by source url |
| 687 | 831 | * @since 1.0.0 |
| 688 | 832 | * @param int $source_id |
| 689 | 833 | * @param string $file |
| @@ -733,9 +877,9 @@ | ||
| 733 | 877 | return true; |
| 734 | 878 | } |
| 735 | 879 | |
| 736 | 880 | $extras = $this->get_extras( $source_id, false, $source_type ) ?: []; |
| 737 | - | |
| 881 | + | |
| 738 | 882 | // 3. Check sizes |
| 739 | 883 | if ( ! empty( $extras['sizes'] ) ) { |
| 740 | 884 | foreach ( $extras['sizes'] as $size ) { |
| 741 | 885 | if ( |
| @@ -753,8 +897,27 @@ | ||
| 753 | 897 | } |
| 754 | 898 | } |
| 755 | 899 | } |
| 756 | 900 | |
| 901 | + // 4. Check additional files (HEIC source, animated-GIF video/poster) | |
| 902 | + if ( ! empty( $extras['additional_files'] ) ) { | |
| 903 | + foreach ( $extras['additional_files'] as $additional_file ) { | |
| 904 | + if ( | |
| 905 | + isset( $additional_file['source_path'] ) && | |
| 906 | + ! empty( $additional_file['source_path'] ) && | |
| 907 | + $additional_file['source_path'] === $source_path && | |
| 908 | + $this->move_to_server_by_key_and_path( | |
| 909 | + $additional_file['key'] ?? null, | |
| 910 | + $additional_file['source_path'], | |
| 911 | + $source_id, | |
| 912 | + $source_type | |
| 913 | + ) | |
| 914 | + ) { | |
| 915 | + return true; | |
| 916 | + } | |
| 917 | + } | |
| 918 | + } | |
| 919 | + | |
| 757 | 920 | return false; |
| 758 | 921 | } |
| 759 | 922 | |
| 760 | 923 | /** |
| @@ -763,12 +926,13 @@ | ||
| 763 | 926 | * @param string $key |
| 764 | 927 | * @param string $relative_path |
| 765 | 928 | * @param int $source_id |
| 766 | 929 | * @param string $source_type |
| 930 | + * @param string $log_type error-log bucket to write to on failure | |
| 767 | 931 | * |
| 768 | 932 | * @return string|false |
| 769 | 933 | */ |
| 770 | - protected function move_to_server_by_key_and_path( $key, $relative_path, $source_id = 0, $source_type = 'media_library' ) { | |
| 934 | + protected function move_to_server_by_key_and_path( $key, $relative_path, $source_id = 0, $source_type = 'media_library', $log_type = 'restore_to_server' ) { | |
| 771 | 935 | if ( empty( $key ) || empty( $relative_path ) ) { |
| 772 | 936 | return false; |
| 773 | 937 | } |
| 774 | 938 | |
| @@ -778,13 +942,18 @@ | ||
| 778 | 942 | if ( file_exists( $file ) ) { |
| 779 | 943 | return $file; |
| 780 | 944 | } |
| 781 | 945 | |
| 782 | - if ( Service::instance()->object_to_server( $key, $file ) ) { | |
| 946 | + // Checked on disk rather than trusting the return value alone — at least one | |
| 947 | + // provider (Cloudflare R2) has been observed writing the file successfully while | |
| 948 | + // still reporting failure (an SDK-level error thrown after the save completes). | |
| 949 | + Service::instance()->object_to_server( $key, $file ); | |
| 950 | + | |
| 951 | + if ( file_exists( $file ) ) { | |
| 783 | 952 | return $file; |
| 784 | 953 | } |
| 785 | 954 | |
| 786 | - Logger::instance()->add_log( 'restore_to_server', $source_id, $source_type, [ | |
| 955 | + Logger::instance()->add_log( $log_type, $source_id, $source_type, [ | |
| 787 | 956 | 'message' => __( 'The file could not be copied to the server. Please try again.', 'media-cloud-sync' ), |
| 788 | 957 | 'file' => $key, |
| 789 | 958 | 'code' => 404, |
| 790 | 959 | ] ); |
| @@ -819,8 +988,13 @@ | ||
| 819 | 988 | /** |
| 820 | 989 | * Delete media item |
| 821 | 990 | */ |
| 822 | 991 | public function delete_attachments_by_item($item, $delete_backup = true) { |
| 992 | + // Lets an integration veto the delete when another row still relies on the same key. | |
| 993 | + if (!apply_filters('wpmcs_should_delete_cloud_files', true, $item)) { | |
| 994 | + return; | |
| 995 | + } | |
| 996 | + | |
| 823 | 997 | $upload_dir = wp_get_upload_dir(); |
| 824 | 998 | |
| 825 | 999 | if (isset($item['extra']) && !empty($item['extra'])) { |
| 826 | 1000 | $extras = Utils::maybe_unserialize($item['extra']); |
| @@ -836,8 +1010,19 @@ | ||
| 836 | 1010 | } |
| 837 | 1011 | |
| 838 | 1012 | if ( |
| 839 | 1013 | isset($extras) && !empty($extras) && |
| 1014 | + isset($extras['additional_files']) && !empty($extras['additional_files']) | |
| 1015 | + ) { | |
| 1016 | + foreach ($extras['additional_files'] as $file) { | |
| 1017 | + if (isset($file['key']) && !empty($file['key'])) { | |
| 1018 | + Service::instance()->deleteSingle($file['key']); | |
| 1019 | + } | |
| 1020 | + } | |
| 1021 | + } | |
| 1022 | + | |
| 1023 | + if ( | |
| 1024 | + isset($extras) && !empty($extras) && | |
| 840 | 1025 | isset($extras['backup']) && !empty($extras['backup']) && |
| 841 | 1026 | $delete_backup |
| 842 | 1027 | ) { |
| 843 | 1028 | $backup = Utils::maybe_unserialize($extras['backup']); |
| @@ -921,9 +1106,57 @@ | ||
| 921 | 1106 | // May be delete server files |
| 922 | 1107 | $this->may_be_delete_server_files_by_id($source_id, $source_type, true, true); |
| 923 | 1108 | } |
| 924 | 1109 | |
| 1110 | + /** | |
| 1111 | + * Track paths restored from cloud (by any integration) so they get removed | |
| 1112 | + * again later, honoring "Remove from server" the way the normal sync pipeline | |
| 1113 | + * would. Fed into the pre-update pipeline if a save happens this request (fast | |
| 1114 | + * path — matches how the item's own pending removals already work), with a | |
| 1115 | + * shutdown fallback (priority 1, ahead of most other plugins' shutdown hooks) | |
| 1116 | + * for requests where nothing ever triggers a save. | |
| 1117 | + * | |
| 1118 | + * @param string[] $paths Absolute paths of the restored files. | |
| 1119 | + * @return void | |
| 1120 | + * @since 1.4.0 | |
| 1121 | + */ | |
| 1122 | + public function track_restored_for_cleanup(array $paths) { | |
| 1123 | + foreach ($paths as $path) { | |
| 1124 | + if (!in_array($path, $this->pending_restored_files, true)) { | |
| 1125 | + $this->pending_restored_files[] = $path; | |
| 1126 | + } | |
| 1127 | + } | |
| 925 | 1128 | |
| 1129 | + if ($this->pending_cleanup_hooked) { | |
| 1130 | + return; | |
| 1131 | + } | |
| 1132 | + $this->pending_cleanup_hooked = true; | |
| 1133 | + | |
| 1134 | + add_filter('wpmcs_pre_update_item_additional_files_to_remove_from_server', function ($files_to_remove) { | |
| 1135 | + $files_to_remove = array_merge((array) $files_to_remove, $this->pending_restored_files); | |
| 1136 | + $this->pending_restored_files = []; | |
| 1137 | + return $files_to_remove; | |
| 1138 | + }); | |
| 1139 | + | |
| 1140 | + add_action('shutdown', array($this, 'flush_pending_restored_files'), 1); | |
| 1141 | + } | |
| 1142 | + | |
| 1143 | + /** | |
| 1144 | + * Shutdown fallback for track_restored_for_cleanup() — removes anything the | |
| 1145 | + * pre-update pipeline didn't already pick up this request. | |
| 1146 | + * | |
| 1147 | + * @return void | |
| 1148 | + * @since 1.4.0 | |
| 1149 | + */ | |
| 1150 | + public function flush_pending_restored_files() { | |
| 1151 | + if (empty($this->pending_restored_files)) { | |
| 1152 | + return; | |
| 1153 | + } | |
| 1154 | + $this->may_be_delete_server_files_by_source_paths($this->pending_restored_files); | |
| 1155 | + $this->pending_restored_files = []; | |
| 1156 | + } | |
| 1157 | + | |
| 1158 | + | |
| 926 | 1159 | public function may_be_delete_server_files_by_source_paths($source_paths) { |
| 927 | 1160 | if (Utils::is_empty($source_paths) || !is_array($source_paths)) { |
| 928 | 1161 | return false; |
| 929 | 1162 | } |
| @@ -989,9 +1222,9 @@ | ||
| 989 | 1222 | } |
| 990 | 1223 | } |
| 991 | 1224 | } |
| 992 | 1225 | |
| 993 | - $this->may_be_delete_server_files_by_item($item, $delete_main_file, $delete_backup); | |
| 1226 | + $this->may_be_delete_server_files_by_item($item, $delete_main_file); | |
| 994 | 1227 | |
| 995 | 1228 | return true; |
| 996 | 1229 | } |
| 997 | 1230 | |
| @@ -1029,8 +1262,32 @@ | ||
| 1029 | 1262 | ) { |
| 1030 | 1263 | foreach ($extras['sizes'] as $sub_image) { |
| 1031 | 1264 | if (isset($sub_image['source_path']) && !empty($sub_image['source_path'])) { |
| 1032 | 1265 | $file = trailingslashit($upload_dir['basedir']) . $sub_image['source_path']; |
| 1266 | + if(file_exists($file)) { | |
| 1267 | + $files_to_remove[] = $file; | |
| 1268 | + } | |
| 1269 | + } | |
| 1270 | + } | |
| 1271 | + } | |
| 1272 | + if ( | |
| 1273 | + isset($extras) && !empty($extras) && | |
| 1274 | + isset($extras['additional_files']) && !empty($extras['additional_files']) | |
| 1275 | + ) { | |
| 1276 | + // animated_video/animated_video_poster are resolved client-side by slicing | |
| 1277 | + // the main image's own (possibly presigned) source_url down to its directory | |
| 1278 | + // and appending the file's filename — verified in WP core's shipped JS | |
| 1279 | + // (block-library.js). That trick drops any presigned-URL query string, so for | |
| 1280 | + // a private item it produces an unsigned request to a private S3 key, which | |
| 1281 | + // fails once the local copy is gone. Keep these two local for private items; | |
| 1282 | + // source_image is unaffected (never fetched by any client, private or not). | |
| 1283 | + $unsafe_when_private = ['animated_video', 'animated_video_poster']; | |
| 1284 | + foreach ($extras['additional_files'] as $name => $additional_file) { | |
| 1285 | + if (!empty($item['is_private']) && in_array($name, $unsafe_when_private, true)) { | |
| 1286 | + continue; | |
| 1287 | + } | |
| 1288 | + if (isset($additional_file['source_path']) && !empty($additional_file['source_path'])) { | |
| 1289 | + $file = trailingslashit($upload_dir['basedir']) . $additional_file['source_path']; | |
| 1033 | 1290 | if(file_exists($file)) { |
| 1034 | 1291 | $files_to_remove[] = $file; |
| 1035 | 1292 | } |
| 1036 | 1293 | } |