PluginProbe
Media Cloud Sync / 1.4.1
Media Cloud Sync v1.4.1
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 1.2.9 All 35 releases
← All changes | includes/base/item.php +226 -35 1.3.111.4.1 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;
@@ -657,9 +740,10 @@
657 740 if ( $file = $this->move_to_server_by_key_and_path(
658 741 $data['key'],
659 742 $data['path'],
660 743 $source_id,
661 - $source_type
744 + $source_type,
745 + $log_type
662 746 ) ) {
663 747 $results[ $label ] = $file;
664 748 }
665 749 }
@@ -672,9 +756,10 @@
672 756 return $this->move_to_server_by_key_and_path(
673 757 $files[ $size ]['key'],
674 758 $files[ $size ]['path'],
675 759 $source_id,
676 - $source_type
760 + $source_type,
761 + $log_type
677 762 );
678 763 }
679 764
680 765 return false;
@@ -679,11 +764,58 @@
679 764
680 765 return false;
681 766 }
682 767
768 + /**
769 + * Whether a moveToServer(..., $all=true, $backup=true) result actually restored
770 + * everything this item is expected to have (every size, the original if present, and
771 + * the backup entry if one exists) — moveToServer()'s return silently drops any single
772 + * file that failed, so a plain non-empty check on it isn't enough to safely delete the
773 + * cloud copies afterward.
774 + * @since 1.4.1
775 + */
776 + public function verify_full_restore( $source_id, $source_type, $moved ) {
777 + $row = $this->get( $source_id, $source_type );
778 + if ( empty( $row ) ) {
779 + return false;
780 + }
683 781
782 + $expected = $this->expected_restore_labels( $row );
783 + $restored = array_diff( array_keys( (array) $moved ), [ 'backup' ] );
784 + if ( ! empty( array_diff( $expected, $restored ) ) ) {
785 + return false;
786 + }
684 787
788 + $backup_item = $this->get_backup( $source_id, $source_type );
789 + if ( empty( $backup_item ) ) {
790 + return true;
791 + }
792 +
793 + $expected_backup = $this->expected_restore_labels( $backup_item );
794 + $restored_backup = ! empty( $moved['backup'] ) ? array_keys( $moved['backup'] ) : [];
795 + return empty( array_diff( $expected_backup, $restored_backup ) );
796 + }
797 +
685 798 /**
799 + * File labels (full, original, each named size) a given item row is expected to have.
800 + */
801 + private function expected_restore_labels( $item_row ) {
802 + $expected = [ 'full' ];
803 + if ( ! empty( $item_row['original_key'] ) || ! empty( $item_row['original_source_path'] ) ) {
804 + $expected[] = 'original';
805 + }
806 +
807 + $extras = ! empty( $item_row['extra'] ) ? Utils::maybe_unserialize( $item_row['extra'] ) : [];
808 + if ( ! empty( $extras['sizes'] ) ) {
809 + $expected = array_merge( $expected, array_keys( $extras['sizes'] ) );
810 + }
811 +
812 + return $expected;
813 + }
814 +
815 +
816 +
817 + /**
686 818 * Get service path of item from database by source url
687 819 * @since 1.0.0
688 820 * @param int $source_id
689 821 * @param string $file
@@ -763,12 +895,13 @@
763 895 * @param string $key
764 896 * @param string $relative_path
765 897 * @param int $source_id
766 898 * @param string $source_type
899 + * @param string $log_type error-log bucket to write to on failure
767 900 *
768 901 * @return string|false
769 902 */
770 - protected function move_to_server_by_key_and_path( $key, $relative_path, $source_id = 0, $source_type = 'media_library' ) {
903 + 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 904 if ( empty( $key ) || empty( $relative_path ) ) {
772 905 return false;
773 906 }
774 907
@@ -778,13 +911,18 @@
778 911 if ( file_exists( $file ) ) {
779 912 return $file;
780 913 }
781 914
782 - if ( Service::instance()->object_to_server( $key, $file ) ) {
915 + // Checked on disk rather than trusting the return value alone — at least one
916 + // provider (Cloudflare R2) has been observed writing the file successfully while
917 + // still reporting failure (an SDK-level error thrown after the save completes).
918 + Service::instance()->object_to_server( $key, $file );
919 +
920 + if ( file_exists( $file ) ) {
783 921 return $file;
784 922 }
785 923
786 - Logger::instance()->add_log( 'restore_to_server', $source_id, $source_type, [
924 + Logger::instance()->add_log( $log_type, $source_id, $source_type, [
787 925 'message' => __( 'The file could not be copied to the server. Please try again.', 'media-cloud-sync' ),
788 926 'file' => $key,
789 927 'code' => 404,
790 928 ] );
@@ -819,8 +957,13 @@
819 957 /**
820 958 * Delete media item
821 959 */
822 960 public function delete_attachments_by_item($item, $delete_backup = true) {
961 + // Lets an integration veto the delete when another row still relies on the same key.
962 + if (!apply_filters('wpmcs_should_delete_cloud_files', true, $item)) {
963 + return;
964 + }
965 +
823 966 $upload_dir = wp_get_upload_dir();
824 967
825 968 if (isset($item['extra']) && !empty($item['extra'])) {
826 969 $extras = Utils::maybe_unserialize($item['extra']);
@@ -921,9 +1064,57 @@
921 1064 // May be delete server files
922 1065 $this->may_be_delete_server_files_by_id($source_id, $source_type, true, true);
923 1066 }
924 1067
1068 + /**
1069 + * Track paths restored from cloud (by any integration) so they get removed
1070 + * again later, honoring "Remove from server" the way the normal sync pipeline
1071 + * would. Fed into the pre-update pipeline if a save happens this request (fast
1072 + * path — matches how the item's own pending removals already work), with a
1073 + * shutdown fallback (priority 1, ahead of most other plugins' shutdown hooks)
1074 + * for requests where nothing ever triggers a save.
1075 + *
1076 + * @param string[] $paths Absolute paths of the restored files.
1077 + * @return void
1078 + * @since 1.4.0
1079 + */
1080 + public function track_restored_for_cleanup(array $paths) {
1081 + foreach ($paths as $path) {
1082 + if (!in_array($path, $this->pending_restored_files, true)) {
1083 + $this->pending_restored_files[] = $path;
1084 + }
1085 + }
925 1086
1087 + if ($this->pending_cleanup_hooked) {
1088 + return;
1089 + }
1090 + $this->pending_cleanup_hooked = true;
1091 +
1092 + add_filter('wpmcs_pre_update_item_additional_files_to_remove_from_server', function ($files_to_remove) {
1093 + $files_to_remove = array_merge((array) $files_to_remove, $this->pending_restored_files);
1094 + $this->pending_restored_files = [];
1095 + return $files_to_remove;
1096 + });
1097 +
1098 + add_action('shutdown', array($this, 'flush_pending_restored_files'), 1);
1099 + }
1100 +
1101 + /**
1102 + * Shutdown fallback for track_restored_for_cleanup() — removes anything the
1103 + * pre-update pipeline didn't already pick up this request.
1104 + *
1105 + * @return void
1106 + * @since 1.4.0
1107 + */
1108 + public function flush_pending_restored_files() {
1109 + if (empty($this->pending_restored_files)) {
1110 + return;
1111 + }
1112 + $this->may_be_delete_server_files_by_source_paths($this->pending_restored_files);
1113 + $this->pending_restored_files = [];
1114 + }
1115 +
1116 +
926 1117 public function may_be_delete_server_files_by_source_paths($source_paths) {
927 1118 if (Utils::is_empty($source_paths) || !is_array($source_paths)) {
928 1119 return false;
929 1120 }
@@ -989,9 +1180,9 @@
989 1180 }
990 1181 }
991 1182 }
992 1183
993 - $this->may_be_delete_server_files_by_item($item, $delete_main_file, $delete_backup);
1184 + $this->may_be_delete_server_files_by_item($item, $delete_main_file);
994 1185
995 1186 return true;
996 1187 }
997 1188