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
media-cloud-sync / includes / integrations / media-library.php

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

1,784 lines 64.9 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 $force_reupload = $this->shouldForceReupload($attachment_meta, $attachment_id, $existing_extras);
1037 if ($force_reupload !== false) {
1038 $do_reupload = $force_reupload;
1039 }
1040 }
1041
1042 // Add prefix if object versioning is ON
1043 if (isset($this->settings['object_versioning']) && $this->settings['object_versioning']) {
1044 $prefix = ($has_existing && isset($existing_extras['prefix']) && !empty($existing_extras['prefix']))
1045 ? $existing_extras['prefix']
1046 : Utils::generate_object_versioning_prefix();
1047 $extras['prefix'] = $prefix;
1048 }
1049
1050 // Width/height
1051 if (isset($attachment_meta) && !empty($attachment_meta)) {
1052 $extras['width'] = (isset($attachment_meta['width']) && !empty($attachment_meta['width'])) ? $attachment_meta['width'] : 0;
1053 $extras['height'] = (isset($attachment_meta['height']) && !empty($attachment_meta['height'])) ? $attachment_meta['height'] : 0;
1054 }
1055
1056 // Excluded by extension settings — an intentional skip, not a sync failure, so no log entry.
1057 if (!Utils::is_extension_available($file_path)) {
1058 return false;
1059 }
1060
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);
1063
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 = [];
1068
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);
1074 }
1075 $additional_files = $this->uploadAdditionalFiles($attachment_meta, $file_dir, $do_reupload, $has_existing, $existing_extras, $prefix, $attachment_id, $is_private);
1076 }
1077
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 : [];
1085
1086 foreach ($sizes as $size_name => $size_data) {
1087 if (Utils::is_empty($size_data) && isset($existing_sizes[$size_name])) {
1088 unset($sizes[$size_name]);
1089 }
1090 }
1091
1092 $extras['sizes'] = array_merge($existing_sizes, $sizes);
1093 }
1094
1095 if (!empty($additional_files)) {
1096 $existing_additional_files = isset($existing_extras['additional_files']) && is_array($existing_extras['additional_files'])
1097 ? $existing_extras['additional_files']
1098 : [];
1099 $extras['additional_files'] = array_merge($existing_additional_files, $additional_files);
1100 }
1101
1102 return [
1103 'file' => [
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'] : ''
1109 ],
1110 'extra' => $extras
1111 ];
1112 }
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 ]);
1121 }
1122
1123 return false;
1124 }
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 }
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
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 /**
1446 * Get Total Media Count (queried)
1447 *
1448 * Param added because need a generic function name,
1449 * if the function calls by source_type
1450 */
1451 public static function get_total_media($source_type) {
1452 global $wpdb;
1453
1454 // Fetch the count of attachments with the 'inherit' status
1455 $count = $wpdb->get_var(
1456 $wpdb->prepare(
1457 "SELECT COUNT(1) FROM {$wpdb->posts} WHERE post_type = %s AND post_status = %s",
1458 'attachment',
1459 'inherit'
1460 )
1461 );
1462
1463 return (int)$count;
1464 }
1465
1466
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 /**
1589 * Edit Image Meta In View Image
1590 * @since 1.0.0
1591 */
1592 function attachment_submitbox_metadata( ) {
1593 $post = get_post();
1594 $attachment_id = $post->ID;
1595
1596 if (!Utils::is_ok_to_serve($attachment_id)) {
1597 return;
1598 }
1599
1600 $wpmcsItem = Item::instance();
1601
1602 $item = $wpmcsItem->get($attachment_id, 'media_library');
1603
1604 if (Utils::is_empty($item)) {
1605 return;
1606 }
1607
1608 $provider = $wpmcsItem->get_field($attachment_id, 'provider', 'media_library');
1609 if($provider) {
1610 $label = Schema::getServiceLabels($provider); ?>
1611 <div class="misc-pub-section misc-pub-provider">
1612 <?php esc_html_e( 'Provider :', 'media-cloud-sync' ); ?> <strong><?php echo esc_textarea(!empty($label) ? $label : $provider); ?></strong>
1613 </div>
1614 <?php
1615 }
1616
1617 $region = $wpmcsItem->get_field($attachment_id, 'region', 'media_library');
1618 if($region) { ?>
1619 <div class="misc-pub-section misc-pub-provider">
1620 <?php esc_html_e( 'Region :', 'media-cloud-sync' ); ?> <strong><?php echo esc_textarea($region); ?></strong>
1621 </div>
1622 <?php
1623 }
1624 $private = (int)$wpmcsItem->get_field($attachment_id, 'is_private', 'media_library');
1625 ?>
1626 <div class="misc-pub-section misc-pub-provider">
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>
1628 </div>
1629 <?php
1630 }
1631
1632
1633 /**
1634 * Function to get attachment details by ID
1635 *
1636 * @since 1.3.12 Added capability checks for attachment access.
1637 */
1638 public function ajax_get_attachment_details() {
1639 $result = array(
1640 'status' => false,
1641 'data' => array(),
1642 'exclude' => false
1643 );
1644
1645 if(!Utils::is_service_enabled()) {
1646 wp_send_json_success( $result );
1647 }
1648
1649 if ( ! isset( $_POST['id'] ) ) {
1650 wp_send_json_success( $result );
1651 }
1652
1653 check_ajax_referer( 'get_media_provider_details', '_nonce' );
1654
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 }
1665
1666 // Return if extension not allowed
1667 $path = get_attached_file( $id );
1668 if(!Utils::is_extension_available($path)) {
1669 $result['exclude'] = true;
1670 wp_send_json_success( $result );
1671 }
1672
1673 if (!Utils::is_ok_to_serve($id)) {
1674 wp_send_json_success( $result );
1675 }
1676
1677 $wpmcsItem = Item::instance();
1678
1679 $item = $wpmcsItem->get($id, 'media_library');
1680
1681 if (Utils::is_empty($item)) {
1682 wp_send_json_success( $result );
1683 }
1684
1685 $provider = $wpmcsItem->get_field($id, 'provider', 'media_library');
1686 if($provider){
1687 $label = Schema::getServiceLabels($provider);
1688 $item['provider'] = !empty($label) ? $label : $provider;
1689 }
1690 $region = $wpmcsItem->get_field($id, 'region', 'media_library');
1691 if($region){
1692 $item['region'] = $region;
1693 }
1694 $item['private'] = (int) $wpmcsItem->get_field($id, 'is_private', 'media_library');
1695 if($item) {
1696 $result= array(
1697 'status' => true,
1698 'data' => $item
1699 );
1700 }
1701
1702 wp_send_json_success( $result );
1703 }
1704
1705 /**
1706 * Takes notice that an attachment is about to be deleted and prepares for it.
1707 *
1708 * @handles pre_delete_attachment
1709 *
1710 * @param bool|null $delete Whether to go forward with deletion.
1711 *
1712 * @return bool|null
1713 */
1714 public function pre_delete_attachment( $delete ) {
1715 if ( is_null( $delete ) ) {
1716 $this->deleting_attachment = true;
1717 }
1718
1719 return $delete;
1720 }
1721
1722 /**
1723 * Takes notice that an attachment has been deleted and undoes previous preparations for the event.
1724 *
1725 * @handles delete_post
1726 *
1727 * Note: delete_post is used as there is a potential that deleted_post is not reached.
1728 */
1729 public function delete_post() {
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;
1764 }
1765
1766 /**
1767 * Is installed?
1768 *
1769 * @return bool
1770 */
1771 public static function is_installed(): bool {
1772 // It is inbuilt in worpress
1773 return true;
1774 }
1775
1776
1777
1778 public static function instance(){
1779 if (is_null(self::$instance)) {
1780 self::$instance = new self();
1781 }
1782 return self::$instance;
1783 }
1784 }