PluginProbe
Media Cloud Sync / 1.4.2
Media Cloud Sync v1.4.2
1.4.2 1.4.1 1.4.0 1.3.12 1.3.11 1.3.10 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.2.0 1.2.10 1.2.11 1.2.12 1.2.13 1.2.2 1.2.3 1.2.4 1.2.5 1.2.6 1.2.7 1.2.8 All 36 releases
← All changes | includes/base/item.php +293 -36 1.3.10 → 1.4.2 View file →
@@ -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 }