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 +209 -19 1.4.0 → 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
@@ -377,10 +382,13 @@
377 382 if(
378 383 ($check_rewrite && (isset($this->settings['rewrite_url']) && $this->settings['rewrite_url'])) ||
379 384 !$check_rewrite
380 385 ) {
386 + if ($check_rewrite) {
387 + return (bool) apply_filters('wpmcs_is_available_from_provider', true, $attachment_id, $source_type);
388 + }
381 389 return true;
382 - }
390 + }
383 391 }
384 392 return false;
385 393 }
386 394
@@ -613,9 +621,12 @@
613 621 if ($privateUrl === false) {
614 622 $new_url = Service::instance()->get_private_url($key);
615 623
616 624 if (!Utils::is_empty($new_url)) {
617 - $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 );
618 629 $expireMinutes = (int)(isset($this->settings['private_url_expire']) && !empty($this->settings['private_url_expire']))
619 630 ? $this->settings['private_url_expire']
620 631 : 20;
621 632 $expireSeconds = $expireMinutes * 60;
@@ -645,11 +656,15 @@
645 656 * @param string $size size of the file, default is full
646 657 * @param string $source_type source type of item, default is media_library
647 658 * @param bool $all if true, it will move all files to server
648 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.
649 664 * @return array an array of server file paths
650 665 */
651 - 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'){
652 667 $server_files = [];
653 668 $server_file = false;
654 669 $source_id = (int)$source_id;
655 670 $item = $this->get($source_id, $source_type);
@@ -654,12 +669,12 @@
654 669 $source_id = (int)$source_id;
655 670 $item = $this->get($source_id, $source_type);
656 671
657 672 // Remove log if exists before move to server
658 - Logger::instance()->remove_log('restore_to_server', $source_id, $source_type);
673 + Logger::instance()->remove_log($log_type, $source_id, $source_type);
659 674
660 675 if ( isset($item) && !empty($item) ) {
661 - $files = $this->moveToServerByItem($item, $size, $all);
676 + $files = $this->moveToServerByItem($item, $size, $all, $log_type);
662 677 if (isset($files) && !empty($files)) {
663 678 $server_files = $all ? array_merge($server_files, $files) : $files;
664 679 }
665 680 }
@@ -665,9 +680,9 @@
665 680 }
666 681 if( $all && $backup ) {
667 682 $backupItem = $this->get_backup($source_id, $source_type);
668 683 if (isset($backupItem) && !empty($backupItem)) {
669 - $files = $this->moveToServerByItem($backupItem, $size, $all);
684 + $files = $this->moveToServerByItem($backupItem, $size, $all, $log_type);
670 685 if (isset($files) && !empty($files)) {
671 686 $server_files['backup'] = $files;
672 687 }
673 688 }
@@ -682,11 +697,12 @@
682 697 * @since 1.0.0
683 698 * @param array $item
684 699 * @param string $size
685 700 * @param bool $all
701 + * @param string $log_type error-log bucket to write to on failure
686 702 * @return array|string
687 703 */
688 - public function moveToServerByItem( $item = [], $size = 'full', $all = false ) {
704 + public function moveToServerByItem( $item = [], $size = 'full', $all = false, $log_type = 'restore_to_server' ) {
689 705 $source_id = (int) ( $item['source_id'] ?? 0 );
690 706 // Validate source ID
691 707 if( $source_id <= 0 ) {
692 708 return false;
@@ -715,8 +731,17 @@
715 731 ];
716 732 }
717 733 }
718 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 +
719 744 // ALL files
720 745 if ( $all ) {
721 746 $results = [];
722 747
@@ -724,9 +749,10 @@
724 749 if ( $file = $this->move_to_server_by_key_and_path(
725 750 $data['key'],
726 751 $data['path'],
727 752 $source_id,
728 - $source_type
753 + $source_type,
754 + $log_type
729 755 ) ) {
730 756 $results[ $label ] = $file;
731 757 }
732 758 }
@@ -739,9 +765,10 @@
739 765 return $this->move_to_server_by_key_and_path(
740 766 $files[ $size ]['key'],
741 767 $files[ $size ]['path'],
742 768 $source_id,
743 - $source_type
769 + $source_type,
770 + $log_type
744 771 );
745 772 }
746 773
747 774 return false;
@@ -746,11 +773,61 @@
746 773
747 774 return false;
748 775 }
749 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 + }
750 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 + }
751 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 +
752 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 + /**
753 830 * Get service path of item from database by source url
754 831 * @since 1.0.0
755 832 * @param int $source_id
756 833 * @param string $file
@@ -800,9 +877,9 @@
800 877 return true;
801 878 }
802 879
803 880 $extras = $this->get_extras( $source_id, false, $source_type ) ?: [];
804 -
881 +
805 882 // 3. Check sizes
806 883 if ( ! empty( $extras['sizes'] ) ) {
807 884 foreach ( $extras['sizes'] as $size ) {
808 885 if (
@@ -820,8 +897,27 @@
820 897 }
821 898 }
822 899 }
823 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 +
824 920 return false;
825 921 }
826 922
827 923 /**
@@ -830,12 +926,13 @@
830 926 * @param string $key
831 927 * @param string $relative_path
832 928 * @param int $source_id
833 929 * @param string $source_type
930 + * @param string $log_type error-log bucket to write to on failure
834 931 *
835 932 * @return string|false
836 933 */
837 - 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' ) {
838 935 if ( empty( $key ) || empty( $relative_path ) ) {
839 936 return false;
840 937 }
841 938
@@ -845,13 +942,18 @@
845 942 if ( file_exists( $file ) ) {
846 943 return $file;
847 944 }
848 945
849 - 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 ) ) {
850 952 return $file;
851 953 }
852 954
853 - Logger::instance()->add_log( 'restore_to_server', $source_id, $source_type, [
955 + Logger::instance()->add_log( $log_type, $source_id, $source_type, [
854 956 'message' => __( 'The file could not be copied to the server. Please try again.', 'media-cloud-sync' ),
855 957 'file' => $key,
856 958 'code' => 404,
857 959 ] );
@@ -886,8 +988,13 @@
886 988 /**
887 989 * Delete media item
888 990 */
889 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 +
890 997 $upload_dir = wp_get_upload_dir();
891 998
892 999 if (isset($item['extra']) && !empty($item['extra'])) {
893 1000 $extras = Utils::maybe_unserialize($item['extra']);
@@ -903,8 +1010,19 @@
903 1010 }
904 1011
905 1012 if (
906 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) &&
907 1025 isset($extras['backup']) && !empty($extras['backup']) &&
908 1026 $delete_backup
909 1027 ) {
910 1028 $backup = Utils::maybe_unserialize($extras['backup']);
@@ -988,9 +1106,57 @@
988 1106 // May be delete server files
989 1107 $this->may_be_delete_server_files_by_id($source_id, $source_type, true, true);
990 1108 }
991 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 + }
992 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 +
993 1159 public function may_be_delete_server_files_by_source_paths($source_paths) {
994 1160 if (Utils::is_empty($source_paths) || !is_array($source_paths)) {
995 1161 return false;
996 1162 }
@@ -1096,8 +1262,32 @@
1096 1262 ) {
1097 1263 foreach ($extras['sizes'] as $sub_image) {
1098 1264 if (isset($sub_image['source_path']) && !empty($sub_image['source_path'])) {
1099 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'];
1100 1290 if(file_exists($file)) {
1101 1291 $files_to_remove[] = $file;
1102 1292 }
1103 1293 }