PluginProbe
Media Cloud Sync / 1.4.1
Media Cloud Sync v1.4.1
1.4.1 1.4.0 1.3.12 1.3.11 1.3.10 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.2.0 1.2.10 1.2.11 1.2.12 1.2.13 1.2.2 1.2.3 1.2.4 1.2.5 1.2.6 1.2.7 1.2.8 1.2.9 All 35 releases
media-cloud-sync / includes / integrations / media-library.php

media-library.php in Media Cloud Sync 1.4.1, at includes/integrations/media-library.php

1,711 lines 61.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace Dudlewebs\WPMCS;
3
4 defined('ABSPATH') || exit;
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 */
16 class MediaLibrary {
17 private static $instance = null;
18 private $assets_url;
19 private $version;
20 private $token;
21
22 protected $config;
23 protected $bucketConfig;
24 protected $settings;
25 protected $credentials;
26 protected $service;
27
28 protected $bucket_name;
29 protected $region = '';
30
31 private $deleting_attachment = false;
32
33
34 public static $source_type_prefix = "media";
35
36 public static function get_label() {
37 return __('Media Library', 'media-cloud-sync');
38 }
39
40 // Map which meta to be updated
41 public static $source_types = [
42 "media_library" => [
43 'table' => 'posts',
44 'class' => 'MediaLibrary'
45 ]
46 ];
47
48 /**
49 * Admin constructor.
50 * @since 1.0.0
51 */
52 public function __construct() {
53 $this->assets_url = WPMCS_ASSETS_URL;
54 $this->version = WPMCS_VERSION;
55 $this->token = WPMCS_TOKEN;
56 $this->settings = Utils::get_settings();
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
76 // Initialize setup
77 $this->registerActions();
78 }
79
80 /**
81 * Register integration access
82 */
83 public function registerActions() {
84 /** IMAGE EDITOR COMPATIBILITY */
85 add_action( 'attachment_submitbox_misc_actions', array( $this, 'attachment_submitbox_metadata'),99 );
86 //Media Modal Ajax
87 add_action( 'wp_ajax_wpmcs_get_attachment_details', array( $this, 'ajax_get_attachment_details' ) );
88
89 /** URL RE-WRITING HOOKS */
90 add_filter( 'wp_get_attachment_url', [ $this, 'wp_get_attachment_url' ], 99, 2 );
91 add_filter( 'wp_get_attachment_image_attributes', [ $this, 'wp_get_attachment_image_attributes' ], 99, 3 );
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 );
96 add_filter( 'get_attached_file', [ $this, 'get_attached_file' ], 10, 2 );
97 add_filter( 'wp_get_original_image_path', [ $this, 'get_attached_file' ], 10, 2 );
98 add_filter( 'wp_video_shortcode', [ $this, 'wp_media_shortcode' ], 100, 5 );
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 );
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
114 /** FILE MANAGEMENT HOOKS */
115 add_filter( 'wp_unique_filename', [ $this, 'wp_unique_filename' ], 10, 3 );
116 add_filter( 'wp_update_attachment_metadata', [ $this, 'update_attachment_metadata' ], 110, 2 );
117 add_filter( 'pre_delete_attachment', [ $this, 'pre_delete_attachment' ], 20 );
118 add_filter( 'delete_attachment', [ $this, 'delete_attachment' ], 20 );
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 );
122 add_filter( 'update_attached_file', [ $this, 'update_attached_file' ], 100, 2 );
123
124 add_action( 'wpmcs_do_update_attachment_metadata', [ $this, 'update_attachment_metadata' ], 10, 2 );
125 }
126
127
128 /**
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.
137 * @since 1.0.0
138 */
139 public function upload_single_media($id, $source_type = 'media_library') {
140 do_action( 'wpmcs_do_update_attachment_metadata', false, $id );
141 }
142
143
144 /**
145 * Allow processes to update the file on provider via update_attached_file()
146 * @since 1.0.0
147 * @param string $file
148 * @param int $attachment_id
149 *
150 * @return string
151 */
152 public function update_attached_file($file, $attachment_id) {
153 if( !Utils::is_ok_to_upload($attachment_id) ) {
154 return $file;
155 }
156
157 $item = Item::instance()->get($attachment_id, 'media_library');
158
159 if (Utils::is_empty($item)) {
160 return $file;
161 }
162
163 $file = apply_filters('wpmcs_update_attached_file', $file, $attachment_id, $item);
164
165 return $file;
166 }
167
168
169 /**
170 * Function to remove data from provider by attachment ID
171 * @since 1.0.0
172 *
173 */
174 public function delete_attachment($attachment_id){
175 $wpmcsItem = Item::instance();
176 $item = $wpmcsItem->get($attachment_id, 'media_library');
177
178 if (Utils::is_empty($item)) {
179 // Remove Log if exists
180 Logger::instance()->remove_log('sync_to_cloud', $attachment_id, 'media_library');
181 return $attachment_id;
182 }
183
184 $wpmcsItem->delete($attachment_id, 'media_library');
185
186 return $attachment_id;
187 }
188
189 /**
190 * Function to execute on wp_update_attachment_metadata
191 * @since 1.0.0
192 * @param array $attachment_meta
193 * @param int $attachment_id
194 * @return array|WP_Error
195 */
196 public function update_attachment_metadata($attachment_meta, $attachment_id) {
197 // Remove Logs
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.
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);
209
210 if (!Utils::is_ok_to_upload($attachment_id) || is_wp_error($attachment_meta)) {
211 return $attachment_meta;
212 }
213
214 $attachment_id = (int) $attachment_id;
215 $is_image = wp_attachment_is_image($attachment_id);
216
217 if ($is_image && $this->should_wait_for_subsizes($attachment_meta, $attachment_id)) {
218 return $attachment_meta;
219 }
220
221 $file = $this->get_attachment_file($attachment_meta, $attachment_id);
222 $source_path = Utils::get_attachment_source_path($file);
223
224 if (empty($source_path) || !is_string($source_path)) {
225 return $attachment_meta;
226 }
227
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;
247 }
248 }
249
250 /**
251 * Checks if we should wait for WordPress to generate subsizes.
252 */
253 private function should_wait_for_subsizes($attachment_meta, $attachment_id) {
254 if (
255 empty($attachment_meta) ||
256 !function_exists('wp_get_registered_image_subsizes') ||
257 !function_exists('wp_get_missing_image_subsizes')
258 ) {
259 return false;
260 }
261
262 // Wait for generate_attachment_metadata if the filter is set to true.
263 if (apply_filters('wpmcs_wait_for_generate_attachment_metadata', false)) {
264 return true;
265 }
266
267 // Check if there are any missing image subsizes.
268 $image_sizes = apply_filters(
269 'intermediate_image_sizes_advanced',
270 wp_get_registered_image_subsizes(),
271 $attachment_meta,
272 $attachment_id
273 );
274
275 // If an image has been rotated, remove original image from metadata so that
276 // `wp_get_missing_image_subsizes()` doesn't use non-rotated image for
277 // generating missing thumbnail sizes.
278 // Also, some images, particularly SVGs, don't create thumbnails but do have
279 // metadata for them. At the time `wp_get_missing_image_subsizes()` checks
280 // the saved metadata, it isn't there, but we already have it.
281 $handle_post_meta = function ($value, $object_id, $meta_key, $single, $meta_type) use ($attachment_id, $attachment_meta) {
282 if(is_array($attachment_meta) && isset($attachment_meta['image_meta']) && !empty($attachment_meta['image_meta']['orientation'])) {
283 unset($attachment_meta['original_image']);
284 }
285 if(is_array($value) && isset($value['image_meta']) && !empty($value['image_meta']['orientation'])) {
286 unset($value['original_image']);
287 }
288
289 if (
290 is_null($value) &&
291 $object_id === $attachment_id &&
292 '_wp_attachment_metadata' === $meta_key &&
293 $single && $meta_type === 'post'
294 ) {
295 // For some reason the filter is expected return an array of values
296 // as if not doing a single record.
297 return [$attachment_meta];
298 }
299 return $value;
300 };
301
302 add_filter('get_post_metadata', $handle_post_meta, 10, 5);
303 $missing_sizes = wp_get_missing_image_subsizes($attachment_id);
304 remove_filter('get_post_metadata', $handle_post_meta);
305
306 return !empty(array_intersect_key($missing_sizes, $image_sizes));
307 }
308
309 /**
310 * Gets the attachment file path.
311 */
312 private function get_attachment_file($attachment_meta, $attachment_id) {
313 $file = $attachment_meta['file'] ?? Utils::get_post_meta($attachment_id, '_wp_attached_file', true);
314 if ($file === basename($file)) {
315 $file = Utils::get_post_meta($attachment_id, '_wp_attached_file', true);
316 }
317 return $file;
318 }
319
320 /**
321 * Handles first upload.
322 * @since 1.2.13
323 * @param array $attachment_meta
324 * @param int $attachment_id
325 * @param string $source_path
326 */
327 private function handle_media_first_upload($attachment_meta, $attachment_id, $source_path) {
328 $uploaded_data = $this->uploadMedia($attachment_meta, $attachment_id, $source_path);
329 if ( $uploaded_data && isset($uploaded_data['file']) && !empty($uploaded_data['file'])) {
330 Item::instance()->add(
331 $attachment_id,
332 $uploaded_data['file']['url'],
333 $uploaded_data['file']['key'],
334 $uploaded_data['file']['source_path'],
335 $uploaded_data['file']['original_source_path'] ?? '',
336 $uploaded_data['file']['original_key'] ?? '',
337 $uploaded_data['extra'],
338 'media_library'
339 );
340 }
341 }
342
343 /**
344 * Handles upload when no backup exists.
345 * @since 1.2.13
346 * @param array $attachment_meta
347 * @param int $attachment_id
348 * @param string $source_path
349 * @param array $existing
350 */
351 private function handle_media_with_no_backup($attachment_meta, $attachment_id, $source_path, $existing) {
352 $uploaded_data = $this->uploadMedia($attachment_meta, $attachment_id, $source_path);
353
354 if ($existing['source_path'] != $source_path) {
355 $uploaded_data['extra']['backup'] = Utils::maybe_serialize($existing);
356 }
357
358 $this->update_item_if_uploaded($attachment_id, $uploaded_data);
359 }
360
361 /**
362 * Handles upload when backup exists.
363 * @since 1.2.13
364 * @param array $attachment_meta
365 * @param int $attachment_id
366 * @param string $source_path
367 * @param array $existing
368 * @param array $backup
369 */
370 private function handle_media_with_backup($attachment_meta, $attachment_id, $source_path, $existing, $backup) {
371 $delete_existing = false;
372
373 if ($backup['source_path'] != $source_path) {
374 $uploaded_data = $this->uploadMedia($attachment_meta, $attachment_id, $source_path);
375 $uploaded_data['extra']['backup'] = Utils::maybe_serialize($backup);
376
377 if ($existing['source_path'] != $source_path) {
378 $delete_existing = true;
379 }
380 } else {
381 $delete_existing = true;
382
383 // construct upload data from backup
384 $uploaded_data = [
385 'file' => [
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'],
391 ],
392 'extra' => Utils::maybe_unserialize($backup['extra']),
393 ];
394 }
395
396 if ($delete_existing) {
397 Item::instance()->delete_attachments_by_item($existing, false);
398 Item::instance()->delete_server_files_by_item($existing, true);
399 }
400
401 $this->update_item_if_uploaded($attachment_id, $uploaded_data);
402 }
403
404 /**
405 * Updates item if upload was successful.
406 * @since 1.2.13
407 * @param int $attachment_id
408 * @param array $uploaded_data
409 * @return void
410 */
411 private function update_item_if_uploaded($attachment_id, $uploaded_data) {
412 if (!empty($uploaded_data['file'])) {
413 Item::instance()->update(
414 $attachment_id,
415 [
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']),
422 ],
423 'media_library'
424 );
425 }
426 }
427
428
429 /**
430 * Create unique names for files effects mainly on delete files from server settings
431 * @since 1.0.0
432 * @return string
433 */
434 public function wp_unique_filename($filename, $ext, $dir) {
435 // Get Post ID if uploaded in post screen.
436 $post_id = Utils::filter_input( 'post_id', INPUT_POST, FILTER_VALIDATE_INT );
437
438 $filename = $this->filter_unique_filename($filename, $ext, $dir, $post_id);
439
440 return $filename;
441 }
442
443 /**
444 * filter unique file names
445 * @since 1.0.0
446 * @return string
447 */
448 private function filter_unique_filename($filename, $ext, $dir, $post_id = null) {
449 // sanitize the file name before we begin processing
450 $filename = sanitize_file_name($filename);
451 $ext = strtolower($ext);
452 $name = wp_basename($filename, $ext);
453
454 // Edge case: if file is named '.ext', treat as an empty name.
455 if ($name === $ext) {
456 $name = '';
457 }
458
459 // Rebuild filename with lowercase extension as provider will have converted extension on upload.
460 $filename = $name . $ext;
461
462 return $this->generate_unique_filename($name, $ext, $dir);
463 }
464
465
466 /**
467 * Generate unique filename
468 * @since 1.0.0
469 * @param string $name
470 * @param string $ext
471 * @param string $time
472 *
473 * @return string
474 */
475 private function generate_unique_filename($name, $ext, $dir) {
476 $upload_dir = wp_get_upload_dir();
477 $filename = $name . $ext;
478 $no_ext_path = $dir . '/' . $name;
479 $rel_no_ext_path = substr($no_ext_path, strlen(trailingslashit($upload_dir['basedir'])),strlen($no_ext_path));
480 $path = $dir . '/' . $name . $ext;
481 $source_path = substr($path, strlen(trailingslashit($upload_dir['basedir'])),strlen($path));
482
483
484 $uploaded_files = Item::instance()->get_similar_files_by_path($rel_no_ext_path);
485
486 if ($uploaded_files !== false) {
487 if (Utils::check_existing_file_names($source_path, $uploaded_files) || file_exists($path)) {
488 $count = 1;
489 $new_file_name = '';
490 $found = true;
491 while ($found) {
492 $tmp_path = $dir . '/' . $name . '-' . $count . $ext;
493 $rel_temp_path = substr($tmp_path, strlen(trailingslashit($upload_dir['basedir'])),strlen($tmp_path));
494
495 if (Utils::check_existing_file_names($rel_temp_path, $uploaded_files) || file_exists($tmp_path)) {
496 $count++;
497 } else {
498 $found = false;
499 $new_file_name = $name . '-' . $count . $ext;
500 }
501 }
502 return $new_file_name;
503 }
504 } else {
505 if (file_exists($path)) {
506 $count = 1;
507 $new_file_name = '';
508 $found = true;
509
510 while ($found) {
511 $tmp_path = $dir . '/' . $name . '-' . $count . $ext;
512 if (file_exists($tmp_path)) {
513 $count++;
514 } else {
515 $found = false;
516 $new_file_name = $name . '-' . $count . $ext;
517 }
518 }
519 return $new_file_name;
520 }
521 }
522
523 return $filename;
524 }
525
526
527 /**
528 * Filters the audio & video shortcodes output to remove "&_=NN" params from source.src as it breaks signed URLs.
529 *
530 * @param string $html Shortcode HTML output.
531 * @param array $atts Array of shortcode attributes.
532 * @param string $media Media file.
533 * @param int $post_id Post ID.
534 * @param string $library Media library used for the shortcode.
535 *
536 * @return string
537 *
538 * Note: Depends on 30377.4.diff from https://core.trac.wordpress.org/ticket/30377
539 */
540 public function wp_media_shortcode( $html, $atts, $media, $post_id, $library ) {
541 return preg_replace( '/&#038;_=[0-9]+/', '', $html );
542 }
543
544 /**
545 * Return the provider URL when the server file is missing
546 * unless we know who the calling process is and we are happy
547 * to copy the file back to the server to be used.
548 *
549 * @handles get_attached_file
550 * @handles wp_get_original_image_path
551 * @since 1.0.0
552 * @param string $file
553 * @param int $attachment_id
554 *
555 * @return string
556 */
557 public function get_attached_file($file, $attachment_id) {
558 $attachment_id = (int)$attachment_id;
559
560 // During the deletion of an attachment, stream wrapper URLs should not be returned.
561 if ( $this->deleting_attachment ) {
562 return $file;
563 }
564
565 if (!Utils::should_serve_from_cloud($attachment_id)) {
566 return $file;
567 }
568
569 $wpmcsItem = Item::instance();
570
571 $item = $wpmcsItem->get($attachment_id, 'media_library');
572
573 if ( file_exists( $file ) || Utils::is_empty($item)) {
574 if(!Utils::is_empty($item)) {
575 /**
576 * Added filter to allow plugins to copy the pending size to the server
577 * before the file is served.
578 *
579 * @param string $file
580 * @param string $file
581 * @param int $attachment_id
582 */
583 return apply_filters( 'wpmcs_get_attached_file_noop', $file, $file, $attachment_id, $item );
584 } else {
585 return $file;
586 }
587 }
588
589 $url = $wpmcsItem->get_url($attachment_id, 'full', 'media_library');
590 if(Utils::is_empty($url)) {
591 return $file;
592 }
593 /**
594 * This filter gives filter implementors a chance to copy back missing item files
595 * from the provider before WordPress returns the file name/path for it. Defaults to
596 * returning the remote URL.
597 *
598 * @param string $url Item URL
599 * @param string $file Server file path
600 * @param int $attachment_id Attachment ID
601 * @param array $item Item data
602 */
603 return apply_filters('wpmcs_get_attached_file', $url, $file, $attachment_id, $item);
604 }
605
606
607 /**
608 * Change src attributes
609 * @since 1.0.0
610 */
611
612 public function wp_calculate_image_srcset($sources, $size_array, $image_src, $attachment_meta, $attachment_id = 0) {
613 $attachment_id = (int)$attachment_id;
614 if ( ! is_array( $sources ) ) {
615 // Sources corrupt
616 return $sources;
617 }
618
619 // Must need $attachment_id other wise not possible to get data from the table
620 if (!Utils::should_serve_from_cloud($attachment_id)) {
621 return $sources;
622 }
623
624 $wpmcsItem = Item::instance();
625
626 if (Utils::is_empty($wpmcsItem->get($attachment_id, 'media_library'))) {
627 return $sources;
628 }
629
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' );
634
635 if ( false === $provider_url || is_wp_error( $provider_url ) ) {
636 // Skip URLs not uploaded to cloud
637 continue;
638 }
639
640 $sources[ $width ]['url'] = $provider_url;
641 }
642 return $sources;
643 }
644
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 /**
678 * Filters the list of attachment image attributes.
679 *
680 * @since 1.0.0
681 * @param array $attr Attributes for the image markup.
682 * @param WP_Post $attachment Image attachment post.
683 * @param string|array $size Requested size. Image size or array of width and height values (in that order).
684 *
685 * @return array
686 */
687 public function wp_get_attachment_image_attributes($attr, $attachment, $size='thumbnail') {
688 if (
689 !$attachment ||
690 !Utils::should_serve_from_cloud($attachment->ID)
691 ) {
692 return $attr;
693 }
694
695 $wpmcsItem = Item::instance();
696
697 $item = $wpmcsItem->get($attachment->ID, 'media_library');
698
699 if (Utils::is_empty($item)) {
700 return $attr;
701 }
702
703 $size = Utils::maybe_convert_size_to_string($attachment->ID, $size);
704 if ($size === false) {
705 return $attr;
706 }
707
708
709
710 if (
711 isset($size) && !empty($size) &&
712 isset($attr['src']) && !empty($attr['src'])
713 ) {
714 $source = $wpmcsItem->get_url($attachment->ID, $size, 'media_library');
715 if (isset($source) && !empty($source)) {
716 $attr['src'] = $source;
717 }
718 }
719
720 /**
721 * Filtered list of attachment image attributes.
722 *
723 * @param array $attr Attributes for the image markup.
724 * @param WP_Post $attachment Image attachment post.
725 * @param string $size Requested size.
726 */
727 return apply_filters('wpmcs_wp_get_attachment_image_attributes', $attr, $attachment, $size, $item);
728 }
729
730 /**
731 * Get attachment url
732 * @since 1.0.0
733 * @param string $url
734 * @param int $attachment_id
735 *
736 * @return bool|mixed|WP_Error
737 */
738 public function wp_get_attachment_url($url, $attachment_id) {
739 if (!Utils::should_serve_from_cloud($attachment_id)) {
740 return $url;
741 }
742
743 $new_url = Item::instance()->get_url($attachment_id, 'full', 'media_library');
744
745 if (Utils::is_empty($new_url)) {
746 return $url;
747 }
748
749 $new_url = apply_filters('wpmcs_wp_get_attachment_url', $new_url, $url, $attachment_id);
750
751 return $new_url;
752 }
753
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( '&#038;'.$this->token.'-fix-wp-check-file-type-ext=.' . $ext, '', $html );
1001 }
1002
1003 return str_replace( '&#038;'.$this->token.'-fix-wp-check-file-type-fragment=', '#', $html );
1004 } );
1005 }
1006
1007 return $out;
1008 }
1009
1010
1011 /**
1012 * Upload media item
1013 */
1014 public function uploadMedia($attachment_meta, $attachment_id, $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'] : '';
1031
1032 $do_reupload = apply_filters('wpmcs_do_reupload_media', false, $attachment_id, 'media_library');
1033
1034 // Force reupload when sizes/dimensions changed
1035 if (!$do_reupload && $has_existing) {
1036 if($this->shouldForceReupload($attachment_meta, $attachment_id, $existing_extras)) {
1037 $do_reupload = true;
1038 }
1039 }
1040
1041 // Add prefix if object versioning is ON
1042 if (isset($this->settings['object_versioning']) && $this->settings['object_versioning']) {
1043 $prefix = ($has_existing && isset($existing_extras['prefix']) && !empty($existing_extras['prefix']))
1044 ? $existing_extras['prefix']
1045 : Utils::generate_object_versioning_prefix();
1046 $extras['prefix'] = $prefix;
1047 }
1048
1049 // Width/height
1050 if (isset($attachment_meta) && !empty($attachment_meta)) {
1051 $extras['width'] = (isset($attachment_meta['width']) && !empty($attachment_meta['width'])) ? $attachment_meta['width'] : 0;
1052 $extras['height'] = (isset($attachment_meta['height']) && !empty($attachment_meta['height'])) ? $attachment_meta['height'] : 0;
1053 }
1054
1055 // Excluded by extension settings — an intentional skip, not a sync failure, so no log entry.
1056 if (!Utils::is_extension_available($file_path)) {
1057 return false;
1058 }
1059
1060 // Upload main file first, if that fails no need to attempt the rest
1061 $uploaded = $this->uploadFullFile($file_path, $source_path, $do_reupload, $has_existing, $existing, $prefix, $attachment_id, $is_private);
1062
1063 if ($uploaded && isset($uploaded['success']) && $uploaded['success']) {
1064 // Attempt to upload original file if present. Not critical if this fails so we don't check the result before proceeding.
1065 $original = $this->uploadOriginalFile($original_file, $file_dir, $do_reupload, $has_existing, $existing, $prefix, $attachment_id, $is_private);
1066
1067 if ($is_image && isset($file_dir) && !empty($file_dir)) {
1068 $sizes_to_upload = $this->get_attachment_image_sizes_for_upload($attachment_meta, $attachment_id);
1069 if (!empty($sizes_to_upload)) {
1070 // Upload image sizes. Again, not critical if this fails so we don't check the result before proceeding.
1071 $sizes = $this->uploadImageSizes($sizes_to_upload, $file_dir, $do_reupload, $existing_extras, $prefix, $attachment_id, $is_private);
1072 }
1073 }
1074
1075 if (!empty($sizes)) {
1076 // Merge over existing sizes rather than replacing wholesale — a size that
1077 // came back empty from uploadImageSizes() (its own fallback included) must
1078 // never erase a previously-good, unrelated size's record.
1079 $existing_sizes = isset($existing_extras['sizes']) && is_array($existing_extras['sizes'])
1080 ? $existing_extras['sizes']
1081 : [];
1082
1083 foreach ($sizes as $size_name => $size_data) {
1084 if (Utils::is_empty($size_data) && isset($existing_sizes[$size_name])) {
1085 unset($sizes[$size_name]);
1086 }
1087 }
1088
1089 $extras['sizes'] = array_merge($existing_sizes, $sizes);
1090 }
1091
1092 return [
1093 'file' => [
1094 'source_path' => $source_path,
1095 'url' => $uploaded['file_url'],
1096 'key' => $uploaded['key'],
1097 'original_source_path' => isset($original['source_path']) ? $original['source_path'] : '',
1098 'original_key' => isset($original['key']) ? $original['key'] : ''
1099 ],
1100 'extra' => $extras
1101 ];
1102 }
1103
1104 if (isset($uploaded['success']) && !$uploaded['success']) {
1105 // Prefer the service's own error message/code when it provided one.
1106 Logger::instance()->add_log('sync_to_cloud', $attachment_id, 'media_library', [
1107 'message' => $uploaded['message'] ?? __('File upload failed', 'media-cloud-sync'),
1108 'file' => $file_path,
1109 'code' => $uploaded['code'] ?? 500
1110 ]);
1111 }
1112
1113 return false;
1114 }
1115
1116 /**
1117 * Image subsizes to sync to cloud (after filters). Empty means no subsizes are uploaded.
1118 *
1119 * `wpmcs_skip_upload_image_sizes` — (bool) When true, no subsizes are uploaded.
1120 * `wpmcs_upload_attachment_image_sizes` — (array) Subset of `$attachment_meta['sizes']` to upload; empty skips all.
1121 *
1122 * @param array $attachment_meta Full attachment metadata.
1123 * @param int $attachment_id Attachment post ID.
1124 * @return array Same shape as attachment meta `sizes` entries.
1125 */
1126 private function get_attachment_image_sizes_for_upload($attachment_meta, $attachment_id) {
1127 $sizes = [];
1128 if (isset($attachment_meta['sizes']) && is_array($attachment_meta['sizes'])) {
1129 $sizes = $attachment_meta['sizes'];
1130 }
1131
1132 if (apply_filters('wpmcs_skip_upload_image_sizes', false, $attachment_id, $attachment_meta)) {
1133 return [];
1134 }
1135
1136 $sizes = apply_filters('wpmcs_upload_attachment_image_sizes', $sizes, $attachment_id, $attachment_meta);
1137
1138 return is_array($sizes) ? $sizes : [];
1139 }
1140
1141 /**
1142 * Decide if we should force a reupload based on dims/sizes differences.
1143 * @since 1.3.6
1144 * @param array $attachment_meta
1145 * @param int $attachment_id
1146 * @param array $existing_extras
1147 */
1148 private function shouldForceReupload($attachment_meta, $attachment_id, $existing_extras) {
1149 $existing_extras = $existing_extras ?? Item::instance()->get_extras($attachment_id, false, 'media_library');
1150
1151 $current_width = isset($attachment_meta['width']) ? (int)$attachment_meta['width'] : 0;
1152 $current_height = isset($attachment_meta['height']) ? (int)$attachment_meta['height'] : 0;
1153
1154 $existing_width = isset($existing_extras['width']) ? (int)$existing_extras['width'] : 0;
1155 $existing_height = isset($existing_extras['height']) ? (int)$existing_extras['height'] : 0;
1156
1157 if ($current_width !== $existing_width || $current_height !== $existing_height) {
1158 return true;
1159 }
1160
1161 $filtered_sizes = $this->get_attachment_image_sizes_for_upload($attachment_meta, $attachment_id);
1162 if (empty($filtered_sizes)) {
1163 return false;
1164 }
1165
1166 $existing_sizes = isset($existing_extras['sizes']) && is_array($existing_extras['sizes']) ? $existing_extras['sizes'] : [];
1167 $current_sizes = [];
1168 foreach ($filtered_sizes as $sname => $smeta) {
1169 $current_sizes[$sname] = [
1170 'width' => isset($smeta['width']) ? (int)$smeta['width'] : 0,
1171 'height' => isset($smeta['height']) ? (int)$smeta['height'] : 0,
1172 ];
1173 }
1174
1175 $existing_relevant = [];
1176 foreach (array_keys($current_sizes) as $sname) {
1177 if (isset($existing_sizes[$sname])) {
1178 $existing_relevant[$sname] = $existing_sizes[$sname];
1179 }
1180 }
1181
1182 if (array_keys($existing_relevant) !== array_keys($current_sizes)) {
1183 return true;
1184 }
1185
1186 foreach ($current_sizes as $sname => $dims) {
1187 if (!isset($existing_sizes[$sname])) {
1188 return true;
1189 }
1190 $ex_w = isset($existing_sizes[$sname]['width']) ? (int)$existing_sizes[$sname]['width'] : 0;
1191 $ex_h = isset($existing_sizes[$sname]['height']) ? (int)$existing_sizes[$sname]['height'] : 0;
1192 if ($ex_w !== $dims['width'] || $ex_h !== $dims['height']) {
1193 return true;
1194 }
1195 }
1196
1197 return false;
1198 }
1199
1200 /**
1201 * Upload the main/full file.
1202 * @since 1.3.6
1203 * @param string $file_path
1204 * @param string $source_path
1205 * @param bool|array $do_reupload Reupload everything (true), nothing (false), or only these sizes (array of size names).
1206 * @param bool $has_existing
1207 * @param array $existing
1208 * @param string $prefix
1209 * @param int $attachment_id
1210 * @param bool $is_private Keeps a reupload of an already-private item under the private path.
1211 */
1212 private function uploadFullFile($file_path, $source_path, $do_reupload, $has_existing, $existing, $prefix, $attachment_id, $is_private = false) {
1213 // $do_reupload may be an array of size names (force just those sizes) — that
1214 // shouldn't force the full file too, only a literal true does.
1215 if ($do_reupload !== true && $has_existing && ($existing['source_path'] == $source_path)) {
1216 return [
1217 'success' => true,
1218 'file_url' => $existing['url'],
1219 'key' => $existing['key']
1220 ];
1221 }
1222
1223 if (file_exists($file_path)) {
1224 return Service::instance()->uploadSingle($file_path, $source_path, $prefix, $is_private);
1225 }
1226
1227 Logger::instance()->add_log('sync_to_cloud', $attachment_id, 'media_library', [
1228 'message' => __('File not found', 'media-cloud-sync'),
1229 'file' => $file_path,
1230 'code' => 404
1231 ]);
1232
1233 return false;
1234 }
1235
1236 /**
1237 * Upload original file when present.
1238 * @since 1.3.6
1239 * @param string $original_file
1240 * @param string $file_dir
1241 * @param bool|array $do_reupload Reupload everything (true), nothing (false), or only these sizes (array of size names).
1242 * @param bool $has_existing
1243 * @param array $existing
1244 * @param string $prefix
1245 * @param int $attachment_id
1246 * @param bool $is_private Keeps a reupload of an already-private item under the private path.
1247 */
1248 private function uploadOriginalFile($original_file, $file_dir, $do_reupload, $has_existing, $existing, $prefix, $attachment_id, $is_private = false) {
1249 $original = [];
1250 if (empty($original_file)) {
1251 return $original;
1252 }
1253
1254 $original_file_path = trailingslashit($file_dir) . $original_file;
1255 $original_file_source_path = Utils::get_attachment_source_path($original_file_path);
1256
1257 // $do_reupload may be an array of size names (force just those sizes) — that
1258 // shouldn't force the original file too, only a literal true does.
1259 if ($do_reupload !== true && $has_existing && isset($existing['original_source_path']) && !Utils::is_empty($existing['original_source_path']) && $existing['original_source_path'] === $original_file_source_path) {
1260 return [ 'source_path' => $original_file_source_path, 'key' => $existing['original_key'] ];
1261 }
1262
1263 if (file_exists($original_file_path)) {
1264 $uploaded_original = Service::instance()->uploadSingle($original_file_path, $original_file_source_path, $prefix, $is_private);
1265 if (isset($uploaded_original['success']) && $uploaded_original['success']) {
1266 return [ 'source_path' => $original_file_source_path, 'key' => $uploaded_original['key'] ];
1267 }
1268
1269 if (isset($uploaded_original['success']) && !$uploaded_original['success']) {
1270 Logger::instance()->add_log('sync_to_cloud', $attachment_id, 'media_library', [
1271 'message' => $uploaded_original['message'] ?? __('Original File upload failed', 'media-cloud-sync'),
1272 'file' => $original_file_path,
1273 'code' => $uploaded_original['code'] ?? 500
1274 ]);
1275 }
1276 } else {
1277 Logger::instance()->add_log('sync_to_cloud', $attachment_id, 'media_library', [
1278 'message' => __('Original File not found', 'media-cloud-sync'),
1279 'file' => $original_file_path,
1280 'code' => 404
1281 ]);
1282 }
1283
1284 return $original;
1285 }
1286
1287 /**
1288 * Upload intermediate image sizes.
1289 * @since 1.3.6
1290 * @param array $attachment_sizes
1291 * @param string $file_dir
1292 * @param bool|array $do_reupload Reupload everything (true), nothing (false), or only these sizes (array of size names).
1293 * @param array $existing_extras
1294 * @param string $prefix
1295 * @param int $attachment_id
1296 * @param bool $is_private Keeps a reupload of an already-private item under the private path.
1297 * @return array
1298 */
1299 private function uploadImageSizes($attachment_sizes, $file_dir, $do_reupload, $existing_extras, $prefix, $attachment_id, $is_private = false) {
1300 $sizes = [];
1301
1302 foreach ($attachment_sizes as $size => $sub_image) {
1303 $sub_size = [];
1304 $sub_file = isset($sub_image['file']) ? $sub_image['file'] : false;
1305 $sub_file_path = '';
1306 $sub_file_source_path = '';
1307
1308 if ($sub_file) {
1309 $sub_file_path = $file_dir . '/' . $sub_file;
1310 $sub_file_source_path = Utils::get_attachment_source_path($sub_file_path);
1311 }
1312
1313 $uploaded_sub_image = [];
1314
1315 // $do_reupload may be an array naming specific sizes to force (e.g. a
1316 // WooCommerce on-the-fly regen of one size only) rather than a plain bool.
1317 $force_this_size = ($do_reupload === true) || (is_array($do_reupload) && in_array($size, $do_reupload, true));
1318
1319 if (
1320 !$force_this_size &&
1321 !Utils::is_empty($existing_extras) &&
1322 isset($existing_extras['sizes']) && !Utils::is_empty($existing_extras['sizes']) &&
1323 isset($existing_extras['sizes'][$size]) && !Utils::is_empty($existing_extras['sizes'][$size]) &&
1324 $existing_extras['sizes'][$size]['source_path'] == $sub_file_source_path
1325 ) {
1326 $uploaded_sub_image = [
1327 'success' => true,
1328 'file_url' => $existing_extras['sizes'][$size]['url'],
1329 'key' => $existing_extras['sizes'][$size]['key']
1330 ];
1331 } else if (file_exists($sub_file_path)) {
1332 $uploaded_sub_image = Service::instance()->uploadSingle($sub_file_path, $sub_file_source_path, $prefix, $is_private);
1333 } else {
1334 Logger::instance()->add_log('sync_to_cloud', $attachment_id, 'media_library', [
1335 /* translators: %1$s: Image size name (e.g., thumbnail, medium, large). */
1336 'message' => sprintf(__('Image size %1$s not found', 'media-cloud-sync'), $size),
1337 'file' => $sub_file_path,
1338 'code' => 404
1339 ]);
1340 }
1341
1342 if (isset($uploaded_sub_image) && !empty($uploaded_sub_image) && isset($uploaded_sub_image['success']) && $uploaded_sub_image['success']) {
1343 $sub_size['source_path'] = $sub_file_source_path;
1344 $sub_size['url'] = $uploaded_sub_image['file_url'];
1345 $sub_size['key'] = $uploaded_sub_image['key'];
1346 $sub_size['width'] = isset($sub_image['width']) ? $sub_image['width'] : 0;
1347 $sub_size['height'] = isset($sub_image['height']) ? $sub_image['height'] : 0;
1348 } else {
1349 if (isset($uploaded_sub_image['success']) && !$uploaded_sub_image['success']) {
1350 Logger::instance()->add_log('sync_to_cloud', $attachment_id, 'media_library', [
1351 /* translators: %1$s: Image size name (e.g., thumbnail, medium, large). */
1352 'message' => sprintf(__('Image size %1$s upload failed', 'media-cloud-sync'), $size),
1353 'file' => $sub_file_path,
1354 'code' => 500
1355 ]);
1356 }
1357
1358 // A failed (re)upload attempt shouldn't erase a previously-good record —
1359 // fall back to what's already stored rather than overwriting it with empty data.
1360 if (isset($existing_extras['sizes'][$size]) && !Utils::is_empty($existing_extras['sizes'][$size])) {
1361 $sub_size = $existing_extras['sizes'][$size];
1362 }
1363 }
1364
1365 $sizes[$size] = $sub_size;
1366 }
1367
1368 return $sizes;
1369 }
1370
1371
1372 /**
1373 * Get Total Media Count (queried)
1374 *
1375 * Param added because need a generic function name,
1376 * if the function calls by source_type
1377 */
1378 public static function get_total_media($source_type) {
1379 global $wpdb;
1380
1381 // Fetch the count of attachments with the 'inherit' status
1382 $count = $wpdb->get_var(
1383 $wpdb->prepare(
1384 "SELECT COUNT(1) FROM {$wpdb->posts} WHERE post_type = %s AND post_status = %s",
1385 'attachment',
1386 'inherit'
1387 )
1388 );
1389
1390 return (int)$count;
1391 }
1392
1393
1394 /**
1395 * Whether an attachment is still waiting on WordPress-generated subsizes — same check
1396 * update_attachment_metadata() uses, so callers can tell that apart from a real failure.
1397 *
1398 * @param int $attachment_id
1399 * @return bool
1400 */
1401 private function is_attachment_not_yet_ready( $attachment_id ) {
1402 if ( ! wp_attachment_is_image( $attachment_id ) ) {
1403 return false;
1404 }
1405
1406 $attachment_meta = wp_get_attachment_metadata( $attachment_id );
1407
1408 return $this->should_wait_for_subsizes( $attachment_meta, $attachment_id );
1409 }
1410
1411 /**
1412 * Upload pending media files.
1413 *
1414 * Processes media attachments that are pending and haven't been synced yet.
1415 *
1416 * Paginates by `posts.ID` (a stable cursor) rather than a raw SQL OFFSET,
1417 * so a concurrent single-item retry (which can remove an arbitrary item
1418 * from the middle of the pending set, not just the front) can't desync
1419 * which row gets picked up next.
1420 *
1421 * @param string $source_type Source type for identifying which source..
1422 * @param int $limit Number of media to process. Default is 50.
1423 * @param int $after_id Only consider attachments with ID greater than this cursor. Default is 0.
1424 * @return array Status array with success, failed count, and the last attachment ID considered.
1425 */
1426 public function upload_pending_media( $source_type, $limit = 50, $after_id = 0 ) {
1427 global $wpdb;
1428
1429 $failed = 0;
1430
1431 $source_type_data = self::$source_types[ $source_type ] ?? false;
1432 if ( ! $source_type_data ) {
1433 return [ 'success' => true, 'failed' => $limit, 'last_id' => $after_id ];
1434 }
1435
1436 $posts = $wpdb->prefix . $source_type_data['table'];
1437 $items = Db::get_table_name();
1438
1439 $join = "
1440 items.source_id = posts.ID
1441 AND items.source_type = %s
1442 AND items.provider = %s
1443 AND items.storage = %s
1444 ";
1445
1446 $params = [
1447 $source_type,
1448 $this->service,
1449 $this->bucket_name,
1450 ];
1451
1452 if ( ! empty( $this->region ) ) {
1453 $join .= " AND items.region = %s";
1454 $params[] = $this->region;
1455 }
1456
1457 $after_id_sql = '';
1458 if ( $after_id > 0 ) {
1459 $after_id_sql = 'AND posts.ID > %d';
1460 $params[] = (int) $after_id;
1461 }
1462
1463 $sql = "
1464 SELECT posts.ID
1465 FROM {$posts} AS posts
1466 LEFT JOIN {$items} AS items USE INDEX (idx_item_lookup)
1467 ON {$join}
1468 WHERE posts.post_type = 'attachment'
1469 AND items.source_id IS NULL
1470 {$after_id_sql}
1471 ORDER BY posts.ID ASC
1472 LIMIT %d
1473 ";
1474
1475 $params[] = (int) $limit;
1476
1477 $rows = $wpdb->get_results( $wpdb->prepare( $sql, $params ) );
1478
1479 if ( empty( $rows ) ) {
1480 return [ 'success' => true, 'failed' => 0, 'last_id' => $after_id ];
1481 }
1482
1483 $last_id = $after_id;
1484 foreach ( $rows as $row ) {
1485 $attachment_id = (int) $row->ID;
1486
1487 // Still waiting on WordPress to finish generating subsizes — not a failure,
1488 // just not ready yet. Leave $last_id alone so it's reconsidered next tick.
1489 if ( $this->is_attachment_not_yet_ready( $attachment_id ) ) {
1490 break;
1491 }
1492
1493 $last_id = $attachment_id;
1494
1495 try {
1496 $this->upload_single_media( $attachment_id, $source_type );
1497 } catch ( \Throwable $e ) {
1498 $failed++;
1499 continue;
1500 }
1501
1502 if ( ! Item::instance()->get( $attachment_id, $source_type ) ) {
1503 $failed++;
1504 }
1505 }
1506
1507 return [
1508 'success' => true,
1509 'failed' => $failed,
1510 'last_id' => $last_id,
1511 ];
1512 }
1513
1514
1515 /**
1516 * Edit Image Meta In View Image
1517 * @since 1.0.0
1518 */
1519 function attachment_submitbox_metadata( ) {
1520 $post = get_post();
1521 $attachment_id = $post->ID;
1522
1523 if (!Utils::is_ok_to_serve($attachment_id)) {
1524 return;
1525 }
1526
1527 $wpmcsItem = Item::instance();
1528
1529 $item = $wpmcsItem->get($attachment_id, 'media_library');
1530
1531 if (Utils::is_empty($item)) {
1532 return;
1533 }
1534
1535 $provider = $wpmcsItem->get_field($attachment_id, 'provider', 'media_library');
1536 if($provider) {
1537 $label = Schema::getServiceLabels($provider); ?>
1538 <div class="misc-pub-section misc-pub-provider">
1539 <?php esc_html_e( 'Provider :', 'media-cloud-sync' ); ?> <strong><?php echo esc_textarea(!empty($label) ? $label : $provider); ?></strong>
1540 </div>
1541 <?php
1542 }
1543
1544 $region = $wpmcsItem->get_field($attachment_id, 'region', 'media_library');
1545 if($region) { ?>
1546 <div class="misc-pub-section misc-pub-provider">
1547 <?php esc_html_e( 'Region :', 'media-cloud-sync' ); ?> <strong><?php echo esc_textarea($region); ?></strong>
1548 </div>
1549 <?php
1550 }
1551 $private = (int)$wpmcsItem->get_field($attachment_id, 'is_private', 'media_library');
1552 ?>
1553 <div class="misc-pub-section misc-pub-provider">
1554 <?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>
1555 </div>
1556 <?php
1557 }
1558
1559
1560 /**
1561 * Function to get attachment details by ID
1562 *
1563 * @since 1.3.12 Added capability checks for attachment access.
1564 */
1565 public function ajax_get_attachment_details() {
1566 $result = array(
1567 'status' => false,
1568 'data' => array(),
1569 'exclude' => false
1570 );
1571
1572 if(!Utils::is_service_enabled()) {
1573 wp_send_json_success( $result );
1574 }
1575
1576 if ( ! isset( $_POST['id'] ) ) {
1577 wp_send_json_success( $result );
1578 }
1579
1580 check_ajax_referer( 'get_media_provider_details', '_nonce' );
1581
1582 if ( ! current_user_can( 'upload_files' ) ) {
1583 wp_send_json_error();
1584 }
1585
1586 $id = absint( wp_unslash( $_POST['id'] ) );
1587 $post = get_post( $id );
1588
1589 if ( ! $post || 'attachment' !== $post->post_type || ! current_user_can( 'edit_post', $id ) ) {
1590 wp_send_json_success( $result );
1591 }
1592
1593 // Return if extension not allowed
1594 $path = get_attached_file( $id );
1595 if(!Utils::is_extension_available($path)) {
1596 $result['exclude'] = true;
1597 wp_send_json_success( $result );
1598 }
1599
1600 if (!Utils::is_ok_to_serve($id)) {
1601 wp_send_json_success( $result );
1602 }
1603
1604 $wpmcsItem = Item::instance();
1605
1606 $item = $wpmcsItem->get($id, 'media_library');
1607
1608 if (Utils::is_empty($item)) {
1609 wp_send_json_success( $result );
1610 }
1611
1612 $provider = $wpmcsItem->get_field($id, 'provider', 'media_library');
1613 if($provider){
1614 $label = Schema::getServiceLabels($provider);
1615 $item['provider'] = !empty($label) ? $label : $provider;
1616 }
1617 $region = $wpmcsItem->get_field($id, 'region', 'media_library');
1618 if($region){
1619 $item['region'] = $region;
1620 }
1621 $item['private'] = (int) $wpmcsItem->get_field($id, 'is_private', 'media_library');
1622 if($item) {
1623 $result= array(
1624 'status' => true,
1625 'data' => $item
1626 );
1627 }
1628
1629 wp_send_json_success( $result );
1630 }
1631
1632 /**
1633 * Takes notice that an attachment is about to be deleted and prepares for it.
1634 *
1635 * @handles pre_delete_attachment
1636 *
1637 * @param bool|null $delete Whether to go forward with deletion.
1638 *
1639 * @return bool|null
1640 */
1641 public function pre_delete_attachment( $delete ) {
1642 if ( is_null( $delete ) ) {
1643 $this->deleting_attachment = true;
1644 }
1645
1646 return $delete;
1647 }
1648
1649 /**
1650 * Takes notice that an attachment has been deleted and undoes previous preparations for the event.
1651 *
1652 * @handles delete_post
1653 *
1654 * Note: delete_post is used as there is a potential that deleted_post is not reached.
1655 */
1656 public function delete_post() {
1657 $this->deleting_attachment = false;
1658 }
1659
1660 /**
1661 * Safety net for deletion that bypasses wp_delete_attachment().
1662 *
1663 * @handles deleted_post
1664 */
1665 public function deleted_post( $post_id, $post ) {
1666 if ( ! $post || 'attachment' !== $post->post_type ) {
1667 return;
1668 }
1669
1670 $item = Item::instance()->get( $post_id, 'media_library' );
1671 if ( Utils::is_empty( $item ) ) {
1672 return;
1673 }
1674
1675 Item::instance()->delete( $post_id, 'media_library' );
1676 }
1677
1678 /**
1679 * Has WP Core fixed wp_check_filetype when URL has params yet?
1680 *
1681 * @see https://core.trac.wordpress.org/ticket/30377
1682 * @see https://github.com/aaemnnosttv/fix-wp-media-shortcodes-with-params/blob/master/fix-wp-media-shortcodes-with-params.php
1683 *
1684 * @return bool
1685 */
1686 public static function wp_check_filetype_broken() {
1687 $normal_file = wp_check_filetype( 'file.mp4', array( 'mp4' => 'video/mp4' ) );
1688 $querys_file = wp_check_filetype( 'file.mp4?param=1', array( 'mp4' => 'video/mp4' ) );
1689
1690 return $normal_file !== $querys_file;
1691 }
1692
1693 /**
1694 * Is installed?
1695 *
1696 * @return bool
1697 */
1698 public static function is_installed(): bool {
1699 // It is inbuilt in worpress
1700 return true;
1701 }
1702
1703
1704
1705 public static function instance(){
1706 if (is_null(self::$instance)) {
1707 self::$instance = new self();
1708 }
1709 return self::$instance;
1710 }
1711 }