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 +1021 -249 1.2.13 → 1.4.2 View file →
@@ -2,8 +2,18 @@
2 2 namespace Dudlewebs\WPMCS;
3 3
4 4 defined('ABSPATH') || exit;
5 5
6 +use Exception;
7 +use WP_Error;
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 + */
6 16 class MediaLibrary {
7 17 private static $instance = null;
8 18 private $assets_url;
9 19 private $version;
@@ -8,15 +18,26 @@
8 18 private $assets_url;
9 19 private $version;
10 20 private $token;
11 21
22 + protected $config;
23 + protected $bucketConfig;
12 24 protected $settings;
25 + protected $credentials;
26 + protected $service;
27 +
28 + protected $bucket_name;
29 + protected $region = '';
30 +
13 31 private $deleting_attachment = false;
14 32
15 33
16 34 public static $source_type_prefix = "media";
17 - public static $label = 'Media Library';
18 35
36 + public static function get_label() {
37 + return __('Media Library', 'media-cloud-sync');
38 + }
39 +
19 40 // Map which meta to be updated
20 41 public static $source_types = [
21 42 "media_library" => [
22 43 'table' => 'posts',
@@ -33,8 +54,26 @@
33 54 $this->version = WPMCS_VERSION;
34 55 $this->token = WPMCS_TOKEN;
35 56 $this->settings = Utils::get_settings();
36 57
58 + $this->credentials = Utils::get_credentials();
59 + $this->config = isset($this->credentials['config']) && !empty($this->credentials['config'])
60 + ? $this->credentials['config']
61 + : [];
62 + $this->bucketConfig = isset($this->credentials['bucketConfig']) && !empty($this->credentials['bucketConfig'])
63 + ? $this->credentials['bucketConfig']
64 + : [];
65 + $this->service = isset($this->credentials['service']) && !empty($this->credentials['service'])
66 + ? $this->credentials['service']
67 + : '';
68 +
69 + if (isset($this->bucketConfig['bucket_name'])) {
70 + $this->bucket_name = $this->bucketConfig['bucket_name'];
71 + }
72 + if (isset($this->config['region'])) {
73 + $this->region = $this->config['region'];
74 + }
75 +
37 76 // Initialize setup
38 77 $this->registerActions();
39 78 }
40 79
@@ -49,14 +88,30 @@
49 88
50 89 /** URL RE-WRITING HOOKS */
51 90 add_filter( 'wp_get_attachment_url', [ $this, 'wp_get_attachment_url' ], 99, 2 );
52 91 add_filter( 'wp_get_attachment_image_attributes', [ $this, 'wp_get_attachment_image_attributes' ], 99, 3 );
53 - add_filter( 'wp_calculate_image_srcset', [ $this, 'wp_calculate_image_srcset' ], 99, 5 );
92 + add_filter( 'get_image_tag', array( $this, 'get_image_tag' ), 99, 6 );
93 + add_filter( 'wp_get_attachment_image_src', array( $this, 'wp_get_attachment_image_src' ), 99, 4 );
94 + add_filter( 'wp_prepare_attachment_for_js', array( $this, 'wp_prepare_attachment_for_js' ), 99, 3 );
95 + add_filter( 'image_get_intermediate_size', array( $this, 'image_get_intermediate_size' ), 99, 3 );
54 96 add_filter( 'get_attached_file', [ $this, 'get_attached_file' ], 10, 2 );
55 97 add_filter( 'wp_get_original_image_path', [ $this, 'get_attached_file' ], 10, 2 );
56 98 add_filter( 'wp_video_shortcode', [ $this, 'wp_media_shortcode' ], 100, 5 );
57 99 add_filter( 'wp_audio_shortcode', [ $this, 'wp_media_shortcode' ], 100, 5 );
100 +
101 + /*
102 + * Responsive Images WP 4.4
103 + */
104 + add_filter( 'wp_calculate_image_srcset', array( $this, 'wp_calculate_image_srcset' ), 10, 5 );
58 105
106 + // Srcset handling
107 + add_filter( 'wp_image_file_matches_image_meta', array( $this, 'wp_image_file_matches_image_meta' ), 10, 4 );
108 +
109 + if ( self::wp_check_filetype_broken() ) {
110 + add_filter( 'shortcode_atts_audio', array( $this, 'filter_shortcode_atts' ), 10, 4 );
111 + add_filter( 'shortcode_atts_video', array( $this, 'filter_shortcode_atts' ), 10, 4 );
112 + }
113 +
59 114 /** FILE MANAGEMENT HOOKS */
60 115 add_filter( 'wp_unique_filename', [ $this, 'wp_unique_filename' ], 10, 3 );
61 116 add_filter( 'wp_update_attachment_metadata', [ $this, 'update_attachment_metadata' ], 110, 2 );
62 117 add_filter( 'pre_delete_attachment', [ $this, 'pre_delete_attachment' ], 20 );
@@ -61,38 +116,29 @@
61 116 add_filter( 'wp_update_attachment_metadata', [ $this, 'update_attachment_metadata' ], 110, 2 );
62 117 add_filter( 'pre_delete_attachment', [ $this, 'pre_delete_attachment' ], 20 );
63 118 add_filter( 'delete_attachment', [ $this, 'delete_attachment' ], 20 );
64 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 );
65 122 add_filter( 'update_attached_file', [ $this, 'update_attached_file' ], 100, 2 );
66 - add_filter( 'load_image_to_edit_path', [ $this, 'load_image_to_edit_path' ], 10, 3 );
123 +
124 + add_action( 'wpmcs_do_update_attachment_metadata', [ $this, 'update_attachment_metadata' ], 10, 2 );
67 125 }
68 126
69 127
70 128 /**
71 - * Function to execute on load_image_to_edit_path
129 + * Upload a single media item from the local server to the provider.
130 + *
131 + * This function will trigger the `wpmcs_do_update_attachment_metadata` action
132 + * with the `false` parameter, which will cause the attachment metadata
133 + * to be updated on the provider.
134 + *
135 + * @param int $id The media item ID.
136 + * @param string $source_type The source type of the media item.
72 137 * @since 1.0.0
73 - * @return string
74 - *
75 138 */
76 - public function load_image_to_edit_path($path, $attachment_id, $size) {
77 - $wpmcsItem = Item::instance();
78 - if (!Utils::is_ok_to_serve($attachment_id) && !$wpmcsItem->is_available_from_provider($attachment, true, 'media_library') ) {
79 - return $path;
80 - }
81 -
82 -
83 - $server_file = false;
84 - //If operation is Image editor Image Save
85 - if(
86 - isset($_REQUEST['do']) &&
87 - isset($_REQUEST['action']) &&
88 - $_REQUEST['action'] === 'image-editor' &&
89 - $_REQUEST['do']==='save'
90 - ) {
91 - $server_file = $wpmcsItem->moveToServer($attachment_id, $size, false, 'media_library');
92 - }
93 -
94 - return $server_file ? $server_file : $path;
139 + public function upload_single_media($id, $source_type = 'media_library') {
140 + do_action( 'wpmcs_do_update_attachment_metadata', false, $id );
95 141 }
96 142
97 143
98 144 /**
@@ -125,21 +171,17 @@
125 171 * @since 1.0.0
126 172 *
127 173 */
128 174 public function delete_attachment($attachment_id){
129 - if (!Utils::is_service_enabled()) {
130 - return $attachment_id;
131 - }
132 -
133 175 $wpmcsItem = Item::instance();
134 176 $item = $wpmcsItem->get($attachment_id, 'media_library');
135 177
136 178 if (Utils::is_empty($item)) {
179 + // Remove Log if exists
180 + Logger::instance()->remove_log('sync_to_cloud', $attachment_id, 'media_library');
137 181 return $attachment_id;
138 182 }
139 183
140 - $wpmcsItem->delete_attachments_by_item($item);
141 -
142 184 $wpmcsItem->delete($attachment_id, 'media_library');
143 185
144 186 return $attachment_id;
145 187 }
@@ -151,40 +193,59 @@
151 193 * @param int $attachment_id
152 194 * @return array|WP_Error
153 195 */
154 196 public function update_attachment_metadata($attachment_meta, $attachment_id) {
155 - if (!Utils::is_ok_to_upload($attachment_id) || is_wp_error($attachment_meta)) {
156 - return $attachment_meta;
157 - }
197 + // Remove Logs
198 + Logger::instance()->remove_log('sync_to_cloud', $attachment_id, 'media_library');
158 199
159 - $attachment_id = (int) $attachment_id;
160 - $is_image = wp_attachment_is_image($attachment_id);
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.
204 + try {
205 + // Ensure attachment is eligible for upload
206 + $attachment_meta = is_array($attachment_meta) && !empty($attachment_meta)
207 + ? $attachment_meta
208 + : wp_get_attachment_metadata($attachment_id);
161 209
162 - if ($is_image && $this->should_wait_for_subsizes($attachment_meta, $attachment_id)) {
163 - return $attachment_meta;
164 - }
210 + if (!Utils::is_ok_to_upload($attachment_id) || is_wp_error($attachment_meta)) {
211 + return $attachment_meta;
212 + }
165 213
166 - $attachment_meta = $attachment_meta ?: wp_get_attachment_metadata($attachment_id);
214 + $attachment_id = (int) $attachment_id;
215 + $is_image = wp_attachment_is_image($attachment_id);
167 216
168 - $file = $this->get_attachment_file($attachment_meta, $attachment_id);
169 - $source_path = Utils::get_attachment_source_path($file);
217 + if ($is_image && $this->should_wait_for_subsizes($attachment_meta, $attachment_id)) {
218 + return $attachment_meta;
219 + }
170 220
171 - if (empty($source_path) || !is_string($source_path)) {
172 - return $attachment_meta;
173 - }
221 + $file = $this->get_attachment_file($attachment_meta, $attachment_id);
222 + $source_path = Utils::get_attachment_source_path($file);
174 223
175 - $existing = Item::instance()->get($attachment_id, 'media_library');
176 - $backup = Item::instance()->get_backup($attachment_id, 'media_library');
224 + if (empty($source_path) || !is_string($source_path)) {
225 + return $attachment_meta;
226 + }
177 227
178 - if (Utils::is_empty($existing)) {
179 - $this->handle_media_first_upload($attachment_meta, $attachment_id, $source_path);
180 - } elseif (Utils::is_empty($backup)) {
181 - $this->handle_media_with_no_backup($attachment_meta, $attachment_id, $source_path, $existing);
182 - } else {
183 - $this->handle_media_with_backup($attachment_meta, $attachment_id, $source_path, $existing, $backup);
228 + $existing = Item::instance()->get($attachment_id, 'media_library');
229 + $backup = Item::instance()->get_backup($attachment_id, 'media_library');
230 +
231 + if (Utils::is_empty($existing)) {
232 + $this->handle_media_first_upload($attachment_meta, $attachment_id, $source_path);
233 + } elseif (Utils::is_empty($backup)) {
234 + $this->handle_media_with_no_backup($attachment_meta, $attachment_id, $source_path, $existing);
235 + } else {
236 + $this->handle_media_with_backup($attachment_meta, $attachment_id, $source_path, $existing, $backup);
237 + }
238 +
239 + return $attachment_meta;
240 + } catch (Exception $e) {
241 + Logger::instance()->add_log('sync_to_cloud', $attachment_id, 'media_library', [
242 + 'message' => $e->getMessage() ?? 'Currupted attachment metadata.',
243 + 'file' => method_exists($e, 'getFile') ? $e->getFile() : 'N/A',
244 + 'code' => 415
245 + ]);
246 + return $attachment_meta;
184 247 }
185 -
186 - return $attachment_meta;
187 248 }
188 249
189 250 /**
190 251 * Checks if we should wait for WordPress to generate subsizes.
@@ -189,9 +250,13 @@
189 250 /**
190 251 * Checks if we should wait for WordPress to generate subsizes.
191 252 */
192 253 private function should_wait_for_subsizes($attachment_meta, $attachment_id) {
193 - if (empty($attachment_meta) || !function_exists('wp_get_registered_image_subsizes')) {
254 + if (
255 + empty($attachment_meta) ||
256 + !function_exists('wp_get_registered_image_subsizes') ||
257 + !function_exists('wp_get_missing_image_subsizes')
258 + ) {
194 259 return false;
195 260 }
196 261
197 262 // Wait for generate_attachment_metadata if the filter is set to true.
@@ -213,20 +278,15 @@
213 278 // Also, some images, particularly SVGs, don't create thumbnails but do have
214 279 // metadata for them. At the time `wp_get_missing_image_subsizes()` checks
215 280 // the saved metadata, it isn't there, but we already have it.
216 281 $handle_post_meta = function ($value, $object_id, $meta_key, $single, $meta_type) use ($attachment_id, $attachment_meta) {
217 - if (
218 - !empty($value['image_meta']['orientation']) ||
219 - !empty($attachment_meta['image_meta']['orientation'])
220 - ) {
221 - if (!empty($value['image_meta']['orientation'])) {
222 - unset($value['image_meta']['orientation']);
223 - }
224 - if (!empty($attachment_meta['image_meta']['orientation'])) {
225 - unset($attachment_meta['image_meta']['orientation']);
226 - }
227 - unset($value['original_image'], $attachment_meta['original_image']);
282 + if(is_array($attachment_meta) && isset($attachment_meta['image_meta']) && !empty($attachment_meta['image_meta']['orientation'])) {
283 + unset($attachment_meta['original_image']);
228 284 }
285 + if(is_array($value) && isset($value['image_meta']) && !empty($value['image_meta']['orientation'])) {
286 + unset($value['original_image']);
287 + }
288 +
229 289 if (
230 290 is_null($value) &&
231 291 $object_id === $attachment_id &&
232 292 '_wp_attachment_metadata' === $meta_key &&
@@ -231,8 +291,10 @@
231 291 $object_id === $attachment_id &&
232 292 '_wp_attachment_metadata' === $meta_key &&
233 293 $single && $meta_type === 'post'
234 294 ) {
295 + // For some reason the filter is expected return an array of values
296 + // as if not doing a single record.
235 297 return [$attachment_meta];
236 298 }
237 299 return $value;
238 300 };
@@ -269,8 +331,10 @@
269 331 $attachment_id,
270 332 $uploaded_data['file']['url'],
271 333 $uploaded_data['file']['key'],
272 334 $uploaded_data['file']['source_path'],
335 + $uploaded_data['file']['original_source_path'] ?? '',
336 + $uploaded_data['file']['original_key'] ?? '',
273 337 $uploaded_data['extra'],
274 338 'media_library'
275 339 );
276 340 }
@@ -287,9 +351,9 @@
287 351 private function handle_media_with_no_backup($attachment_meta, $attachment_id, $source_path, $existing) {
288 352 $uploaded_data = $this->uploadMedia($attachment_meta, $attachment_id, $source_path);
289 353
290 354 if ($existing['source_path'] != $source_path) {
291 - $uploaded_data['extra']['backup'] = serialize($existing);
355 + $uploaded_data['extra']['backup'] = Utils::maybe_serialize($existing);
292 356 }
293 357
294 358 $this->update_item_if_uploaded($attachment_id, $uploaded_data);
295 359 }
@@ -307,9 +371,9 @@
307 371 $delete_existing = false;
308 372
309 373 if ($backup['source_path'] != $source_path) {
310 374 $uploaded_data = $this->uploadMedia($attachment_meta, $attachment_id, $source_path);
311 - $uploaded_data['extra']['backup'] = maybe_serialize($backup);
375 + $uploaded_data['extra']['backup'] = Utils::maybe_serialize($backup);
312 376
313 377 if ($existing['source_path'] != $source_path) {
314 378 $delete_existing = true;
315 379 }
@@ -318,19 +382,21 @@
318 382
319 383 // construct upload data from backup
320 384 $uploaded_data = [
321 385 'file' => [
322 - 'source_path' => $backup['source_path'],
323 - 'url' => $backup['url'],
324 - 'key' => $backup['key'],
386 + 'source_path' => $backup['source_path'],
387 + 'url' => $backup['url'],
388 + 'key' => $backup['key'],
389 + 'original_source_path' => $backup['original_source_path'],
390 + 'original_key' => $backup['original_key'],
325 391 ],
326 - 'extra' => maybe_unserialize($backup['extra']),
392 + 'extra' => Utils::maybe_unserialize($backup['extra']),
327 393 ];
328 394 }
329 395
330 396 if ($delete_existing) {
331 397 Item::instance()->delete_attachments_by_item($existing, false);
332 - Item::instance()->may_be_delete_server_files_by_item($existing, true);
398 + Item::instance()->delete_server_files_by_item($existing, true);
333 399 }
334 400
335 401 $this->update_item_if_uploaded($attachment_id, $uploaded_data);
336 402 }
@@ -346,12 +412,14 @@
346 412 if (!empty($uploaded_data['file'])) {
347 413 Item::instance()->update(
348 414 $attachment_id,
349 415 [
350 - 'source_path' => $uploaded_data['file']['source_path'],
351 - 'url' => $uploaded_data['file']['url'],
352 - 'key' => $uploaded_data['file']['key'],
353 - 'extra' => maybe_serialize($uploaded_data['extra']),
416 + 'source_path' => $uploaded_data['file']['source_path'],
417 + 'url' => $uploaded_data['file']['url'],
418 + 'key' => $uploaded_data['file']['key'],
419 + 'original_source_path' => $uploaded_data['file']['original_source_path'] ?? '',
420 + 'original_key' => $uploaded_data['file']['original_key'] ?? '',
421 + 'extra' => Utils::maybe_serialize($uploaded_data['extra']),
354 422 ],
355 423 'media_library'
356 424 );
357 425 }
@@ -377,12 +445,8 @@
377 445 * @since 1.0.0
378 446 * @return string
379 447 */
380 448 private function filter_unique_filename($filename, $ext, $dir, $post_id = null) {
381 - if (!Utils::is_service_enabled()) {
382 - return $filename;
383 - }
384 -
385 449 // sanitize the file name before we begin processing
386 450 $filename = sanitize_file_name($filename);
387 451 $ext = strtolower($ext);
388 452 $name = wp_basename($filename, $ext);
@@ -497,23 +561,12 @@
497 561 if ( $this->deleting_attachment ) {
498 562 return $file;
499 563 }
500 564
501 - if (!Utils::is_ok_to_serve($attachment_id)) {
565 + if (!Utils::should_serve_from_cloud($attachment_id)) {
502 566 return $file;
503 567 }
504 568
505 - if( isset($_REQUEST['action']) && $_REQUEST['action'] === 'image-editor' ) {
506 - // Avoid rewriting URL when image restore in image editor
507 - if(isset($_REQUEST['do']) && $_REQUEST['do']==='restore') {
508 - return $file;
509 - }
510 - // Avoid rewriting URL when image save in image editor
511 - if(isset($_REQUEST['do']) && $_REQUEST['do'] === 'save') {
512 - return $file;
513 - }
514 - }
515 -
516 569 $wpmcsItem = Item::instance();
517 570
518 571 $item = $wpmcsItem->get($attachment_id, 'media_library');
519 572
@@ -557,49 +610,72 @@
557 610 */
558 611
559 612 public function wp_calculate_image_srcset($sources, $size_array, $image_src, $attachment_meta, $attachment_id = 0) {
560 613 $attachment_id = (int)$attachment_id;
614 + if ( ! is_array( $sources ) ) {
615 + // Sources corrupt
616 + return $sources;
617 + }
561 618
562 619 // Must need $attachment_id other wise not possible to get data from the table
563 - if (!Utils::is_ok_to_serve($attachment_id)) {
620 + if (!Utils::should_serve_from_cloud($attachment_id)) {
564 621 return $sources;
565 622 }
566 623
567 624 $wpmcsItem = Item::instance();
568 625
569 - $item = $wpmcsItem->get($attachment_id, 'media_library');
570 -
571 - if (Utils::is_empty($item)) {
626 + if (Utils::is_empty($wpmcsItem->get($attachment_id, 'media_library'))) {
572 627 return $sources;
573 628 }
574 629
575 - $item_extra = $wpmcsItem->get_extras($attachment_id, false, 'media_library');
630 + foreach ( $sources as $width => $source ) {
631 + $filename = wp_basename( $source['url'] );
632 + $size = $this->find_image_size_from_width( $attachment_meta, $width, $filename );
633 + $provider_url = $wpmcsItem->get_url( $attachment_id, $size, 'media_library' );
576 634
577 - if (isset($item_extra['width']) && !empty($item_extra['width'])) {
578 - $sources[$item_extra['width']]=[
579 - 'url' => $wpmcsItem->get_url($attachment_id, 'full', 'media_library'),
580 - 'descriptor' => 'w',
581 - 'value' => $item_extra['width']
582 - ];
583 - }
635 + if ( false === $provider_url || is_wp_error( $provider_url ) ) {
636 + // Skip URLs not uploaded to cloud
637 + continue;
638 + }
584 639
585 - if ($item_extra) {
586 - if (isset($item_extra['sizes']) && !empty($item_extra['sizes'])) {
587 - foreach ($item_extra['sizes'] as $size => $size_array) {
588 - if (isset($size_array['width']) && !empty($size_array['width'])) {
589 - $w = $size_array['width'];
590 - if (isset($sources[$w]) && !empty($sources[$w])) {
591 - $sources[$w]['url'] = $wpmcsItem->get_url($attachment_id, $size, 'media_library');
592 - }
593 - }
594 - }
595 - }
596 - }
597 -
640 + $sources[ $width ]['url'] = $provider_url;
641 + }
598 642 return $sources;
599 643 }
600 644
601 645 /**
646 + * Helper function to find size name from width and filename
647 + *
648 + * @param array $sizes
649 + * @param string $width
650 + * @param string $filename
651 + *
652 + * @return null|string
653 + */
654 + protected function find_image_size_from_width( $meta, $width, $filename ) {
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 + }
664 + }
665 + }
666 +
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 + }
673 +
674 + return null;
675 + }
676 +
677 + /**
602 678 * Filters the list of attachment image attributes.
603 679 *
604 680 * @since 1.0.0
605 681 * @param array $attr Attributes for the image markup.
@@ -609,10 +685,10 @@
609 685 * @return array
610 686 */
611 687 public function wp_get_attachment_image_attributes($attr, $attachment, $size='thumbnail') {
612 688 if (
613 - !$attachment ||
614 - !Utils::is_ok_to_serve($attachment->ID)
689 + !$attachment ||
690 + !Utils::should_serve_from_cloud($attachment->ID)
615 691 ) {
616 692 return $attr;
617 693 }
618 694
@@ -659,9 +735,9 @@
659 735 *
660 736 * @return bool|mixed|WP_Error
661 737 */
662 738 public function wp_get_attachment_url($url, $attachment_id) {
663 - if (!Utils::is_ok_to_serve($attachment_id)) {
739 + if (!Utils::should_serve_from_cloud($attachment_id)) {
664 740 return $url;
665 741 }
666 742
667 743 $new_url = Item::instance()->get_url($attachment_id, 'full', 'media_library');
@@ -675,172 +751,699 @@
675 751 return $new_url;
676 752 }
677 753
678 754 /**
755 + * Maybe replace attachment URLs when retrieving the image tag
756 + *
757 + * @param string $html
758 + * @param int $id
759 + * @param string $alt
760 + * @param string $title
761 + * @param string $align
762 + * @param string $size
763 + *
764 + * @return string
765 + */
766 + public function get_image_tag( $html, $id, $alt = '', $title = '', $align = '', $size = '' ) {
767 + if ( ! is_string( $html ) ) {
768 + return $html;
769 + }
770 +
771 + if (!Utils::should_serve_from_cloud($id)) {
772 + return $html;
773 + }
774 +
775 + preg_match( '@\ssrc=[\'\"]([^\'\"]*)[\'\"]@', $html, $matches );
776 +
777 + if ( ! isset( $matches[1] ) ) {
778 + // Can't establish img src
779 + return $html;
780 + }
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 +
790 + $img_src = $matches[1];
791 + $size = Utils::maybe_convert_size_to_string( $id, $size );
792 + $new_url = Item::instance()->get_url($id, $size, 'media_library');
793 +
794 + if (Utils::is_empty($new_url)) {
795 + return $html;
796 + }
797 +
798 + return str_replace( $img_src, $new_url, $html );
799 + }
800 +
801 +
802 + /**
803 + * Relace URLs for images that represent an attachment
804 + *
805 + * @param array|bool $image
806 + * @param int $attachment_id
807 + * @param string|array $size
808 + * @param bool $icon
809 + *
810 + * @return array
811 + */
812 + public function wp_get_attachment_image_src( $image, $attachment_id, $size, $icon ) {
813 + if (!Utils::should_serve_from_cloud($attachment_id)) {
814 + return $image;
815 + }
816 +
817 + if ( isset( $image[0] ) ) {
818 + $size = Utils::maybe_convert_size_to_string( $attachment_id, $size );
819 + $new_url = Item::instance()->get_url($attachment_id, $size, 'media_library');
820 +
821 + if (Utils::is_empty($new_url)) {
822 + return $image;
823 + }
824 +
825 + $image[0] = $new_url;
826 + }
827 +
828 + return $image;
829 + }
830 +
831 + /**
832 + * Replace URLs when outputting attachments in the media grid
833 + *
834 + * @param array $response
835 + * @param int|object $attachment
836 + * @param array $meta
837 + *
838 + * @return array
839 + */
840 + public function wp_prepare_attachment_for_js( $response, $attachment, $meta ) {
841 + if (!Utils::should_serve_from_cloud($attachment->ID)) {
842 + return $response;
843 + }
844 +
845 + if ( isset( $response['sizes'] ) && is_array( $response['sizes'] ) ) {
846 + foreach ( $response['sizes'] as $size => $value ) {
847 + $url = Item::instance()->get_url($attachment->ID, $size, 'media_library');
848 + if (Utils::is_empty($url)) {
849 + continue;
850 + }
851 + $response['sizes'][ $size ]['url'] = $url;
852 + }
853 + }
854 +
855 + return $response;
856 + }
857 +
858 +
859 + /**
860 + * Replace URLs when retrieving intermediate sizes.
861 + *
862 + * @param array $data
863 + * @param int $post_id
864 + * @param string|array $size
865 + *
866 + * @return array
867 + */
868 + public function image_get_intermediate_size( $data, $post_id, $size ) {
869 + if (!Utils::should_serve_from_cloud($post_id)) {
870 + return $data;
871 + }
872 + if ( isset( $data['url'] ) ) {
873 + $size = Utils::maybe_convert_size_to_string( $post_id, $size );
874 + $url = Item::instance()->get_url($post_id, $size, 'media_library');
875 +
876 + if (Utils::is_empty($url)) {
877 + return $data;
878 + }
879 +
880 + $data['url'] = $url;
881 + }
882 +
883 + return $data;
884 + }
885 +
886 + /**
887 + * Determines if the image metadata is for the image source file.
888 + *
889 + * @handles wp_image_file_matches_image_meta
890 + *
891 + * @param bool $match
892 + * @param string $image_location
893 + * @param array $image_meta
894 + * @param int $source_id
895 + *
896 + * @return bool
897 + */
898 + public function wp_image_file_matches_image_meta( $match, $image_location, $image_meta, $source_id ) {
899 + // If already matched or the URL is local, there's nothing for us to do.
900 + if ( $match || FilterContent::url_needs_replacing( $image_location ) ) {
901 + return $match;
902 + }
903 +
904 + $item = array(
905 + 'id' => $source_id,
906 + 'source_type' => 'media_library',
907 + );
908 +
909 + return FilterContent::instance()->item_matches_src( $item, $image_location );
910 + }
911 +
912 +
913 + /**
914 + * Filters shortcode attributes to temporarily add file extension to end of URL params.
915 + *
916 + * The temporary extension is removed once wp_check_filetype has been used.
917 + *
918 + * The function compensates for when query args or fragments are included in the URL,
919 + * which makes wp_check_filetype fail to see the extension of the file.
920 + *
921 + * @see https://core.trac.wordpress.org/ticket/30377
922 + * @see https://github.com/aaemnnosttv/fix-wp-media-shortcodes-with-params/blob/master/fix-wp-media-shortcodes-with-params.php
923 + *
924 + * @param array $out The output array of shortcode attributes.
925 + * @param array $pairs The supported attributes and their defaults.
926 + * @param array $atts The user defined shortcode attributes.
927 + * @param string $shortcode The shortcode name.
928 + *
929 + * @return array
930 + */
931 + public function filter_shortcode_atts( $out, $pairs, $atts, $shortcode ) {
932 + $get_media_extensions = "wp_get_{$shortcode}_extensions";
933 +
934 + if ( ! function_exists( $get_media_extensions ) ) {
935 + return $out;
936 + }
937 +
938 + $default_types = $get_media_extensions();
939 +
940 + if ( empty( $default_types ) || ! is_array( $default_types ) ) {
941 + return $out;
942 + }
943 +
944 + // URLs can be in src or type specific fallback attributes.
945 + array_unshift( $default_types, 'src' );
946 +
947 + $fixes = array();
948 +
949 + foreach ( $default_types as $type ) {
950 + if ( empty( $out[ $type ] ) ) {
951 + continue;
952 + }
953 +
954 + if ( false !== strpos( $out[ $type ], '&'.$this->token.'-fix-wp-check-file-type-ext=.' ) ) {
955 + continue;
956 + }
957 +
958 + if ( Utils::is_url( $out[ $type ] ) ) {
959 + $url = $out[ $type ];
960 + $parts = wp_parse_url( $url );
961 +
962 + if (
963 + empty( $parts['path'] ) ||
964 + ( empty( $parts['query'] ) && empty( $parts['fragment'] ) )
965 + ) {
966 + continue;
967 + }
968 +
969 + $ext = pathinfo( $parts['path'], PATHINFO_EXTENSION );
970 +
971 + if ( empty( $ext ) ) {
972 + continue;
973 + }
974 +
975 + $scheme = empty( $parts['scheme'] ) ? '' : $parts['scheme'] . '://';
976 + $user = empty( $parts['user'] ) ? '' : $parts['user'];
977 + $pass = ! empty( $user ) && ! empty( $parts['pass'] ) ? ':' . $parts['pass'] : '';
978 + $auth = ! empty( $user ) ? $user . $pass . '@' : '';
979 + $host = empty( $parts['host'] ) ? '' : $parts['host'];
980 + $port = ! empty( $host ) && ! empty( $parts['port'] ) ? ':' . $parts['port'] : '';
981 + $path = $parts['path'];
982 + $query = empty( $parts['query'] ) ? '?'.$this->token.'-fix-wp-check-file-type=true' : '?' . $parts['query'];
983 +
984 + if ( ! empty( $parts['fragment'] ) ) {
985 + $query .= '&'.$this->token.'-fix-wp-check-file-type-fragment=' . $parts['fragment'];
986 + }
987 +
988 + $query .= '&'.$this->token.'-fix-wp-check-file-type-ext=.' . $ext;
989 +
990 + $out[ $type ] = $scheme . $auth . $host . $port . $path . $query;
991 + $fixes[] = $ext;
992 + }
993 + }
994 +
995 + if ( $fixes ) {
996 + add_filter( "wp_{$shortcode}_shortcode", function ( $html ) use ( $fixes ) {
997 + $html = str_replace( '?'.$this->token.'-fix-wp-check-file-type=true', '', $html );
998 +
999 + foreach ( $fixes as $ext ) {
1000 + $html = str_replace( '&'.$this->token.'-fix-wp-check-file-type-ext=.' . $ext, '', $html );
1001 + }
1002 +
1003 + return str_replace( '&'.$this->token.'-fix-wp-check-file-type-fragment=', '#', $html );
1004 + } );
1005 + }
1006 +
1007 + return $out;
1008 + }
1009 +
1010 +
1011 + /**
679 1012 * Upload media item
680 1013 */
681 1014 public function uploadMedia($attachment_meta, $attachment_id, $source_path) {
682 - $wpmcsItem = Item::instance();
683 - $upload_dir = wp_get_upload_dir();
684 - $is_image = wp_attachment_is_image($attachment_id);
685 - $existing = $wpmcsItem->get($attachment_id, 'media_library');
686 - $existing_extras = $wpmcsItem->get_extras($attachment_id, false, 'media_library');
687 - $has_existing = !Utils::is_empty($existing);
688 - $sizes = [];
689 - $uploaded = [];
690 - $extras = [];
691 - $prefix = '';
692 - $file_path = '';
693 - $file_dir = '';
694 - $original_file_path = '';
695 - $original_file_source_path = '';
1015 + $wpmcsItem = Item::instance();
1016 + $upload_dir = wp_get_upload_dir();
1017 + $is_image = wp_attachment_is_image($attachment_id);
1018 + $existing = $wpmcsItem->get($attachment_id, 'media_library');
1019 + $existing_extras = $wpmcsItem->get_extras($attachment_id, false, 'media_library');
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']);
1024 + $sizes = [];
1025 + $uploaded = [];
1026 + $extras = [];
1027 + $prefix = '';
1028 + $file_path = trailingslashit($upload_dir['basedir']) . $source_path;
1029 + $file_dir = isset(pathinfo($file_path)['dirname']) ? pathinfo($file_path)['dirname'] : '';
1030 + $original_file = isset($attachment_meta['original_image']) ? $attachment_meta['original_image'] : '';
696 1031
1032 + $do_reupload = apply_filters('wpmcs_do_reupload_media', false, $attachment_id, 'media_library');
697 1033
1034 + // Force reupload when sizes/dimensions changed
1035 + if (!$do_reupload && $has_existing) {
1036 + $force_reupload = $this->shouldForceReupload($attachment_meta, $attachment_id, $existing_extras);
1037 + if ($force_reupload !== false) {
1038 + $do_reupload = $force_reupload;
1039 + }
1040 + }
1041 +
698 1042 // Add prefix if object versioning is ON
699 1043 if (isset($this->settings['object_versioning']) && $this->settings['object_versioning']) {
700 - $prefix = ($existing_extras && isset($existing_extras['prefix']) && !empty($existing_extras['prefix']))
701 - ? $existing_extras['prefix']
1044 + $prefix = ($has_existing && isset($existing_extras['prefix']) && !empty($existing_extras['prefix']))
1045 + ? $existing_extras['prefix']
702 1046 : Utils::generate_object_versioning_prefix();
703 - $extras['prefix'] = $prefix;
1047 + $extras['prefix'] = $prefix;
704 1048 }
705 1049
706 - // Get width and height from image meta
1050 + // Width/height
707 1051 if (isset($attachment_meta) && !empty($attachment_meta)) {
708 1052 $extras['width'] = (isset($attachment_meta['width']) && !empty($attachment_meta['width'])) ? $attachment_meta['width'] : 0;
709 1053 $extras['height'] = (isset($attachment_meta['height']) && !empty($attachment_meta['height'])) ? $attachment_meta['height'] : 0;
710 1054 }
711 1055
712 -
713 - // Get Original File Name/Path from Image meta
714 - $original_file = isset($attachment_meta) && !empty($attachment_meta) &&
715 - isset($attachment_meta['original_image']) && !empty($attachment_meta['original_image'])
716 - ? $attachment_meta['original_image'] : '';
717 -
718 - $file_path = trailingslashit($upload_dir['basedir']) . $source_path;
719 - $file_dir = isset(pathinfo($file_path)['dirname']) ? pathinfo($file_path)['dirname'] : '';
720 -
721 - // Check whether the extension is enabled for uploading
1056 + // Excluded by extension settings — an intentional skip, not a sync failure, so no log entry.
722 1057 if (!Utils::is_extension_available($file_path)) {
723 - return $attachment_meta;
1058 + return false;
724 1059 }
725 1060
726 - // Upload the Full Size file
727 - if( $has_existing && ( $existing['source_path'] == $source_path ) ) {
728 - $uploaded = [
729 - 'success' => true,
730 - 'file_url' => $existing['url'],
731 - 'key' => $existing['key']
732 - ];
733 - } else if(file_exists($file_path)){
734 - $uploaded = Service::instance()->uploadSingle($file_path, $source_path, $prefix);
735 - }
1061 + // Upload main file first, if that fails no need to attempt the rest
1062 + $uploaded = $this->uploadFullFile($file_path, $source_path, $do_reupload, $has_existing, $existing, $prefix, $attachment_id, $is_private);
736 1063
737 - if(
738 - isset($uploaded) && !empty($uploaded) &&
739 - isset($uploaded['success']) && $uploaded['success']
740 - ) {
741 - if(isset($original_file) && !empty($original_file)){
742 - // Find original image relative and absolute path
743 - $original_file_path = trailingslashit($file_dir) . $original_file;
744 - $original_file_source_path = Utils::get_attachment_source_path($original_file_path);
745 - $uploaded_original = [];
1064 + if ($uploaded && isset($uploaded['success']) && $uploaded['success']) {
1065 + // Attempt to upload original file if present. Not critical if this fails so we don't check the result before proceeding.
1066 + $original = $this->uploadOriginalFile($original_file, $file_dir, $do_reupload, $has_existing, $existing, $prefix, $attachment_id, $is_private);
1067 + $additional_files = [];
746 1068
747 - // Upload Original File
748 - if(
749 - !Utils::is_empty($existing_extras) &&
750 - isset($existing_extras['original']) && !Utils::is_empty($existing_extras['original']) &&
751 - $existing_extras['original']['source_path'] == $original_file_source_path
752 - ) {
753 - $uploaded_original = [
754 - 'success' => true,
755 - 'file_url' => $existing_extras['original']['url'],
756 - 'key' => $existing_extras['original']['key']
757 - ];
758 - } else if(file_exists($original_file_path)) {
759 - $uploaded_original = Service::instance()->uploadSingle($original_file_path, $original_file_source_path, $prefix);
1069 + if ($is_image && isset($file_dir) && !empty($file_dir)) {
1070 + $sizes_to_upload = $this->get_attachment_image_sizes_for_upload($attachment_meta, $attachment_id);
1071 + if (!empty($sizes_to_upload)) {
1072 + // Upload image sizes. Again, not critical if this fails so we don't check the result before proceeding.
1073 + $sizes = $this->uploadImageSizes($sizes_to_upload, $file_dir, $do_reupload, $existing_extras, $prefix, $attachment_id, $is_private);
760 1074 }
761 -
762 - if(
763 - isset($uploaded_original) && !empty($uploaded_original) &&
764 - isset($uploaded_original['success']) && $uploaded_original['success']
765 - ) {
766 - $extras['original'] = array(
767 - 'source_path' => $original_file_source_path,
768 - 'url' => $uploaded_original['file_url'],
769 - 'key' => $uploaded_original['key']
770 - );
771 - }
1075 + $additional_files = $this->uploadAdditionalFiles($attachment_meta, $file_dir, $do_reupload, $has_existing, $existing_extras, $prefix, $attachment_id, $is_private);
772 1076 }
773 1077
774 - if (
775 - $is_image &&
776 - isset($attachment_meta['sizes']) && !empty($attachment_meta['sizes']) &&
777 - isset($file_dir) && !empty($file_dir)
778 - ) {
779 - foreach ($attachment_meta['sizes'] as $size => $sub_image) {
780 - $sub_size = [];
781 - $sub_file = isset($sub_image['file']) ? $sub_image['file'] : false;
782 - $sub_file_path = '';
783 - $sub_file_source_path = '';
784 - $uploaded_sub_image = [];
785 -
786 - if ($sub_file) {
787 - $sub_file_path = $file_dir . '/' . $sub_file;
788 - $sub_file_source_path = Utils::get_attachment_source_path($sub_file_path);
789 - }
1078 + if (!empty($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 + : [];
790 1085
791 - // Upload Image size
792 - if(
793 - !Utils::is_empty($existing_extras) &&
794 - isset($existing_extras['sizes']) && !Utils::is_empty($existing_extras['sizes']) &&
795 - isset($existing_extras['sizes'][$size]) && !Utils::is_empty($existing_extras['sizes'][$size]) &&
796 - $existing_extras['sizes'][$size]['source_path'] == $sub_file_source_path
797 - ) {
798 - $uploaded_sub_image = [
799 - 'success' => true,
800 - 'file_url' => $existing_extras['sizes'][$size]['url'],
801 - 'key' => $existing_extras['sizes'][$size]['key']
802 - ];
803 - } else if(file_exists($sub_file_path)) {
804 - $uploaded_sub_image = Service::instance()->uploadSingle($sub_file_path, $sub_file_source_path, $prefix);
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]);
805 1089 }
1090 + }
806 1091
807 - if (
808 - isset($uploaded_sub_image) && !empty($uploaded_sub_image) &&
809 - isset($uploaded_sub_image['success']) && $uploaded_sub_image['success']
810 - ) {
811 - $sub_size['source_path'] = $sub_file_source_path;
812 - $sub_size['url'] = $uploaded_sub_image['file_url'];
813 - $sub_size['key'] = $uploaded_sub_image['key'];
814 - $sub_size['width'] = isset($sub_image['width']) ? $sub_image['width']: 0;
815 - $sub_size['height'] = isset($sub_image['height']) ? $sub_image['height']: 0;
816 - }
817 -
818 - $sizes[$size] = $sub_size;
819 - }
1092 + $extras['sizes'] = array_merge($existing_sizes, $sizes);
820 1093 }
821 -
822 -
823 - if (isset($sizes) && !empty($sizes)) {
824 - $extras['sizes'] = $sizes;
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);
825 1100 }
826 1101
827 1102 return [
828 1103 'file' => [
829 - 'source_path' => $source_path,
830 - 'url' => $uploaded['file_url'],
831 - 'key' => $uploaded['key'],
1104 + 'source_path' => $source_path,
1105 + 'url' => $uploaded['file_url'],
1106 + 'key' => $uploaded['key'],
1107 + 'original_source_path' => isset($original['source_path']) ? $original['source_path'] : '',
1108 + 'original_key' => isset($original['key']) ? $original['key'] : ''
832 1109 ],
833 1110 'extra' => $extras
834 1111 ];
1112 + }
835 1113
1114 + if (isset($uploaded['success']) && !$uploaded['success']) {
1115 + // Prefer the service's own error message/code when it provided one.
1116 + Logger::instance()->add_log('sync_to_cloud', $attachment_id, 'media_library', [
1117 + 'message' => $uploaded['message'] ?? __('File upload failed', 'media-cloud-sync'),
1118 + 'file' => $file_path,
1119 + 'code' => $uploaded['code'] ?? 500
1120 + ]);
836 1121 }
837 1122
838 1123 return false;
839 1124 }
840 1125
1126 + /**
1127 + * Image subsizes to sync to cloud (after filters). Empty means no subsizes are uploaded.
1128 + *
1129 + * `wpmcs_skip_upload_image_sizes` — (bool) When true, no subsizes are uploaded.
1130 + * `wpmcs_upload_attachment_image_sizes` — (array) Subset of `$attachment_meta['sizes']` to upload; empty skips all.
1131 + *
1132 + * @param array $attachment_meta Full attachment metadata.
1133 + * @param int $attachment_id Attachment post ID.
1134 + * @return array Same shape as attachment meta `sizes` entries.
1135 + */
1136 + private function get_attachment_image_sizes_for_upload($attachment_meta, $attachment_id) {
1137 + $sizes = [];
1138 + if (isset($attachment_meta['sizes']) && is_array($attachment_meta['sizes'])) {
1139 + $sizes = $attachment_meta['sizes'];
1140 + }
841 1141
1142 + if (apply_filters('wpmcs_skip_upload_image_sizes', false, $attachment_id, $attachment_meta)) {
1143 + return [];
1144 + }
1145 +
1146 + $sizes = apply_filters('wpmcs_upload_attachment_image_sizes', $sizes, $attachment_id, $attachment_meta);
1147 +
1148 + return is_array($sizes) ? $sizes : [];
1149 + }
1150 +
842 1151 /**
1152 + * Decide if we should force a reupload based on dims/sizes differences.
1153 + * @since 1.3.6
1154 + * @param array $attachment_meta
1155 + * @param int $attachment_id
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.
1159 + */
1160 + private function shouldForceReupload($attachment_meta, $attachment_id, $existing_extras) {
1161 + $existing_extras = $existing_extras ?? Item::instance()->get_extras($attachment_id, false, 'media_library');
1162 +
1163 + $current_width = isset($attachment_meta['width']) ? (int)$attachment_meta['width'] : 0;
1164 + $current_height = isset($attachment_meta['height']) ? (int)$attachment_meta['height'] : 0;
1165 +
1166 + $existing_width = isset($existing_extras['width']) ? (int)$existing_extras['width'] : 0;
1167 + $existing_height = isset($existing_extras['height']) ? (int)$existing_extras['height'] : 0;
1168 +
1169 + if ($current_width !== $existing_width || $current_height !== $existing_height) {
1170 + return true;
1171 + }
1172 +
1173 + $filtered_sizes = $this->get_attachment_image_sizes_for_upload($attachment_meta, $attachment_id);
1174 + if (empty($filtered_sizes)) {
1175 + return false;
1176 + }
1177 +
1178 + $existing_sizes = isset($existing_extras['sizes']) && is_array($existing_extras['sizes']) ? $existing_extras['sizes'] : [];
1179 + $current_sizes = [];
1180 + foreach ($filtered_sizes as $sname => $smeta) {
1181 + $current_sizes[$sname] = [
1182 + 'width' => isset($smeta['width']) ? (int)$smeta['width'] : 0,
1183 + 'height' => isset($smeta['height']) ? (int)$smeta['height'] : 0,
1184 + ];
1185 + }
1186 +
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 = [];
1192 + foreach ($current_sizes as $sname => $dims) {
1193 + if (!isset($existing_sizes[$sname])) {
1194 + $changed_sizes[] = $sname;
1195 + continue;
1196 + }
1197 + $ex_w = isset($existing_sizes[$sname]['width']) ? (int)$existing_sizes[$sname]['width'] : 0;
1198 + $ex_h = isset($existing_sizes[$sname]['height']) ? (int)$existing_sizes[$sname]['height'] : 0;
1199 + if ($ex_w !== $dims['width'] || $ex_h !== $dims['height']) {
1200 + $changed_sizes[] = $sname;
1201 + }
1202 + }
1203 +
1204 + return empty($changed_sizes) ? false : $changed_sizes;
1205 + }
1206 +
1207 + /**
1208 + * Upload the main/full file.
1209 + * @since 1.3.6
1210 + * @param string $file_path
1211 + * @param string $source_path
1212 + * @param bool|array $do_reupload Reupload everything (true), nothing (false), or only these sizes (array of size names).
1213 + * @param bool $has_existing
1214 + * @param array $existing
1215 + * @param string $prefix
1216 + * @param int $attachment_id
1217 + * @param bool $is_private Keeps a reupload of an already-private item under the private path.
1218 + */
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)) {
1223 + return [
1224 + 'success' => true,
1225 + 'file_url' => $existing['url'],
1226 + 'key' => $existing['key']
1227 + ];
1228 + }
1229 +
1230 + if (file_exists($file_path)) {
1231 + return Service::instance()->uploadSingle($file_path, $source_path, $prefix, $is_private);
1232 + }
1233 +
1234 + Logger::instance()->add_log('sync_to_cloud', $attachment_id, 'media_library', [
1235 + 'message' => __('File not found', 'media-cloud-sync'),
1236 + 'file' => $file_path,
1237 + 'code' => 404
1238 + ]);
1239 +
1240 + return false;
1241 + }
1242 +
1243 + /**
1244 + * Upload original file when present.
1245 + * @since 1.3.6
1246 + * @param string $original_file
1247 + * @param string $file_dir
1248 + * @param bool|array $do_reupload Reupload everything (true), nothing (false), or only these sizes (array of size names).
1249 + * @param bool $has_existing
1250 + * @param array $existing
1251 + * @param string $prefix
1252 + * @param int $attachment_id
1253 + * @param bool $is_private Keeps a reupload of an already-private item under the private path.
1254 + */
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 [];
1283 + }
1284 +
1285 + $file_path = trailingslashit($file_dir) . $filename;
1286 + $source_path = Utils::get_attachment_source_path($file_path);
1287 +
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 ];
1292 + }
1293 +
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'] ];
1298 + }
1299 +
1300 + if (isset($uploaded['success']) && !$uploaded['success']) {
1301 + Logger::instance()->add_log('sync_to_cloud', $attachment_id, 'media_library', [
1302 + 'message' => $uploaded['message'] ?? $not_found_message,
1303 + 'file' => $file_path,
1304 + 'code' => $uploaded['code'] ?? 500
1305 + ]);
1306 + }
1307 + } else {
1308 + Logger::instance()->add_log('sync_to_cloud', $attachment_id, 'media_library', [
1309 + 'message' => $not_found_message,
1310 + 'file' => $file_path,
1311 + 'code' => 404
1312 + ]);
1313 + }
1314 +
1315 + return [];
1316 + }
1317 +
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 + /**
1361 + * Upload intermediate image sizes.
1362 + * @since 1.3.6
1363 + * @param array $attachment_sizes
1364 + * @param string $file_dir
1365 + * @param bool|array $do_reupload Reupload everything (true), nothing (false), or only these sizes (array of size names).
1366 + * @param array $existing_extras
1367 + * @param string $prefix
1368 + * @param int $attachment_id
1369 + * @param bool $is_private Keeps a reupload of an already-private item under the private path.
1370 + * @return array
1371 + */
1372 + private function uploadImageSizes($attachment_sizes, $file_dir, $do_reupload, $existing_extras, $prefix, $attachment_id, $is_private = false) {
1373 + $sizes = [];
1374 +
1375 + foreach ($attachment_sizes as $size => $sub_image) {
1376 + $sub_size = [];
1377 + $sub_file = isset($sub_image['file']) ? $sub_image['file'] : false;
1378 + $sub_file_path = '';
1379 + $sub_file_source_path = '';
1380 +
1381 + if ($sub_file) {
1382 + $sub_file_path = $file_dir . '/' . $sub_file;
1383 + $sub_file_source_path = Utils::get_attachment_source_path($sub_file_path);
1384 + }
1385 +
1386 + $uploaded_sub_image = [];
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 +
1392 + if (
1393 + !$force_this_size &&
1394 + !Utils::is_empty($existing_extras) &&
1395 + isset($existing_extras['sizes']) && !Utils::is_empty($existing_extras['sizes']) &&
1396 + isset($existing_extras['sizes'][$size]) && !Utils::is_empty($existing_extras['sizes'][$size]) &&
1397 + $existing_extras['sizes'][$size]['source_path'] == $sub_file_source_path
1398 + ) {
1399 + $uploaded_sub_image = [
1400 + 'success' => true,
1401 + 'file_url' => $existing_extras['sizes'][$size]['url'],
1402 + 'key' => $existing_extras['sizes'][$size]['key']
1403 + ];
1404 + } else if (file_exists($sub_file_path)) {
1405 + $uploaded_sub_image = Service::instance()->uploadSingle($sub_file_path, $sub_file_source_path, $prefix, $is_private);
1406 + } else {
1407 + Logger::instance()->add_log('sync_to_cloud', $attachment_id, 'media_library', [
1408 + /* translators: %1$s: Image size name (e.g., thumbnail, medium, large). */
1409 + 'message' => sprintf(__('Image size %1$s not found', 'media-cloud-sync'), $size),
1410 + 'file' => $sub_file_path,
1411 + 'code' => 404
1412 + ]);
1413 + }
1414 +
1415 + if (isset($uploaded_sub_image) && !empty($uploaded_sub_image) && isset($uploaded_sub_image['success']) && $uploaded_sub_image['success']) {
1416 + $sub_size['source_path'] = $sub_file_source_path;
1417 + $sub_size['url'] = $uploaded_sub_image['file_url'];
1418 + $sub_size['key'] = $uploaded_sub_image['key'];
1419 + $sub_size['width'] = isset($sub_image['width']) ? $sub_image['width'] : 0;
1420 + $sub_size['height'] = isset($sub_image['height']) ? $sub_image['height'] : 0;
1421 + } else {
1422 + if (isset($uploaded_sub_image['success']) && !$uploaded_sub_image['success']) {
1423 + Logger::instance()->add_log('sync_to_cloud', $attachment_id, 'media_library', [
1424 + /* translators: %1$s: Image size name (e.g., thumbnail, medium, large). */
1425 + 'message' => sprintf(__('Image size %1$s upload failed', 'media-cloud-sync'), $size),
1426 + 'file' => $sub_file_path,
1427 + 'code' => 500
1428 + ]);
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 + }
1436 + }
1437 +
1438 + $sizes[$size] = $sub_size;
1439 + }
1440 +
1441 + return $sizes;
1442 + }
1443 +
1444 +
1445 + /**
843 1446 * Get Total Media Count (queried)
844 1447 *
845 1448 * Param added because need a generic function name,
846 1449 * if the function calls by source_type
@@ -861,8 +1464,129 @@
861 1464 }
862 1465
863 1466
864 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 + /**
1485 + * Upload pending media files.
1486 + *
1487 + * Processes media attachments that are pending and haven't been synced yet.
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 + *
1494 + * @param string $source_type Source type for identifying which source..
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.
1498 + */
1499 + public function upload_pending_media( $source_type, $limit = 50, $after_id = 0 ) {
1500 + global $wpdb;
1501 +
1502 + $failed = 0;
1503 +
1504 + $source_type_data = self::$source_types[ $source_type ] ?? false;
1505 + if ( ! $source_type_data ) {
1506 + return [ 'success' => true, 'failed' => $limit, 'last_id' => $after_id ];
1507 + }
1508 +
1509 + $posts = $wpdb->prefix . $source_type_data['table'];
1510 + $items = Db::get_table_name();
1511 +
1512 + $join = "
1513 + items.source_id = posts.ID
1514 + AND items.source_type = %s
1515 + AND items.provider = %s
1516 + AND items.storage = %s
1517 + ";
1518 +
1519 + $params = [
1520 + $source_type,
1521 + $this->service,
1522 + $this->bucket_name,
1523 + ];
1524 +
1525 + if ( ! empty( $this->region ) ) {
1526 + $join .= " AND items.region = %s";
1527 + $params[] = $this->region;
1528 + }
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 +
1536 + $sql = "
1537 + SELECT posts.ID
1538 + FROM {$posts} AS posts
1539 + LEFT JOIN {$items} AS items USE INDEX (idx_item_lookup)
1540 + ON {$join}
1541 + WHERE posts.post_type = 'attachment'
1542 + AND items.source_id IS NULL
1543 + {$after_id_sql}
1544 + ORDER BY posts.ID ASC
1545 + LIMIT %d
1546 + ";
1547 +
1548 + $params[] = (int) $limit;
1549 +
1550 + $rows = $wpdb->get_results( $wpdb->prepare( $sql, $params ) );
1551 +
1552 + if ( empty( $rows ) ) {
1553 + return [ 'success' => true, 'failed' => 0, 'last_id' => $after_id ];
1554 + }
1555 +
1556 + $last_id = $after_id;
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 +
1568 + try {
1569 + $this->upload_single_media( $attachment_id, $source_type );
1570 + } catch ( \Throwable $e ) {
1571 + $failed++;
1572 + continue;
1573 + }
1574 +
1575 + if ( ! Item::instance()->get( $attachment_id, $source_type ) ) {
1576 + $failed++;
1577 + }
1578 + }
1579 +
1580 + return [
1581 + 'success' => true,
1582 + 'failed' => $failed,
1583 + 'last_id' => $last_id,
1584 + ];
1585 + }
1586 +
1587 +
1588 + /**
865 1589 * Edit Image Meta In View Image
866 1590 * @since 1.0.0
867 1591 */
868 1592 function attachment_submitbox_metadata( ) {
@@ -884,9 +1608,9 @@
884 1608 $provider = $wpmcsItem->get_field($attachment_id, 'provider', 'media_library');
885 1609 if($provider) {
886 1610 $label = Schema::getServiceLabels($provider); ?>
887 1611 <div class="misc-pub-section misc-pub-provider">
888 - <?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>
889 1613 </div>
890 1614 <?php
891 1615 }
892 1616
@@ -892,9 +1616,9 @@
892 1616
893 1617 $region = $wpmcsItem->get_field($attachment_id, 'region', 'media_library');
894 1618 if($region) { ?>
895 1619 <div class="misc-pub-section misc-pub-provider">
896 - <?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>
897 1621 </div>
898 1622 <?php
899 1623 }
900 1624 $private = (int)$wpmcsItem->get_field($attachment_id, 'is_private', 'media_library');
@@ -899,9 +1623,9 @@
899 1623 }
900 1624 $private = (int)$wpmcsItem->get_field($attachment_id, 'is_private', 'media_library');
901 1625 ?>
902 1626 <div class="misc-pub-section misc-pub-provider">
903 - <?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>
904 1628 </div>
905 1629 <?php
906 1630 }
907 1631
@@ -907,8 +1631,10 @@
907 1631
908 1632
909 1633 /**
910 1634 * Function to get attachment details by ID
1635 + *
1636 + * @since 1.3.12 Added capability checks for attachment access.
911 1637 */
912 1638 public function ajax_get_attachment_details() {
913 1639 $result = array(
914 1640 'status' => false,
@@ -915,8 +1641,12 @@
915 1641 'data' => array(),
916 1642 'exclude' => false
917 1643 );
918 1644
1645 + if(!Utils::is_service_enabled()) {
1646 + wp_send_json_success( $result );
1647 + }
1648 +
919 1649 if ( ! isset( $_POST['id'] ) ) {
920 1650 wp_send_json_success( $result );
921 1651 }
922 1652
@@ -921,9 +1651,18 @@
921 1651 }
922 1652
923 1653 check_ajax_referer( 'get_media_provider_details', '_nonce' );
924 1654
925 - $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 + }
926 1665
927 1666 // Return if extension not allowed
928 1667 $path = get_attached_file( $id );
929 1668 if(!Utils::is_extension_available($path)) {
@@ -951,9 +1690,9 @@
951 1690 $region = $wpmcsItem->get_field($id, 'region', 'media_library');
952 1691 if($region){
953 1692 $item['region'] = $region;
954 1693 }
955 - $item['private'] = $wpmcsItem->get_field($id, 'private', 'media_library');
1694 + $item['private'] = (int) $wpmcsItem->get_field($id, 'is_private', 'media_library');
956 1695 if($item) {
957 1696 $result= array(
958 1697 'status' => true,
959 1698 'data' => $item
@@ -988,8 +1727,41 @@
988 1727 * Note: delete_post is used as there is a potential that deleted_post is not reached.
989 1728 */
990 1729 public function delete_post() {
991 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' );
1749 + }
1750 +
1751 + /**
1752 + * Has WP Core fixed wp_check_filetype when URL has params yet?
1753 + *
1754 + * @see https://core.trac.wordpress.org/ticket/30377
1755 + * @see https://github.com/aaemnnosttv/fix-wp-media-shortcodes-with-params/blob/master/fix-wp-media-shortcodes-with-params.php
1756 + *
1757 + * @return bool
1758 + */
1759 + public static function wp_check_filetype_broken() {
1760 + $normal_file = wp_check_filetype( 'file.mp4', array( 'mp4' => 'video/mp4' ) );
1761 + $querys_file = wp_check_filetype( 'file.mp4?param=1', array( 'mp4' => 'video/mp4' ) );
1762 +
1763 + return $normal_file !== $querys_file;
992 1764 }
993 1765
994 1766 /**
995 1767 * Is installed?