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
← All changes | includes/integrations/media-library.php +1582 -14 1.0.31.4.1 View file →
@@ -2,8 +2,18 @@
2 2 namespace Dudlewebs\WPMCS;
3 3
4 4 defined('ABSPATH') || exit;
5 5
6 +use Exception;
7 +use WP_Error;
8 +
9 +/**
10 + * Media Library integration.
11 + *
12 + * Loaded by the autoloader; activation is gated via `is_installed()` in Integration::init().
13 + *
14 + * @since 1.0.0
15 + */
6 16 class MediaLibrary {
7 17 private static $instance = null;
8 18 private $assets_url;
9 19 private $version;
@@ -8,8 +18,34 @@
8 18 private $assets_url;
9 19 private $version;
10 20 private $token;
11 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 +
12 48 /**
13 49 * Admin constructor.
14 50 * @since 1.0.0
15 51 */
@@ -16,9 +52,28 @@
16 52 public function __construct() {
17 53 $this->assets_url = WPMCS_ASSETS_URL;
18 54 $this->version = WPMCS_VERSION;
19 55 $this->token = WPMCS_TOKEN;
56 + $this->settings = Utils::get_settings();
20 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 +
21 76 // Initialize setup
22 77 $this->registerActions();
23 78 }
24 79
@@ -29,17 +84,1440 @@
29 84 /** IMAGE EDITOR COMPATIBILITY */
30 85 add_action( 'attachment_submitbox_misc_actions', array( $this, 'attachment_submitbox_metadata'),99 );
31 86 //Media Modal Ajax
32 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 );
33 125 }
34 126
35 127
36 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( '/&_=[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( '&'.$this->token.'-fix-wp-check-file-type-ext=.' . $ext, '', $html );
1001 + }
1002 +
1003 + return str_replace( '&'.$this->token.'-fix-wp-check-file-type-fragment=', '#', $html );
1004 + } );
1005 + }
1006 +
1007 + return $out;
1008 + }
1009 +
1010 +
1011 + /**
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 + /**
37 1516 * Edit Image Meta In View Image
38 1517 * @since 1.0.0
39 1518 */
40 1519 function attachment_submitbox_metadata( ) {
41 - global $wpmcsItem;
42 1520 $post = get_post();
43 1521 $attachment_id = $post->ID;
44 1522
45 1523 if (!Utils::is_ok_to_serve($attachment_id)) {
@@ -45,34 +1523,36 @@
45 1523 if (!Utils::is_ok_to_serve($attachment_id)) {
46 1524 return;
47 1525 }
48 1526
49 - $item = $wpmcsItem->get($attachment_id);
1527 + $wpmcsItem = Item::instance();
50 1528
1529 + $item = $wpmcsItem->get($attachment_id, 'media_library');
1530 +
51 1531 if (Utils::is_empty($item)) {
52 1532 return;
53 1533 }
54 1534
55 - $provider = $wpmcsItem->get_field($attachment_id, 'provider');
1535 + $provider = $wpmcsItem->get_field($attachment_id, 'provider', 'media_library');
56 1536 if($provider) {
57 1537 $label = Schema::getServiceLabels($provider); ?>
58 1538 <div class="misc-pub-section misc-pub-provider">
59 - <?php esc_html_e( 'Provider :', 'media-cloud-sync' ); ?> <strong><?php echo esc_textarea(!empty($label) ? $label : $provider); ?></strong></a>
1539 + <?php esc_html_e( 'Provider :', 'media-cloud-sync' ); ?> <strong><?php echo esc_textarea(!empty($label) ? $label : $provider); ?></strong>
60 1540 </div>
61 1541 <?php
62 1542 }
63 1543
64 - $region = $wpmcsItem->get_field($attachment_id, 'region');
1544 + $region = $wpmcsItem->get_field($attachment_id, 'region', 'media_library');
65 1545 if($region) { ?>
66 1546 <div class="misc-pub-section misc-pub-provider">
67 - <?php esc_html_e( 'Region :', 'media-cloud-sync' ); ?> <strong><?php echo esc_textarea($region); ?></strong></a>
1547 + <?php esc_html_e( 'Region :', 'media-cloud-sync' ); ?> <strong><?php echo esc_textarea($region); ?></strong>
68 1548 </div>
69 1549 <?php
70 1550 }
71 - $private = (int)$wpmcsItem->get_field($attachment_id, 'is_private');
1551 + $private = (int)$wpmcsItem->get_field($attachment_id, 'is_private', 'media_library');
72 1552 ?>
73 1553 <div class="misc-pub-section misc-pub-provider">
74 - <?php esc_html_e( 'Access :', 'media-cloud-sync' ); ?> <strong><?php $private ? esc_html_e( 'Private', 'media-cloud-sync' ) : esc_html_e( 'Public', 'media-cloud-sync' ); ?></strong></a>
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>
75 1555 </div>
76 1556 <?php
77 1557 }
78 1558
@@ -78,8 +1558,10 @@
78 1558
79 1559
80 1560 /**
81 1561 * Function to get attachment details by ID
1562 + *
1563 + * @since 1.3.12 Added capability checks for attachment access.
82 1564 */
83 1565 public function ajax_get_attachment_details() {
84 1566 $result = array(
85 1567 'status' => false,
@@ -86,8 +1568,12 @@
86 1568 'data' => array(),
87 1569 'exclude' => false
88 1570 );
89 1571
1572 + if(!Utils::is_service_enabled()) {
1573 + wp_send_json_success( $result );
1574 + }
1575 +
90 1576 if ( ! isset( $_POST['id'] ) ) {
91 1577 wp_send_json_success( $result );
92 1578 }
93 1579
@@ -92,11 +1578,19 @@
92 1578 }
93 1579
94 1580 check_ajax_referer( 'get_media_provider_details', '_nonce' );
95 1581
96 - $id= intval( sanitize_text_field( $_POST['id'] ) );
97 - global $wpmcsItem;
1582 + if ( ! current_user_can( 'upload_files' ) ) {
1583 + wp_send_json_error();
1584 + }
98 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 +
99 1593 // Return if extension not allowed
100 1594 $path = get_attached_file( $id );
101 1595 if(!Utils::is_extension_available($path)) {
102 1596 $result['exclude'] = true;
@@ -106,24 +1600,26 @@
106 1600 if (!Utils::is_ok_to_serve($id)) {
107 1601 wp_send_json_success( $result );
108 1602 }
109 1603
110 - $item = $wpmcsItem->get($id);
1604 + $wpmcsItem = Item::instance();
111 1605
1606 + $item = $wpmcsItem->get($id, 'media_library');
1607 +
112 1608 if (Utils::is_empty($item)) {
113 1609 wp_send_json_success( $result );
114 1610 }
115 1611
116 - $provider = $wpmcsItem->get_field($id, 'provider');
1612 + $provider = $wpmcsItem->get_field($id, 'provider', 'media_library');
117 1613 if($provider){
118 1614 $label = Schema::getServiceLabels($provider);
119 1615 $item['provider'] = !empty($label) ? $label : $provider;
120 1616 }
121 - $region = $wpmcsItem->get_field($id, 'region');
1617 + $region = $wpmcsItem->get_field($id, 'region', 'media_library');
122 1618 if($region){
123 1619 $item['region'] = $region;
124 1620 }
125 - $item['private'] = $wpmcsItem->get_field($id, 'private');
1621 + $item['private'] = (int) $wpmcsItem->get_field($id, 'is_private', 'media_library');
126 1622 if($item) {
127 1623 $result= array(
128 1624 'status' => true,
129 1625 'data' => $item
@@ -131,8 +1627,80 @@
131 1627 }
132 1628
133 1629 wp_send_json_success( $result );
134 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 +
135 1703
136 1704
137 1705 public static function instance(){
138 1706 if (is_null(self::$instance)) {