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/integrations/media-library.php +299 -103 1.3.10 → 1.4.2 View file →
@@ -5,8 +5,15 @@
5 5
6 6 use Exception;
7 7 use WP_Error;
8 8
9 +/**
10 + * Media Library integration.
11 + *
12 + * Loaded by the autoloader; activation is gated via `is_installed()` in Integration::init().
13 + *
14 + * @since 1.0.0
15 + */
9 16 class MediaLibrary {
10 17 private static $instance = null;
11 18 private $assets_url;
12 19 private $version;
@@ -24,10 +31,13 @@
24 31 private $deleting_attachment = false;
25 32
26 33
27 34 public static $source_type_prefix = "media";
28 - public static $label = 'Media Library';
29 35
36 + public static function get_label() {
37 + return __('Media Library', 'media-cloud-sync');
38 + }
39 +
30 40 // Map which meta to be updated
31 41 public static $source_types = [
32 42 "media_library" => [
33 43 'table' => 'posts',
@@ -106,8 +116,10 @@
106 116 add_filter( 'wp_update_attachment_metadata', [ $this, 'update_attachment_metadata' ], 110, 2 );
107 117 add_filter( 'pre_delete_attachment', [ $this, 'pre_delete_attachment' ], 20 );
108 118 add_filter( 'delete_attachment', [ $this, 'delete_attachment' ], 20 );
109 119 add_action( 'delete_post', [$this, 'delete_post'] );
120 + // Safety net for code that deletes an attachment bypassing wp_delete_attachment().
121 + add_action( 'deleted_post', [ $this, 'deleted_post' ], 10, 2 );
110 122 add_filter( 'update_attached_file', [ $this, 'update_attached_file' ], 100, 2 );
111 123
112 124 add_action( 'wpmcs_do_update_attachment_metadata', [ $this, 'update_attachment_metadata' ], 10, 2 );
113 125 }
@@ -159,12 +171,8 @@
159 171 * @since 1.0.0
160 172 *
161 173 */
162 174 public function delete_attachment($attachment_id){
163 - if (!Utils::is_service_enabled()) {
164 - return $attachment_id;
165 - }
166 -
167 175 $wpmcsItem = Item::instance();
168 176 $item = $wpmcsItem->get($attachment_id, 'media_library');
169 177
170 178 if (Utils::is_empty($item)) {
@@ -187,8 +195,13 @@
187 195 */
188 196 public function update_attachment_metadata($attachment_meta, $attachment_id) {
189 197 // Remove Logs
190 198 Logger::instance()->remove_log('sync_to_cloud', $attachment_id, 'media_library');
199 +
200 + // Reachable from three independent, uncoordinated triggers (WP's own
201 + // wp_update_attachment_metadata hook, this plugin's bulk sync, Imagify's re-sync).
202 + // No lock here — Item::add()'s upsert is what keeps a race safe; skipping instead
203 + // would risk dropping a real re-upload request.
191 204 try {
192 205 // Ensure attachment is eligible for upload
193 206 $attachment_meta = is_array($attachment_meta) && !empty($attachment_meta)
194 207 ? $attachment_meta
@@ -375,9 +388,9 @@
375 388 'key' => $backup['key'],
376 389 'original_source_path' => $backup['original_source_path'],
377 390 'original_key' => $backup['original_key'],
378 391 ],
379 - 'extra' => Utilsmaybe_unserialize($backup['extra']),
392 + 'extra' => Utils::maybe_unserialize($backup['extra']),
380 393 ];
381 394 }
382 395
383 396 if ($delete_existing) {
@@ -432,12 +445,8 @@
432 445 * @since 1.0.0
433 446 * @return string
434 447 */
435 448 private function filter_unique_filename($filename, $ext, $dir, $post_id = null) {
436 - if (!Utils::is_service_enabled()) {
437 - return $filename;
438 - }
439 -
440 449 // sanitize the file name before we begin processing
441 450 $filename = sanitize_file_name($filename);
442 451 $ext = strtolower($ext);
443 452 $name = wp_basename($filename, $ext);
@@ -552,9 +561,9 @@
552 561 if ( $this->deleting_attachment ) {
553 562 return $file;
554 563 }
555 564
556 - if (!Utils::is_ok_to_serve($attachment_id)) {
565 + if (!Utils::should_serve_from_cloud($attachment_id)) {
557 566 return $file;
558 567 }
559 568
560 569 $wpmcsItem = Item::instance();
@@ -607,9 +616,9 @@
607 616 return $sources;
608 617 }
609 618
610 619 // Must need $attachment_id other wise not possible to get data from the table
611 - if (!Utils::is_ok_to_serve($attachment_id)) {
620 + if (!Utils::should_serve_from_cloud($attachment_id)) {
612 621 return $sources;
613 622 }
614 623
615 624 $wpmcsItem = Item::instance();
@@ -642,22 +651,26 @@
642 651 *
643 652 * @return null|string
644 653 */
645 654 protected function find_image_size_from_width( $meta, $width, $filename ) {
646 - $sizes = $meta['sizes'];
647 - foreach ( $sizes as $name => $size ) {
648 - if ( $width === absint( $size['width'] ) && $size['file'] === $filename ) {
649 - return $name;
655 + if ( ! is_array( $meta ) ) {
656 + return null;
657 + }
658 +
659 + if ( ! empty( $meta['sizes'] ) && is_array( $meta['sizes'] ) ) {
660 + foreach ( $meta['sizes'] as $name => $size ) {
661 + if ( $width === absint( $size['width'] ?? 0 ) && isset( $size['file'] ) && $size['file'] === $filename ) {
662 + return $name;
663 + }
650 664 }
651 665 }
652 666
653 - // Check for full size
654 - if($width === $meta['width']) {
655 - $full_file = wp_basename( $meta['file'] );
656 - if($full_file === $filename) {
657 - return 'full';
658 - }
659 - }
667 + if ( isset( $meta['width'], $meta['file'] ) && $width === absint( $meta['width'] ) ) {
668 + $full_file = wp_basename( $meta['file'] );
669 + if ( $full_file === $filename ) {
670 + return 'full';
671 + }
672 + }
660 673
661 674 return null;
662 675 }
663 676
@@ -672,10 +685,10 @@
672 685 * @return array
673 686 */
674 687 public function wp_get_attachment_image_attributes($attr, $attachment, $size='thumbnail') {
675 688 if (
676 - !$attachment ||
677 - !Utils::is_ok_to_serve($attachment->ID)
689 + !$attachment ||
690 + !Utils::should_serve_from_cloud($attachment->ID)
678 691 ) {
679 692 return $attr;
680 693 }
681 694
@@ -722,9 +735,9 @@
722 735 *
723 736 * @return bool|mixed|WP_Error
724 737 */
725 738 public function wp_get_attachment_url($url, $attachment_id) {
726 - if (!Utils::is_ok_to_serve($attachment_id)) {
739 + if (!Utils::should_serve_from_cloud($attachment_id)) {
727 740 return $url;
728 741 }
729 742
730 743 $new_url = Item::instance()->get_url($attachment_id, 'full', 'media_library');
@@ -749,14 +762,14 @@
749 762 * @param string $size
750 763 *
751 764 * @return string
752 765 */
753 - public function get_image_tag( $html, $id, $alt, $title, $align, $size ) {
766 + public function get_image_tag( $html, $id, $alt = '', $title = '', $align = '', $size = '' ) {
754 767 if ( ! is_string( $html ) ) {
755 768 return $html;
756 769 }
757 770
758 - if (!Utils::is_ok_to_serve($id)) {
771 + if (!Utils::should_serve_from_cloud($id)) {
759 772 return $html;
760 773 }
761 774
762 775 preg_match( '@\ssrc=[\'\"]([^\'\"]*)[\'\"]@', $html, $matches );
@@ -765,8 +778,16 @@
765 778 // Can't establish img src
766 779 return $html;
767 780 }
768 781
782 + if ( empty( $size ) && preg_match( '/\bsize-([^\s"\']+)/', $html, $size_match ) ) {
783 + $size = $size_match[1];
784 + }
785 +
786 + if ( empty( $size ) ) {
787 + $size = 'full';
788 + }
789 +
769 790 $img_src = $matches[1];
770 791 $size = Utils::maybe_convert_size_to_string( $id, $size );
771 792 $new_url = Item::instance()->get_url($id, $size, 'media_library');
772 793
@@ -788,9 +809,9 @@
788 809 *
789 810 * @return array
790 811 */
791 812 public function wp_get_attachment_image_src( $image, $attachment_id, $size, $icon ) {
792 - if (!Utils::is_ok_to_serve($attachment_id)) {
813 + if (!Utils::should_serve_from_cloud($attachment_id)) {
793 814 return $image;
794 815 }
795 816
796 817 if ( isset( $image[0] ) ) {
@@ -816,9 +837,9 @@
816 837 *
817 838 * @return array
818 839 */
819 840 public function wp_prepare_attachment_for_js( $response, $attachment, $meta ) {
820 - if (!Utils::is_ok_to_serve($attachment->ID)) {
841 + if (!Utils::should_serve_from_cloud($attachment->ID)) {
821 842 return $response;
822 843 }
823 844
824 845 if ( isset( $response['sizes'] ) && is_array( $response['sizes'] ) ) {
@@ -844,9 +865,9 @@
844 865 *
845 866 * @return array
846 867 */
847 868 public function image_get_intermediate_size( $data, $post_id, $size ) {
848 - if (!Utils::is_ok_to_serve($post_id)) {
869 + if (!Utils::should_serve_from_cloud($post_id)) {
849 870 return $data;
850 871 }
851 872 if ( isset( $data['url'] ) ) {
852 873 $size = Utils::maybe_convert_size_to_string( $post_id, $size );
@@ -996,8 +1017,11 @@
996 1017 $is_image = wp_attachment_is_image($attachment_id);
997 1018 $existing = $wpmcsItem->get($attachment_id, 'media_library');
998 1019 $existing_extras = $wpmcsItem->get_extras($attachment_id, false, 'media_library');
999 1020 $has_existing = !Utils::is_empty($existing);
1021 + // A reupload of an already-private item must keep writing under the private path —
1022 + // never true for a genuinely new item, which is exactly right (new uploads stay public).
1023 + $is_private = $has_existing && !empty($existing['is_private']);
1000 1024 $sizes = [];
1001 1025 $uploaded = [];
1002 1026 $extras = [];
1003 1027 $prefix = '';
@@ -1008,10 +1032,11 @@
1008 1032 $do_reupload = apply_filters('wpmcs_do_reupload_media', false, $attachment_id, 'media_library');
1009 1033
1010 1034 // Force reupload when sizes/dimensions changed
1011 1035 if (!$do_reupload && $has_existing) {
1012 - if($this->shouldForceReupload($attachment_meta, $attachment_id, $existing_extras)) {
1013 - $do_reupload = true;
1036 + $force_reupload = $this->shouldForceReupload($attachment_meta, $attachment_id, $existing_extras);
1037 + if ($force_reupload !== false) {
1038 + $do_reupload = $force_reupload;
1014 1039 }
1015 1040 }
1016 1041
1017 1042 // Add prefix if object versioning is ON
@@ -1027,37 +1052,54 @@
1027 1052 $extras['width'] = (isset($attachment_meta['width']) && !empty($attachment_meta['width'])) ? $attachment_meta['width'] : 0;
1028 1053 $extras['height'] = (isset($attachment_meta['height']) && !empty($attachment_meta['height'])) ? $attachment_meta['height'] : 0;
1029 1054 }
1030 1055
1031 - // Check whether the extension is enabled for uploading
1056 + // Excluded by extension settings — an intentional skip, not a sync failure, so no log entry.
1032 1057 if (!Utils::is_extension_available($file_path)) {
1033 - Logger::instance()->add_log('sync_to_cloud', $attachment_id, 'media_library', [
1034 - 'message' => __('File extension is not supported', 'media-cloud-sync'),
1035 - 'file' => $file_path,
1036 - 'code' => 415
1037 - ]);
1038 1058 return false;
1039 1059 }
1040 1060
1041 1061 // Upload main file first, if that fails no need to attempt the rest
1042 - $uploaded = $this->uploadFullFile($file_path, $source_path, $do_reupload, $has_existing, $existing, $prefix, $attachment_id);
1062 + $uploaded = $this->uploadFullFile($file_path, $source_path, $do_reupload, $has_existing, $existing, $prefix, $attachment_id, $is_private);
1043 1063
1044 1064 if ($uploaded && isset($uploaded['success']) && $uploaded['success']) {
1045 1065 // Attempt to upload original file if present. Not critical if this fails so we don't check the result before proceeding.
1046 - $original = $this->uploadOriginalFile($original_file, $file_dir, $do_reupload, $has_existing, $existing, $prefix, $attachment_id);
1066 + $original = $this->uploadOriginalFile($original_file, $file_dir, $do_reupload, $has_existing, $existing, $prefix, $attachment_id, $is_private);
1067 + $additional_files = [];
1047 1068
1048 1069 if ($is_image && isset($file_dir) && !empty($file_dir)) {
1049 1070 $sizes_to_upload = $this->get_attachment_image_sizes_for_upload($attachment_meta, $attachment_id);
1050 1071 if (!empty($sizes_to_upload)) {
1051 1072 // Upload image sizes. Again, not critical if this fails so we don't check the result before proceeding.
1052 - $sizes = $this->uploadImageSizes($sizes_to_upload, $file_dir, $do_reupload, $existing_extras, $prefix, $attachment_id);
1073 + $sizes = $this->uploadImageSizes($sizes_to_upload, $file_dir, $do_reupload, $existing_extras, $prefix, $attachment_id, $is_private);
1053 1074 }
1075 + $additional_files = $this->uploadAdditionalFiles($attachment_meta, $file_dir, $do_reupload, $has_existing, $existing_extras, $prefix, $attachment_id, $is_private);
1054 1076 }
1055 1077
1056 1078 if (!empty($sizes)) {
1057 - $extras['sizes'] = $sizes;
1079 + // Merge over existing sizes rather than replacing wholesale — a size that
1080 + // came back empty from uploadImageSizes() (its own fallback included) must
1081 + // never erase a previously-good, unrelated size's record.
1082 + $existing_sizes = isset($existing_extras['sizes']) && is_array($existing_extras['sizes'])
1083 + ? $existing_extras['sizes']
1084 + : [];
1085 +
1086 + foreach ($sizes as $size_name => $size_data) {
1087 + if (Utils::is_empty($size_data) && isset($existing_sizes[$size_name])) {
1088 + unset($sizes[$size_name]);
1089 + }
1090 + }
1091 +
1092 + $extras['sizes'] = array_merge($existing_sizes, $sizes);
1058 1093 }
1059 1094
1095 + if (!empty($additional_files)) {
1096 + $existing_additional_files = isset($existing_extras['additional_files']) && is_array($existing_extras['additional_files'])
1097 + ? $existing_extras['additional_files']
1098 + : [];
1099 + $extras['additional_files'] = array_merge($existing_additional_files, $additional_files);
1100 + }
1101 +
1060 1102 return [
1061 1103 'file' => [
1062 1104 'source_path' => $source_path,
1063 1105 'url' => $uploaded['file_url'],
@@ -1069,12 +1111,13 @@
1069 1111 ];
1070 1112 }
1071 1113
1072 1114 if (isset($uploaded['success']) && !$uploaded['success']) {
1115 + // Prefer the service's own error message/code when it provided one.
1073 1116 Logger::instance()->add_log('sync_to_cloud', $attachment_id, 'media_library', [
1074 - 'message' => __('File upload failed', 'media-cloud-sync'),
1117 + 'message' => $uploaded['message'] ?? __('File upload failed', 'media-cloud-sync'),
1075 1118 'file' => $file_path,
1076 - 'code' => 500
1119 + 'code' => $uploaded['code'] ?? 500
1077 1120 ]);
1078 1121 }
1079 1122
1080 1123 return false;
@@ -1110,8 +1153,10 @@
1110 1153 * @since 1.3.6
1111 1154 * @param array $attachment_meta
1112 1155 * @param int $attachment_id
1113 1156 * @param array $existing_extras
1157 + * @return bool|array True forces a full reupload (main file changed); an array of
1158 + * size names forces only those sizes; false means nothing changed.
1114 1159 */
1115 1160 private function shouldForceReupload($attachment_meta, $attachment_id, $existing_extras) {
1116 1161 $existing_extras = $existing_extras ?? Item::instance()->get_extras($attachment_id, false, 'media_library');
1117 1162
@@ -1138,31 +1183,26 @@
1138 1183 'height' => isset($smeta['height']) ? (int)$smeta['height'] : 0,
1139 1184 ];
1140 1185 }
1141 1186
1142 - $existing_relevant = [];
1143 - foreach (array_keys($current_sizes) as $sname) {
1144 - if (isset($existing_sizes[$sname])) {
1145 - $existing_relevant[$sname] = $existing_sizes[$sname];
1146 - }
1147 - }
1148 -
1149 - if (array_keys($existing_relevant) !== array_keys($current_sizes)) {
1150 - return true;
1151 - }
1152 -
1187 + // The main file is unchanged (already ruled out above) — a mismatch here is
1188 + // limited to specific sizes, so re-send only those instead of forcing a
1189 + // blanket reupload that would also re-fetch the main file from local disk
1190 + // (which "Remove from server" may have already, correctly, deleted).
1191 + $changed_sizes = [];
1153 1192 foreach ($current_sizes as $sname => $dims) {
1154 1193 if (!isset($existing_sizes[$sname])) {
1155 - return true;
1194 + $changed_sizes[] = $sname;
1195 + continue;
1156 1196 }
1157 1197 $ex_w = isset($existing_sizes[$sname]['width']) ? (int)$existing_sizes[$sname]['width'] : 0;
1158 1198 $ex_h = isset($existing_sizes[$sname]['height']) ? (int)$existing_sizes[$sname]['height'] : 0;
1159 1199 if ($ex_w !== $dims['width'] || $ex_h !== $dims['height']) {
1160 - return true;
1200 + $changed_sizes[] = $sname;
1161 1201 }
1162 1202 }
1163 1203
1164 - return false;
1204 + return empty($changed_sizes) ? false : $changed_sizes;
1165 1205 }
1166 1206
1167 1207 /**
1168 1208 * Upload the main/full file.
@@ -1168,16 +1208,19 @@
1168 1208 * Upload the main/full file.
1169 1209 * @since 1.3.6
1170 1210 * @param string $file_path
1171 1211 * @param string $source_path
1172 - * @param bool $do_reupload
1212 + * @param bool|array $do_reupload Reupload everything (true), nothing (false), or only these sizes (array of size names).
1173 1213 * @param bool $has_existing
1174 1214 * @param array $existing
1175 1215 * @param string $prefix
1176 1216 * @param int $attachment_id
1217 + * @param bool $is_private Keeps a reupload of an already-private item under the private path.
1177 1218 */
1178 - private function uploadFullFile($file_path, $source_path, $do_reupload, $has_existing, $existing, $prefix, $attachment_id) {
1179 - if (!$do_reupload && $has_existing && ($existing['source_path'] == $source_path)) {
1219 + private function uploadFullFile($file_path, $source_path, $do_reupload, $has_existing, $existing, $prefix, $attachment_id, $is_private = false) {
1220 + // $do_reupload may be an array of size names (force just those sizes) — that
1221 + // shouldn't force the full file too, only a literal true does.
1222 + if ($do_reupload !== true && $has_existing && ($existing['source_path'] == $source_path)) {
1180 1223 return [
1181 1224 'success' => true,
1182 1225 'file_url' => $existing['url'],
1183 1226 'key' => $existing['key']
@@ -1184,9 +1227,9 @@
1184 1227 ];
1185 1228 }
1186 1229
1187 1230 if (file_exists($file_path)) {
1188 - return Service::instance()->uploadSingle($file_path, $source_path, $prefix);
1231 + return Service::instance()->uploadSingle($file_path, $source_path, $prefix, $is_private);
1189 1232 }
1190 1233
1191 1234 Logger::instance()->add_log('sync_to_cloud', $attachment_id, 'media_library', [
1192 1235 'message' => __('File not found', 'media-cloud-sync'),
@@ -1201,63 +1244,133 @@
1201 1244 * Upload original file when present.
1202 1245 * @since 1.3.6
1203 1246 * @param string $original_file
1204 1247 * @param string $file_dir
1205 - * @param bool $do_reupload
1248 + * @param bool|array $do_reupload Reupload everything (true), nothing (false), or only these sizes (array of size names).
1206 1249 * @param bool $has_existing
1207 1250 * @param array $existing
1208 1251 * @param string $prefix
1209 1252 * @param int $attachment_id
1253 + * @param bool $is_private Keeps a reupload of an already-private item under the private path.
1210 1254 */
1211 - private function uploadOriginalFile($original_file, $file_dir, $do_reupload, $has_existing, $existing, $prefix, $attachment_id) {
1212 - $original = [];
1213 - if (empty($original_file)) {
1214 - return $original;
1255 + private function uploadOriginalFile($original_file, $file_dir, $do_reupload, $has_existing, $existing, $prefix, $attachment_id, $is_private = false) {
1256 + return $this->uploadNamedFile(
1257 + $original_file, $file_dir,
1258 + $existing['original_source_path'] ?? '', $existing['original_key'] ?? '',
1259 + $do_reupload, $has_existing, $prefix, $attachment_id, $is_private,
1260 + __('Original File not found', 'media-cloud-sync')
1261 + );
1262 + }
1263 +
1264 + /**
1265 + * Upload one optional named additional file (original image, HEIC source,
1266 + * animated-GIF video/poster) that lives alongside the main file.
1267 + * @since 1.4.3
1268 + * @param string $filename Relative filename within $file_dir, or empty if absent.
1269 + * @param string $file_dir
1270 + * @param string $existing_source_path Previously recorded source_path, if any.
1271 + * @param string $existing_key Previously recorded cloud key, if any.
1272 + * @param bool|array $do_reupload Reupload everything (true), nothing (false), or only these sizes (array of size names).
1273 + * @param bool $has_existing
1274 + * @param string $prefix
1275 + * @param int $attachment_id
1276 + * @param bool $is_private Keeps a reupload of an already-private item under the private path.
1277 + * @param string $not_found_message
1278 + * @return array{source_path?: string, key?: string} Empty when absent or upload failed.
1279 + */
1280 + private function uploadNamedFile($filename, $file_dir, $existing_source_path, $existing_key, $do_reupload, $has_existing, $prefix, $attachment_id, $is_private, $not_found_message) {
1281 + if (empty($filename)) {
1282 + return [];
1215 1283 }
1216 1284
1217 - $original_file_path = trailingslashit($file_dir) . $original_file;
1218 - $original_file_source_path = Utils::get_attachment_source_path($original_file_path);
1285 + $file_path = trailingslashit($file_dir) . $filename;
1286 + $source_path = Utils::get_attachment_source_path($file_path);
1219 1287
1220 - if (!$do_reupload && $has_existing && isset($existing['original_source_path']) && !Utils::is_empty($existing['original_source_path']) && $existing['original_source_path'] === $original_file_source_path) {
1221 - return [ 'source_path' => $original_file_source_path, 'key' => $existing['original_key'] ];
1288 + // $do_reupload may be an array of size names (force just those sizes) — that
1289 + // shouldn't force this file too, only a literal true does.
1290 + if ($do_reupload !== true && $has_existing && !Utils::is_empty($existing_source_path) && $existing_source_path === $source_path) {
1291 + return [ 'source_path' => $source_path, 'key' => $existing_key ];
1222 1292 }
1223 1293
1224 - if (file_exists($original_file_path)) {
1225 - $uploaded_original = Service::instance()->uploadSingle($original_file_path, $original_file_source_path, $prefix);
1226 - if (isset($uploaded_original['success']) && $uploaded_original['success']) {
1227 - return [ 'source_path' => $original_file_source_path, 'key' => $uploaded_original['key'] ];
1294 + if (file_exists($file_path)) {
1295 + $uploaded = Service::instance()->uploadSingle($file_path, $source_path, $prefix, $is_private);
1296 + if (isset($uploaded['success']) && $uploaded['success']) {
1297 + return [ 'source_path' => $source_path, 'key' => $uploaded['key'] ];
1228 1298 }
1229 1299
1230 - if (isset($uploaded_original['success']) && !$uploaded_original['success']) {
1300 + if (isset($uploaded['success']) && !$uploaded['success']) {
1231 1301 Logger::instance()->add_log('sync_to_cloud', $attachment_id, 'media_library', [
1232 - 'message' => __('Original File upload failed', 'media-cloud-sync'),
1233 - 'file' => $original_file_path,
1234 - 'code' => 500
1302 + 'message' => $uploaded['message'] ?? $not_found_message,
1303 + 'file' => $file_path,
1304 + 'code' => $uploaded['code'] ?? 500
1235 1305 ]);
1236 1306 }
1237 1307 } else {
1238 1308 Logger::instance()->add_log('sync_to_cloud', $attachment_id, 'media_library', [
1239 - 'message' => __('Original File not found', 'media-cloud-sync'),
1240 - 'file' => $original_file_path,
1309 + 'message' => $not_found_message,
1310 + 'file' => $file_path,
1241 1311 'code' => 404
1242 1312 ]);
1243 1313 }
1244 1314
1245 - return $original;
1315 + return [];
1246 1316 }
1247 1317
1248 1318 /**
1319 + * Upload the WP 7.1 client-side-processing additional files (HEIC source,
1320 + * animated-GIF video/poster) that uploadMedia() otherwise has no idea exist.
1321 + * @since 1.4.3
1322 + * @param array $attachment_meta
1323 + * @param string $file_dir
1324 + * @param bool|array $do_reupload
1325 + * @param bool $has_existing
1326 + * @param array $existing_extras
1327 + * @param string $prefix
1328 + * @param int $attachment_id
1329 + * @param bool $is_private
1330 + * @return array Keyed by meta key ('source_image', 'animated_video', 'animated_video_poster').
1331 + */
1332 + private function uploadAdditionalFiles($attachment_meta, $file_dir, $do_reupload, $has_existing, $existing_extras, $prefix, $attachment_id, $is_private) {
1333 + $additional_file_keys = [
1334 + 'source_image' => __('HEIC source file not found', 'media-cloud-sync'),
1335 + 'animated_video' => __('Animated video file not found', 'media-cloud-sync'),
1336 + 'animated_video_poster' => __('Animated video poster file not found', 'media-cloud-sync'),
1337 + ];
1338 + $existing_additional_files = isset($existing_extras['additional_files']) && is_array($existing_extras['additional_files'])
1339 + ? $existing_extras['additional_files']
1340 + : [];
1341 +
1342 + $additional_files = [];
1343 + foreach ($additional_file_keys as $meta_key => $not_found_message) {
1344 + if (empty($attachment_meta[$meta_key])) {
1345 + continue;
1346 + }
1347 + $existing_file = $existing_additional_files[$meta_key] ?? [];
1348 + $result = $this->uploadNamedFile(
1349 + $attachment_meta[$meta_key], $file_dir,
1350 + $existing_file['source_path'] ?? '', $existing_file['key'] ?? '',
1351 + $do_reupload, $has_existing, $prefix, $attachment_id, $is_private, $not_found_message
1352 + );
1353 + if (!empty($result)) {
1354 + $additional_files[$meta_key] = $result;
1355 + }
1356 + }
1357 + return $additional_files;
1358 + }
1359 +
1360 + /**
1249 1361 * Upload intermediate image sizes.
1250 1362 * @since 1.3.6
1251 1363 * @param array $attachment_sizes
1252 1364 * @param string $file_dir
1253 - * @param bool $do_reupload
1365 + * @param bool|array $do_reupload Reupload everything (true), nothing (false), or only these sizes (array of size names).
1254 1366 * @param array $existing_extras
1255 1367 * @param string $prefix
1256 1368 * @param int $attachment_id
1257 - * @return array
1369 + * @param bool $is_private Keeps a reupload of an already-private item under the private path.
1370 + * @return array
1258 1371 */
1259 - private function uploadImageSizes($attachment_sizes, $file_dir, $do_reupload, $existing_extras, $prefix, $attachment_id) {
1372 + private function uploadImageSizes($attachment_sizes, $file_dir, $do_reupload, $existing_extras, $prefix, $attachment_id, $is_private = false) {
1260 1373 $sizes = [];
1261 1374
1262 1375 foreach ($attachment_sizes as $size => $sub_image) {
1263 1376 $sub_size = [];
@@ -1271,10 +1384,14 @@
1271 1384 }
1272 1385
1273 1386 $uploaded_sub_image = [];
1274 1387
1388 + // $do_reupload may be an array naming specific sizes to force (e.g. a
1389 + // WooCommerce on-the-fly regen of one size only) rather than a plain bool.
1390 + $force_this_size = ($do_reupload === true) || (is_array($do_reupload) && in_array($size, $do_reupload, true));
1391 +
1275 1392 if (
1276 - !$do_reupload &&
1393 + !$force_this_size &&
1277 1394 !Utils::is_empty($existing_extras) &&
1278 1395 isset($existing_extras['sizes']) && !Utils::is_empty($existing_extras['sizes']) &&
1279 1396 isset($existing_extras['sizes'][$size]) && !Utils::is_empty($existing_extras['sizes'][$size]) &&
1280 1397 $existing_extras['sizes'][$size]['source_path'] == $sub_file_source_path
@@ -1284,9 +1401,9 @@
1284 1401 'file_url' => $existing_extras['sizes'][$size]['url'],
1285 1402 'key' => $existing_extras['sizes'][$size]['key']
1286 1403 ];
1287 1404 } else if (file_exists($sub_file_path)) {
1288 - $uploaded_sub_image = Service::instance()->uploadSingle($sub_file_path, $sub_file_source_path, $prefix);
1405 + $uploaded_sub_image = Service::instance()->uploadSingle($sub_file_path, $sub_file_source_path, $prefix, $is_private);
1289 1406 } else {
1290 1407 Logger::instance()->add_log('sync_to_cloud', $attachment_id, 'media_library', [
1291 1408 /* translators: %1$s: Image size name (e.g., thumbnail, medium, large). */
1292 1409 'message' => sprintf(__('Image size %1$s not found', 'media-cloud-sync'), $size),
@@ -1309,8 +1426,14 @@
1309 1426 'file' => $sub_file_path,
1310 1427 'code' => 500
1311 1428 ]);
1312 1429 }
1430 +
1431 + // A failed (re)upload attempt shouldn't erase a previously-good record —
1432 + // fall back to what's already stored rather than overwriting it with empty data.
1433 + if (isset($existing_extras['sizes'][$size]) && !Utils::is_empty($existing_extras['sizes'][$size])) {
1434 + $sub_size = $existing_extras['sizes'][$size];
1435 + }
1313 1436 }
1314 1437
1315 1438 $sizes[$size] = $sub_size;
1316 1439 }
@@ -1341,18 +1464,40 @@
1341 1464 }
1342 1465
1343 1466
1344 1467 /**
1468 + * Whether an attachment is still waiting on WordPress-generated subsizes — same check
1469 + * update_attachment_metadata() uses, so callers can tell that apart from a real failure.
1470 + *
1471 + * @param int $attachment_id
1472 + * @return bool
1473 + */
1474 + private function is_attachment_not_yet_ready( $attachment_id ) {
1475 + if ( ! wp_attachment_is_image( $attachment_id ) ) {
1476 + return false;
1477 + }
1478 +
1479 + $attachment_meta = wp_get_attachment_metadata( $attachment_id );
1480 +
1481 + return $this->should_wait_for_subsizes( $attachment_meta, $attachment_id );
1482 + }
1483 +
1484 + /**
1345 1485 * Upload pending media files.
1346 1486 *
1347 1487 * Processes media attachments that are pending and haven't been synced yet.
1348 1488 *
1489 + * Paginates by `posts.ID` (a stable cursor) rather than a raw SQL OFFSET,
1490 + * so a concurrent single-item retry (which can remove an arbitrary item
1491 + * from the middle of the pending set, not just the front) can't desync
1492 + * which row gets picked up next.
1493 + *
1349 1494 * @param string $source_type Source type for identifying which source..
1350 - * @param int $limit Number of media to process. Default is 50.
1351 - * @param int $offset Offset for media query. Default is 0.
1352 - * @return array Status array with success and failed media IDs.
1495 + * @param int $limit Number of media to process. Default is 50.
1496 + * @param int $after_id Only consider attachments with ID greater than this cursor. Default is 0.
1497 + * @return array Status array with success, failed count, and the last attachment ID considered.
1353 1498 */
1354 - public function upload_pending_media( $source_type, $limit = 50, $offset = 0 ) {
1499 + public function upload_pending_media( $source_type, $limit = 50, $after_id = 0 ) {
1355 1500 global $wpdb;
1356 1501
1357 1502 $failed = 0;
1358 1503
@@ -1357,9 +1502,9 @@
1357 1502 $failed = 0;
1358 1503
1359 1504 $source_type_data = self::$source_types[ $source_type ] ?? false;
1360 1505 if ( ! $source_type_data ) {
1361 - return [ 'success' => true, 'failed' => $limit ];
1506 + return [ 'success' => true, 'failed' => $limit, 'last_id' => $after_id ];
1362 1507 }
1363 1508
1364 1509 $posts = $wpdb->prefix . $source_type_data['table'];
1365 1510 $items = Db::get_table_name();
@@ -1381,8 +1526,14 @@
1381 1526 $join .= " AND items.region = %s";
1382 1527 $params[] = $this->region;
1383 1528 }
1384 1529
1530 + $after_id_sql = '';
1531 + if ( $after_id > 0 ) {
1532 + $after_id_sql = 'AND posts.ID > %d';
1533 + $params[] = (int) $after_id;
1534 + }
1535 +
1385 1536 $sql = "
1386 1537 SELECT posts.ID
1387 1538 FROM {$posts} AS posts
1388 1539 LEFT JOIN {$items} AS items USE INDEX (idx_item_lookup)
@@ -1388,30 +1539,41 @@
1388 1539 LEFT JOIN {$items} AS items USE INDEX (idx_item_lookup)
1389 1540 ON {$join}
1390 1541 WHERE posts.post_type = 'attachment'
1391 1542 AND items.source_id IS NULL
1543 + {$after_id_sql}
1392 1544 ORDER BY posts.ID ASC
1393 - LIMIT %d OFFSET %d
1545 + LIMIT %d
1394 1546 ";
1395 1547
1396 1548 $params[] = (int) $limit;
1397 - $params[] = (int) $offset;
1398 1549
1399 1550 $rows = $wpdb->get_results( $wpdb->prepare( $sql, $params ) );
1400 1551
1401 1552 if ( empty( $rows ) ) {
1402 - return [ 'success' => true, 'failed' => 0 ];
1553 + return [ 'success' => true, 'failed' => 0, 'last_id' => $after_id ];
1403 1554 }
1404 1555
1556 + $last_id = $after_id;
1405 1557 foreach ( $rows as $row ) {
1558 + $attachment_id = (int) $row->ID;
1559 +
1560 + // Still waiting on WordPress to finish generating subsizes — not a failure,
1561 + // just not ready yet. Leave $last_id alone so it's reconsidered next tick.
1562 + if ( $this->is_attachment_not_yet_ready( $attachment_id ) ) {
1563 + break;
1564 + }
1565 +
1566 + $last_id = $attachment_id;
1567 +
1406 1568 try {
1407 - $this->upload_single_media( (int) $row->ID, $source_type );
1569 + $this->upload_single_media( $attachment_id, $source_type );
1408 1570 } catch ( \Throwable $e ) {
1409 1571 $failed++;
1410 1572 continue;
1411 1573 }
1412 1574
1413 - if ( ! Item::instance()->get( (int) $row->ID, $source_type ) ) {
1575 + if ( ! Item::instance()->get( $attachment_id, $source_type ) ) {
1414 1576 $failed++;
1415 1577 }
1416 1578 }
1417 1579
@@ -1417,8 +1579,9 @@
1417 1579
1418 1580 return [
1419 1581 'success' => true,
1420 1582 'failed' => $failed,
1583 + 'last_id' => $last_id,
1421 1584 ];
1422 1585 }
1423 1586
1424 1587
@@ -1445,9 +1608,9 @@
1445 1608 $provider = $wpmcsItem->get_field($attachment_id, 'provider', 'media_library');
1446 1609 if($provider) {
1447 1610 $label = Schema::getServiceLabels($provider); ?>
1448 1611 <div class="misc-pub-section misc-pub-provider">
1449 - <?php esc_html_e( 'Provider :', 'media-cloud-sync' ); ?> <strong><?php echo esc_textarea(!empty($label) ? $label : $provider); ?></strong></a>
1612 + <?php esc_html_e( 'Provider :', 'media-cloud-sync' ); ?> <strong><?php echo esc_textarea(!empty($label) ? $label : $provider); ?></strong>
1450 1613 </div>
1451 1614 <?php
1452 1615 }
1453 1616
@@ -1453,9 +1616,9 @@
1453 1616
1454 1617 $region = $wpmcsItem->get_field($attachment_id, 'region', 'media_library');
1455 1618 if($region) { ?>
1456 1619 <div class="misc-pub-section misc-pub-provider">
1457 - <?php esc_html_e( 'Region :', 'media-cloud-sync' ); ?> <strong><?php echo esc_textarea($region); ?></strong></a>
1620 + <?php esc_html_e( 'Region :', 'media-cloud-sync' ); ?> <strong><?php echo esc_textarea($region); ?></strong>
1458 1621 </div>
1459 1622 <?php
1460 1623 }
1461 1624 $private = (int)$wpmcsItem->get_field($attachment_id, 'is_private', 'media_library');
@@ -1460,9 +1623,9 @@
1460 1623 }
1461 1624 $private = (int)$wpmcsItem->get_field($attachment_id, 'is_private', 'media_library');
1462 1625 ?>
1463 1626 <div class="misc-pub-section misc-pub-provider">
1464 - <?php esc_html_e( 'Access :', 'media-cloud-sync' ); ?> <strong><?php $private ? esc_html_e( 'Private', 'media-cloud-sync' ) : esc_html_e( 'Public', 'media-cloud-sync' ); ?></strong></a>
1627 + <?php esc_html_e( 'Access :', 'media-cloud-sync' ); ?> <strong><?php $private ? esc_html_e( 'Private', 'media-cloud-sync' ) : esc_html_e( 'Public', 'media-cloud-sync' ); ?></strong>
1465 1628 </div>
1466 1629 <?php
1467 1630 }
1468 1631
@@ -1468,8 +1631,10 @@
1468 1631
1469 1632
1470 1633 /**
1471 1634 * Function to get attachment details by ID
1635 + *
1636 + * @since 1.3.12 Added capability checks for attachment access.
1472 1637 */
1473 1638 public function ajax_get_attachment_details() {
1474 1639 $result = array(
1475 1640 'status' => false,
@@ -1476,8 +1641,12 @@
1476 1641 'data' => array(),
1477 1642 'exclude' => false
1478 1643 );
1479 1644
1645 + if(!Utils::is_service_enabled()) {
1646 + wp_send_json_success( $result );
1647 + }
1648 +
1480 1649 if ( ! isset( $_POST['id'] ) ) {
1481 1650 wp_send_json_success( $result );
1482 1651 }
1483 1652
@@ -1482,9 +1651,18 @@
1482 1651 }
1483 1652
1484 1653 check_ajax_referer( 'get_media_provider_details', '_nonce' );
1485 1654
1486 - $id= intval( sanitize_text_field( $_POST['id'] ) );
1655 + if ( ! current_user_can( 'upload_files' ) ) {
1656 + wp_send_json_error();
1657 + }
1658 +
1659 + $id = absint( wp_unslash( $_POST['id'] ) );
1660 + $post = get_post( $id );
1661 +
1662 + if ( ! $post || 'attachment' !== $post->post_type || ! current_user_can( 'edit_post', $id ) ) {
1663 + wp_send_json_success( $result );
1664 + }
1487 1665
1488 1666 // Return if extension not allowed
1489 1667 $path = get_attached_file( $id );
1490 1668 if(!Utils::is_extension_available($path)) {
@@ -1512,9 +1690,9 @@
1512 1690 $region = $wpmcsItem->get_field($id, 'region', 'media_library');
1513 1691 if($region){
1514 1692 $item['region'] = $region;
1515 1693 }
1516 - $item['private'] = $wpmcsItem->get_field($id, 'private', 'media_library');
1694 + $item['private'] = (int) $wpmcsItem->get_field($id, 'is_private', 'media_library');
1517 1695 if($item) {
1518 1696 $result= array(
1519 1697 'status' => true,
1520 1698 'data' => $item
@@ -1549,8 +1727,26 @@
1549 1727 * Note: delete_post is used as there is a potential that deleted_post is not reached.
1550 1728 */
1551 1729 public function delete_post() {
1552 1730 $this->deleting_attachment = false;
1731 + }
1732 +
1733 + /**
1734 + * Safety net for deletion that bypasses wp_delete_attachment().
1735 + *
1736 + * @handles deleted_post
1737 + */
1738 + public function deleted_post( $post_id, $post ) {
1739 + if ( ! $post || 'attachment' !== $post->post_type ) {
1740 + return;
1741 + }
1742 +
1743 + $item = Item::instance()->get( $post_id, 'media_library' );
1744 + if ( Utils::is_empty( $item ) ) {
1745 + return;
1746 + }
1747 +
1748 + Item::instance()->delete( $post_id, 'media_library' );
1553 1749 }
1554 1750
1555 1751 /**
1556 1752 * Has WP Core fixed wp_check_filetype when URL has params yet?