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 +294 -94 1.3.11 → 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 }
@@ -183,8 +195,13 @@
183 195 */
184 196 public function update_attachment_metadata($attachment_meta, $attachment_id) {
185 197 // Remove Logs
186 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.
187 204 try {
188 205 // Ensure attachment is eligible for upload
189 206 $attachment_meta = is_array($attachment_meta) && !empty($attachment_meta)
190 207 ? $attachment_meta
@@ -544,9 +561,9 @@
544 561 if ( $this->deleting_attachment ) {
545 562 return $file;
546 563 }
547 564
548 - if (!Utils::is_ok_to_serve($attachment_id)) {
565 + if (!Utils::should_serve_from_cloud($attachment_id)) {
549 566 return $file;
550 567 }
551 568
552 569 $wpmcsItem = Item::instance();
@@ -599,9 +616,9 @@
599 616 return $sources;
600 617 }
601 618
602 619 // Must need $attachment_id other wise not possible to get data from the table
603 - if (!Utils::is_ok_to_serve($attachment_id)) {
620 + if (!Utils::should_serve_from_cloud($attachment_id)) {
604 621 return $sources;
605 622 }
606 623
607 624 $wpmcsItem = Item::instance();
@@ -634,22 +651,26 @@
634 651 *
635 652 * @return null|string
636 653 */
637 654 protected function find_image_size_from_width( $meta, $width, $filename ) {
638 - $sizes = $meta['sizes'];
639 - foreach ( $sizes as $name => $size ) {
640 - if ( $width === absint( $size['width'] ) && $size['file'] === $filename ) {
641 - 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 + }
642 664 }
643 665 }
644 666
645 - // Check for full size
646 - if($width === $meta['width']) {
647 - $full_file = wp_basename( $meta['file'] );
648 - if($full_file === $filename) {
649 - return 'full';
650 - }
651 - }
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 + }
652 673
653 674 return null;
654 675 }
655 676
@@ -664,10 +685,10 @@
664 685 * @return array
665 686 */
666 687 public function wp_get_attachment_image_attributes($attr, $attachment, $size='thumbnail') {
667 688 if (
668 - !$attachment ||
669 - !Utils::is_ok_to_serve($attachment->ID)
689 + !$attachment ||
690 + !Utils::should_serve_from_cloud($attachment->ID)
670 691 ) {
671 692 return $attr;
672 693 }
673 694
@@ -714,9 +735,9 @@
714 735 *
715 736 * @return bool|mixed|WP_Error
716 737 */
717 738 public function wp_get_attachment_url($url, $attachment_id) {
718 - if (!Utils::is_ok_to_serve($attachment_id)) {
739 + if (!Utils::should_serve_from_cloud($attachment_id)) {
719 740 return $url;
720 741 }
721 742
722 743 $new_url = Item::instance()->get_url($attachment_id, 'full', 'media_library');
@@ -741,14 +762,14 @@
741 762 * @param string $size
742 763 *
743 764 * @return string
744 765 */
745 - public function get_image_tag( $html, $id, $alt, $title, $align, $size ) {
766 + public function get_image_tag( $html, $id, $alt = '', $title = '', $align = '', $size = '' ) {
746 767 if ( ! is_string( $html ) ) {
747 768 return $html;
748 769 }
749 770
750 - if (!Utils::is_ok_to_serve($id)) {
771 + if (!Utils::should_serve_from_cloud($id)) {
751 772 return $html;
752 773 }
753 774
754 775 preg_match( '@\ssrc=[\'\"]([^\'\"]*)[\'\"]@', $html, $matches );
@@ -757,8 +778,16 @@
757 778 // Can't establish img src
758 779 return $html;
759 780 }
760 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 +
761 790 $img_src = $matches[1];
762 791 $size = Utils::maybe_convert_size_to_string( $id, $size );
763 792 $new_url = Item::instance()->get_url($id, $size, 'media_library');
764 793
@@ -780,9 +809,9 @@
780 809 *
781 810 * @return array
782 811 */
783 812 public function wp_get_attachment_image_src( $image, $attachment_id, $size, $icon ) {
784 - if (!Utils::is_ok_to_serve($attachment_id)) {
813 + if (!Utils::should_serve_from_cloud($attachment_id)) {
785 814 return $image;
786 815 }
787 816
788 817 if ( isset( $image[0] ) ) {
@@ -808,9 +837,9 @@
808 837 *
809 838 * @return array
810 839 */
811 840 public function wp_prepare_attachment_for_js( $response, $attachment, $meta ) {
812 - if (!Utils::is_ok_to_serve($attachment->ID)) {
841 + if (!Utils::should_serve_from_cloud($attachment->ID)) {
813 842 return $response;
814 843 }
815 844
816 845 if ( isset( $response['sizes'] ) && is_array( $response['sizes'] ) ) {
@@ -836,9 +865,9 @@
836 865 *
837 866 * @return array
838 867 */
839 868 public function image_get_intermediate_size( $data, $post_id, $size ) {
840 - if (!Utils::is_ok_to_serve($post_id)) {
869 + if (!Utils::should_serve_from_cloud($post_id)) {
841 870 return $data;
842 871 }
843 872 if ( isset( $data['url'] ) ) {
844 873 $size = Utils::maybe_convert_size_to_string( $post_id, $size );
@@ -988,8 +1017,11 @@
988 1017 $is_image = wp_attachment_is_image($attachment_id);
989 1018 $existing = $wpmcsItem->get($attachment_id, 'media_library');
990 1019 $existing_extras = $wpmcsItem->get_extras($attachment_id, false, 'media_library');
991 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']);
992 1024 $sizes = [];
993 1025 $uploaded = [];
994 1026 $extras = [];
995 1027 $prefix = '';
@@ -1000,10 +1032,11 @@
1000 1032 $do_reupload = apply_filters('wpmcs_do_reupload_media', false, $attachment_id, 'media_library');
1001 1033
1002 1034 // Force reupload when sizes/dimensions changed
1003 1035 if (!$do_reupload && $has_existing) {
1004 - if($this->shouldForceReupload($attachment_meta, $attachment_id, $existing_extras)) {
1005 - $do_reupload = true;
1036 + $force_reupload = $this->shouldForceReupload($attachment_meta, $attachment_id, $existing_extras);
1037 + if ($force_reupload !== false) {
1038 + $do_reupload = $force_reupload;
1006 1039 }
1007 1040 }
1008 1041
1009 1042 // Add prefix if object versioning is ON
@@ -1019,37 +1052,54 @@
1019 1052 $extras['width'] = (isset($attachment_meta['width']) && !empty($attachment_meta['width'])) ? $attachment_meta['width'] : 0;
1020 1053 $extras['height'] = (isset($attachment_meta['height']) && !empty($attachment_meta['height'])) ? $attachment_meta['height'] : 0;
1021 1054 }
1022 1055
1023 - // Check whether the extension is enabled for uploading
1056 + // Excluded by extension settings — an intentional skip, not a sync failure, so no log entry.
1024 1057 if (!Utils::is_extension_available($file_path)) {
1025 - Logger::instance()->add_log('sync_to_cloud', $attachment_id, 'media_library', [
1026 - 'message' => __('File extension is not supported', 'media-cloud-sync'),
1027 - 'file' => $file_path,
1028 - 'code' => 415
1029 - ]);
1030 1058 return false;
1031 1059 }
1032 1060
1033 1061 // Upload main file first, if that fails no need to attempt the rest
1034 - $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);
1035 1063
1036 1064 if ($uploaded && isset($uploaded['success']) && $uploaded['success']) {
1037 1065 // Attempt to upload original file if present. Not critical if this fails so we don't check the result before proceeding.
1038 - $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 = [];
1039 1068
1040 1069 if ($is_image && isset($file_dir) && !empty($file_dir)) {
1041 1070 $sizes_to_upload = $this->get_attachment_image_sizes_for_upload($attachment_meta, $attachment_id);
1042 1071 if (!empty($sizes_to_upload)) {
1043 1072 // Upload image sizes. Again, not critical if this fails so we don't check the result before proceeding.
1044 - $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);
1045 1074 }
1075 + $additional_files = $this->uploadAdditionalFiles($attachment_meta, $file_dir, $do_reupload, $has_existing, $existing_extras, $prefix, $attachment_id, $is_private);
1046 1076 }
1047 1077
1048 1078 if (!empty($sizes)) {
1049 - $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);
1050 1093 }
1051 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 +
1052 1102 return [
1053 1103 'file' => [
1054 1104 'source_path' => $source_path,
1055 1105 'url' => $uploaded['file_url'],
@@ -1061,12 +1111,13 @@
1061 1111 ];
1062 1112 }
1063 1113
1064 1114 if (isset($uploaded['success']) && !$uploaded['success']) {
1115 + // Prefer the service's own error message/code when it provided one.
1065 1116 Logger::instance()->add_log('sync_to_cloud', $attachment_id, 'media_library', [
1066 - 'message' => __('File upload failed', 'media-cloud-sync'),
1117 + 'message' => $uploaded['message'] ?? __('File upload failed', 'media-cloud-sync'),
1067 1118 'file' => $file_path,
1068 - 'code' => 500
1119 + 'code' => $uploaded['code'] ?? 500
1069 1120 ]);
1070 1121 }
1071 1122
1072 1123 return false;
@@ -1102,8 +1153,10 @@
1102 1153 * @since 1.3.6
1103 1154 * @param array $attachment_meta
1104 1155 * @param int $attachment_id
1105 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.
1106 1159 */
1107 1160 private function shouldForceReupload($attachment_meta, $attachment_id, $existing_extras) {
1108 1161 $existing_extras = $existing_extras ?? Item::instance()->get_extras($attachment_id, false, 'media_library');
1109 1162
@@ -1130,31 +1183,26 @@
1130 1183 'height' => isset($smeta['height']) ? (int)$smeta['height'] : 0,
1131 1184 ];
1132 1185 }
1133 1186
1134 - $existing_relevant = [];
1135 - foreach (array_keys($current_sizes) as $sname) {
1136 - if (isset($existing_sizes[$sname])) {
1137 - $existing_relevant[$sname] = $existing_sizes[$sname];
1138 - }
1139 - }
1140 -
1141 - if (array_keys($existing_relevant) !== array_keys($current_sizes)) {
1142 - return true;
1143 - }
1144 -
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 = [];
1145 1192 foreach ($current_sizes as $sname => $dims) {
1146 1193 if (!isset($existing_sizes[$sname])) {
1147 - return true;
1194 + $changed_sizes[] = $sname;
1195 + continue;
1148 1196 }
1149 1197 $ex_w = isset($existing_sizes[$sname]['width']) ? (int)$existing_sizes[$sname]['width'] : 0;
1150 1198 $ex_h = isset($existing_sizes[$sname]['height']) ? (int)$existing_sizes[$sname]['height'] : 0;
1151 1199 if ($ex_w !== $dims['width'] || $ex_h !== $dims['height']) {
1152 - return true;
1200 + $changed_sizes[] = $sname;
1153 1201 }
1154 1202 }
1155 1203
1156 - return false;
1204 + return empty($changed_sizes) ? false : $changed_sizes;
1157 1205 }
1158 1206
1159 1207 /**
1160 1208 * Upload the main/full file.
@@ -1160,16 +1208,19 @@
1160 1208 * Upload the main/full file.
1161 1209 * @since 1.3.6
1162 1210 * @param string $file_path
1163 1211 * @param string $source_path
1164 - * @param bool $do_reupload
1212 + * @param bool|array $do_reupload Reupload everything (true), nothing (false), or only these sizes (array of size names).
1165 1213 * @param bool $has_existing
1166 1214 * @param array $existing
1167 1215 * @param string $prefix
1168 1216 * @param int $attachment_id
1217 + * @param bool $is_private Keeps a reupload of an already-private item under the private path.
1169 1218 */
1170 - private function uploadFullFile($file_path, $source_path, $do_reupload, $has_existing, $existing, $prefix, $attachment_id) {
1171 - 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)) {
1172 1223 return [
1173 1224 'success' => true,
1174 1225 'file_url' => $existing['url'],
1175 1226 'key' => $existing['key']
@@ -1176,9 +1227,9 @@
1176 1227 ];
1177 1228 }
1178 1229
1179 1230 if (file_exists($file_path)) {
1180 - return Service::instance()->uploadSingle($file_path, $source_path, $prefix);
1231 + return Service::instance()->uploadSingle($file_path, $source_path, $prefix, $is_private);
1181 1232 }
1182 1233
1183 1234 Logger::instance()->add_log('sync_to_cloud', $attachment_id, 'media_library', [
1184 1235 'message' => __('File not found', 'media-cloud-sync'),
@@ -1193,63 +1244,133 @@
1193 1244 * Upload original file when present.
1194 1245 * @since 1.3.6
1195 1246 * @param string $original_file
1196 1247 * @param string $file_dir
1197 - * @param bool $do_reupload
1248 + * @param bool|array $do_reupload Reupload everything (true), nothing (false), or only these sizes (array of size names).
1198 1249 * @param bool $has_existing
1199 1250 * @param array $existing
1200 1251 * @param string $prefix
1201 1252 * @param int $attachment_id
1253 + * @param bool $is_private Keeps a reupload of an already-private item under the private path.
1202 1254 */
1203 - private function uploadOriginalFile($original_file, $file_dir, $do_reupload, $has_existing, $existing, $prefix, $attachment_id) {
1204 - $original = [];
1205 - if (empty($original_file)) {
1206 - 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 [];
1207 1283 }
1208 1284
1209 - $original_file_path = trailingslashit($file_dir) . $original_file;
1210 - $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);
1211 1287
1212 - 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) {
1213 - 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 ];
1214 1292 }
1215 1293
1216 - if (file_exists($original_file_path)) {
1217 - $uploaded_original = Service::instance()->uploadSingle($original_file_path, $original_file_source_path, $prefix);
1218 - if (isset($uploaded_original['success']) && $uploaded_original['success']) {
1219 - 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'] ];
1220 1298 }
1221 1299
1222 - if (isset($uploaded_original['success']) && !$uploaded_original['success']) {
1300 + if (isset($uploaded['success']) && !$uploaded['success']) {
1223 1301 Logger::instance()->add_log('sync_to_cloud', $attachment_id, 'media_library', [
1224 - 'message' => __('Original File upload failed', 'media-cloud-sync'),
1225 - 'file' => $original_file_path,
1226 - 'code' => 500
1302 + 'message' => $uploaded['message'] ?? $not_found_message,
1303 + 'file' => $file_path,
1304 + 'code' => $uploaded['code'] ?? 500
1227 1305 ]);
1228 1306 }
1229 1307 } else {
1230 1308 Logger::instance()->add_log('sync_to_cloud', $attachment_id, 'media_library', [
1231 - 'message' => __('Original File not found', 'media-cloud-sync'),
1232 - 'file' => $original_file_path,
1309 + 'message' => $not_found_message,
1310 + 'file' => $file_path,
1233 1311 'code' => 404
1234 1312 ]);
1235 1313 }
1236 1314
1237 - return $original;
1315 + return [];
1238 1316 }
1239 1317
1240 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 + /**
1241 1361 * Upload intermediate image sizes.
1242 1362 * @since 1.3.6
1243 1363 * @param array $attachment_sizes
1244 1364 * @param string $file_dir
1245 - * @param bool $do_reupload
1365 + * @param bool|array $do_reupload Reupload everything (true), nothing (false), or only these sizes (array of size names).
1246 1366 * @param array $existing_extras
1247 1367 * @param string $prefix
1248 1368 * @param int $attachment_id
1249 - * @return array
1369 + * @param bool $is_private Keeps a reupload of an already-private item under the private path.
1370 + * @return array
1250 1371 */
1251 - 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) {
1252 1373 $sizes = [];
1253 1374
1254 1375 foreach ($attachment_sizes as $size => $sub_image) {
1255 1376 $sub_size = [];
@@ -1263,10 +1384,14 @@
1263 1384 }
1264 1385
1265 1386 $uploaded_sub_image = [];
1266 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 +
1267 1392 if (
1268 - !$do_reupload &&
1393 + !$force_this_size &&
1269 1394 !Utils::is_empty($existing_extras) &&
1270 1395 isset($existing_extras['sizes']) && !Utils::is_empty($existing_extras['sizes']) &&
1271 1396 isset($existing_extras['sizes'][$size]) && !Utils::is_empty($existing_extras['sizes'][$size]) &&
1272 1397 $existing_extras['sizes'][$size]['source_path'] == $sub_file_source_path
@@ -1276,9 +1401,9 @@
1276 1401 'file_url' => $existing_extras['sizes'][$size]['url'],
1277 1402 'key' => $existing_extras['sizes'][$size]['key']
1278 1403 ];
1279 1404 } else if (file_exists($sub_file_path)) {
1280 - $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);
1281 1406 } else {
1282 1407 Logger::instance()->add_log('sync_to_cloud', $attachment_id, 'media_library', [
1283 1408 /* translators: %1$s: Image size name (e.g., thumbnail, medium, large). */
1284 1409 'message' => sprintf(__('Image size %1$s not found', 'media-cloud-sync'), $size),
@@ -1301,8 +1426,14 @@
1301 1426 'file' => $sub_file_path,
1302 1427 'code' => 500
1303 1428 ]);
1304 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 + }
1305 1436 }
1306 1437
1307 1438 $sizes[$size] = $sub_size;
1308 1439 }
@@ -1333,18 +1464,40 @@
1333 1464 }
1334 1465
1335 1466
1336 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 + /**
1337 1485 * Upload pending media files.
1338 1486 *
1339 1487 * Processes media attachments that are pending and haven't been synced yet.
1340 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 + *
1341 1494 * @param string $source_type Source type for identifying which source..
1342 - * @param int $limit Number of media to process. Default is 50.
1343 - * @param int $offset Offset for media query. Default is 0.
1344 - * @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.
1345 1498 */
1346 - public function upload_pending_media( $source_type, $limit = 50, $offset = 0 ) {
1499 + public function upload_pending_media( $source_type, $limit = 50, $after_id = 0 ) {
1347 1500 global $wpdb;
1348 1501
1349 1502 $failed = 0;
1350 1503
@@ -1349,9 +1502,9 @@
1349 1502 $failed = 0;
1350 1503
1351 1504 $source_type_data = self::$source_types[ $source_type ] ?? false;
1352 1505 if ( ! $source_type_data ) {
1353 - return [ 'success' => true, 'failed' => $limit ];
1506 + return [ 'success' => true, 'failed' => $limit, 'last_id' => $after_id ];
1354 1507 }
1355 1508
1356 1509 $posts = $wpdb->prefix . $source_type_data['table'];
1357 1510 $items = Db::get_table_name();
@@ -1373,8 +1526,14 @@
1373 1526 $join .= " AND items.region = %s";
1374 1527 $params[] = $this->region;
1375 1528 }
1376 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 +
1377 1536 $sql = "
1378 1537 SELECT posts.ID
1379 1538 FROM {$posts} AS posts
1380 1539 LEFT JOIN {$items} AS items USE INDEX (idx_item_lookup)
@@ -1380,30 +1539,41 @@
1380 1539 LEFT JOIN {$items} AS items USE INDEX (idx_item_lookup)
1381 1540 ON {$join}
1382 1541 WHERE posts.post_type = 'attachment'
1383 1542 AND items.source_id IS NULL
1543 + {$after_id_sql}
1384 1544 ORDER BY posts.ID ASC
1385 - LIMIT %d OFFSET %d
1545 + LIMIT %d
1386 1546 ";
1387 1547
1388 1548 $params[] = (int) $limit;
1389 - $params[] = (int) $offset;
1390 1549
1391 1550 $rows = $wpdb->get_results( $wpdb->prepare( $sql, $params ) );
1392 1551
1393 1552 if ( empty( $rows ) ) {
1394 - return [ 'success' => true, 'failed' => 0 ];
1553 + return [ 'success' => true, 'failed' => 0, 'last_id' => $after_id ];
1395 1554 }
1396 1555
1556 + $last_id = $after_id;
1397 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 +
1398 1568 try {
1399 - $this->upload_single_media( (int) $row->ID, $source_type );
1569 + $this->upload_single_media( $attachment_id, $source_type );
1400 1570 } catch ( \Throwable $e ) {
1401 1571 $failed++;
1402 1572 continue;
1403 1573 }
1404 1574
1405 - if ( ! Item::instance()->get( (int) $row->ID, $source_type ) ) {
1575 + if ( ! Item::instance()->get( $attachment_id, $source_type ) ) {
1406 1576 $failed++;
1407 1577 }
1408 1578 }
1409 1579
@@ -1409,8 +1579,9 @@
1409 1579
1410 1580 return [
1411 1581 'success' => true,
1412 1582 'failed' => $failed,
1583 + 'last_id' => $last_id,
1413 1584 ];
1414 1585 }
1415 1586
1416 1587
@@ -1437,9 +1608,9 @@
1437 1608 $provider = $wpmcsItem->get_field($attachment_id, 'provider', 'media_library');
1438 1609 if($provider) {
1439 1610 $label = Schema::getServiceLabels($provider); ?>
1440 1611 <div class="misc-pub-section misc-pub-provider">
1441 - <?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>
1442 1613 </div>
1443 1614 <?php
1444 1615 }
1445 1616
@@ -1445,9 +1616,9 @@
1445 1616
1446 1617 $region = $wpmcsItem->get_field($attachment_id, 'region', 'media_library');
1447 1618 if($region) { ?>
1448 1619 <div class="misc-pub-section misc-pub-provider">
1449 - <?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>
1450 1621 </div>
1451 1622 <?php
1452 1623 }
1453 1624 $private = (int)$wpmcsItem->get_field($attachment_id, 'is_private', 'media_library');
@@ -1452,9 +1623,9 @@
1452 1623 }
1453 1624 $private = (int)$wpmcsItem->get_field($attachment_id, 'is_private', 'media_library');
1454 1625 ?>
1455 1626 <div class="misc-pub-section misc-pub-provider">
1456 - <?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>
1457 1628 </div>
1458 1629 <?php
1459 1630 }
1460 1631
@@ -1460,8 +1631,10 @@
1460 1631
1461 1632
1462 1633 /**
1463 1634 * Function to get attachment details by ID
1635 + *
1636 + * @since 1.3.12 Added capability checks for attachment access.
1464 1637 */
1465 1638 public function ajax_get_attachment_details() {
1466 1639 $result = array(
1467 1640 'status' => false,
@@ -1478,9 +1651,18 @@
1478 1651 }
1479 1652
1480 1653 check_ajax_referer( 'get_media_provider_details', '_nonce' );
1481 1654
1482 - $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 + }
1483 1665
1484 1666 // Return if extension not allowed
1485 1667 $path = get_attached_file( $id );
1486 1668 if(!Utils::is_extension_available($path)) {
@@ -1508,9 +1690,9 @@
1508 1690 $region = $wpmcsItem->get_field($id, 'region', 'media_library');
1509 1691 if($region){
1510 1692 $item['region'] = $region;
1511 1693 }
1512 - $item['private'] = $wpmcsItem->get_field($id, 'private', 'media_library');
1694 + $item['private'] = (int) $wpmcsItem->get_field($id, 'is_private', 'media_library');
1513 1695 if($item) {
1514 1696 $result= array(
1515 1697 'status' => true,
1516 1698 'data' => $item
@@ -1545,8 +1727,26 @@
1545 1727 * Note: delete_post is used as there is a potential that deleted_post is not reached.
1546 1728 */
1547 1729 public function delete_post() {
1548 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' );
1549 1749 }
1550 1750
1551 1751 /**
1552 1752 * Has WP Core fixed wp_check_filetype when URL has params yet?