PluginProbe
Media Cloud Sync / 1.4.2
Media Cloud Sync v1.4.2
1.4.2 1.4.1 1.4.0 1.3.12 1.3.11 1.3.10 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.2.0 1.2.10 1.2.11 1.2.12 1.2.13 1.2.2 1.2.3 1.2.4 1.2.5 1.2.6 1.2.7 1.2.8 All 36 releases
← All changes | includes/base/item.php +794 -212 1.2.13 → 1.4.2 View file →
@@ -18,8 +18,17 @@
18 18 protected $bucket_name;
19 19 protected $region = '';
20 20
21 21 /**
22 + * Absolute paths restored from cloud (by any integration) pending removal
23 + * again — fed into the pre-update pipeline if a save happens this request,
24 + * with a shutdown fallback otherwise. See track_restored_for_cleanup().
25 + * @since 1.4.0
26 + */
27 + protected $pending_restored_files = [];
28 + protected $pending_cleanup_hooked = false;
29 +
30 + /**
22 31 * Admin constructor.
23 32 * @since 1.0.0
24 33 */
25 34 public function __construct() {
@@ -44,16 +53,12 @@
44 53 ? $this->credentials['bucketConfig']
45 54 : [];
46 55 $this->service = isset($this->credentials['service']) && !empty($this->credentials['service'])
47 56 ? $this->credentials['service']
48 - : [];
57 + : '';
49 58
50 - if (isset($this->bucketConfig['bucket_name'])) {
51 - $this->bucket_name = $this->bucketConfig['bucket_name'];
52 - }
53 - if (isset($this->config['region'])) {
54 - $this->region = $this->config['region'];
55 - }
59 + $this->bucket_name = isset($this->bucketConfig['bucket_name']) ? $this->bucketConfig['bucket_name'] : '';
60 + $this->region = isset($this->config['region']) ? $this->config['region'] : '';
56 61 }
57 62
58 63 /**
59 64 * Add Item In database
@@ -64,9 +69,11 @@
64 69 $source_id,
65 70 $url,
66 71 $key,
67 72 $source_path,
68 - $meta,
73 + $original_source_path = '',
74 + $original_key = '',
75 + $meta = array(),
69 76 $source_type = 'media_library',
70 77 $is_private = 0
71 78 ) {
72 79 global $wpdb;
@@ -79,21 +86,53 @@
79 86 'source_path' => $source_path,
80 87 'source_type' => $source_type,
81 88 'url' => $url,
82 89 'key' => $key,
90 + 'original_source_path' => $original_source_path,
91 + 'original_key' => $original_key,
83 92 'is_private' => $is_private,
84 - 'extra' => maybe_serialize($meta),
93 + 'extra' => Utils::maybe_serialize($meta),
85 94 );
86 95
87 96 // Do some pre-update actions
88 - $this->pre_update_item($source_id, $data, $source_type);
97 + $this->pre_update_item($source_id, $data, [], $source_type);
89 98
90 - if ($wpdb->insert(Db::get_table_name(), $data)) {
91 - $item_id = $wpdb->insert_id;
99 + // Upsert instead of a plain insert — two independent triggers (this plugin's bulk
100 + // sync, WordPress's own metadata hook, Imagify's re-sync) can land on the same
101 + // attachment around the same time; this converges on one row instead of erroring.
102 + $table = Db::get_table_name();
103 + $inserted = $wpdb->query($wpdb->prepare(
104 + "INSERT INTO {$table}
105 + (provider, region, storage, source_id, source_path, source_type, url, `key`, original_source_path, original_key, is_private, extra)
106 + VALUES (%s, %s, %s, %d, %s, %s, %s, %s, %s, %s, %d, %s)
107 + ON DUPLICATE KEY UPDATE
108 + id = LAST_INSERT_ID(id),
109 + source_path = VALUES(source_path),
110 + url = VALUES(url),
111 + `key` = VALUES(`key`),
112 + original_source_path = VALUES(original_source_path),
113 + original_key = VALUES(original_key),
114 + is_private = VALUES(is_private),
115 + extra = VALUES(extra)",
116 + $data['provider'], $data['region'], $data['storage'], $data['source_id'], $data['source_path'],
117 + $data['source_type'], $data['url'], $data['key'], $data['original_source_path'], $data['original_key'],
118 + $data['is_private'], $data['extra']
119 + ));
120 +
121 + if ($inserted !== false) {
122 + // rows_affected: 1 = new row, 2 = existing row updated. Capture before any
123 + // other query on $wpdb overwrites it.
124 + $is_new_row = (int) $wpdb->rows_affected === 1;
125 +
126 + $item_id = (int) $wpdb->insert_id;
92 127 $data['id'] = $item_id;
93 128 Integration::update_meta($source_id, 'item', $data, false, false, $source_type);
94 129 Cache::update_item_cache($source_id.'_item_'.$source_type, $data);
95 - Counter::add( 'uploaded', $source_type );
130 +
131 + // Only count a genuinely new item, not a duplicate-collision update.
132 + if ($is_new_row) {
133 + Counter::add( 'uploaded', $source_type );
134 + }
96 135 }
97 136
98 137 // Do some post-update actions
99 138 $this->post_update_item($source_id, $data, $source_type);
@@ -111,9 +150,22 @@
111 150 if($item === false) {
112 151 $item = Integration::get_meta($source_id, 'item', false, false, false, $source_type);
113 152 if($item == false){
114 153 $item_table = Db::get_table_name();
115 - $item = $wpdb->get_row("SELECT * FROM ".$item_table." WHERE source_id = $source_id AND source_type='$source_type'", ARRAY_A);
154 + $query = "SELECT * FROM {$item_table}
155 + WHERE source_id = %d
156 + AND source_type = %s
157 + AND provider = %s
158 + AND storage = %s";
159 +
160 + if(!empty($this->region)) {
161 + $query .= " AND region = %s";
162 + $query = $wpdb->prepare($query, $source_id, $source_type, $this->service, $this->bucket_name, $this->region);
163 + } else {
164 + $query = $wpdb->prepare($query, $source_id, $source_type, $this->service, $this->bucket_name);
165 + }
166 +
167 + $item = $wpdb->get_row( $query, ARRAY_A);
116 168
117 169 if($wpdb->last_error || null === $item || !(isset($item) && !empty($item))) {
118 170 Cache::update_item_cache($source_id.'_item_'.$source_type, '');
119 171 return false;
@@ -124,9 +176,16 @@
124 176 // Update cache
125 177 Cache::update_item_cache($source_id.'_item_'.$source_type, $item == false ? '' : $item);
126 178 }
127 179
128 - return !empty($item) ? $item : false;
180 + /**
181 + * Filter to modify item data when retrieved
182 + * @param array|false $item
183 + * @param int $source_id
184 + * @param string $source_type
185 + * @since 1.3.5
186 + */
187 + return apply_filters( 'wpmcs_get_item' , !empty($item) ? $item : false, $source_id, $source_type );
129 188 }
130 189
131 190 /**
132 191 * Function to delete item from data base
@@ -134,9 +193,22 @@
134 193 */
135 194 public function delete($source_id, $source_type = 'media_library'){
136 195 global $wpdb;
137 196 if (isset($source_id) && !Utils::is_empty($source_id)) {
138 - $rows = $wpdb->delete(Db::get_table_name(), array('source_id' => $source_id, 'source_type' => $source_type));
197 + // Delete attachments by item from cloud
198 + $item = $this->get($source_id, $source_type);
199 + $this->delete_attachments_by_item($item);
200 +
201 + $where = array(
202 + 'source_id' => $source_id,
203 + 'source_type' => $source_type,
204 + 'provider' => $this->service,
205 + 'storage' => $this->bucket_name,
206 + );
207 + if(!empty($this->region)) {
208 + $where['region'] = $this->region;
209 + }
210 + $rows = $wpdb->delete( Db::get_table_name(), $where );
139 211 if ($wpdb->last_error || false === $rows) {
140 212 return false;
141 213 }
142 214 Integration::delete_meta($source_id, 'item', false, $source_type);
@@ -141,8 +213,12 @@
141 213 }
142 214 Integration::delete_meta($source_id, 'item', false, $source_type);
143 215 Cache::delete_item_cache($source_id.'_item_'.$source_type);
144 216 Counter::remove( 'uploaded', $source_type );
217 +
218 + // Remove logs by media ID & source type
219 + Logger::instance()->remove_log_by_media_id($source_id, $source_type);
220 +
145 221 return true;
146 222 }
147 223 return false;
148 224 }
@@ -150,21 +226,69 @@
150 226
151 227 /**
152 228 * Function to update item in data base
153 229 * @since 1.0.0
230 + * @param array|null $target_identity Optional ['provider'=>, 'storage'=>, 'region'=>] to
231 + * move the row to a different connection's identity.
232 + * The WHERE clause still uses this instance's own
233 + * (source) binding to locate the row — only the SET
234 + * values change. @since 1.4.0
154 235 */
155 - public function update($source_id, $data, $source_type = 'media_library') {
236 + public function update($source_id, $data, $source_type = 'media_library', $target_identity = null) {
156 237 global $wpdb;
157 238 if (isset($source_id) && !Utils::is_empty($source_id)) {
239 + $data['provider'] = $target_identity['provider'] ?? $this->service;
240 + $data['storage'] = $target_identity['storage'] ?? $this->bucket_name;
241 + $data['region'] = $target_identity['region'] ?? $this->region;
158 242
159 - $data['provider'] = $this->service;
160 - $data['region'] = $this->region;
161 - $data['storage'] = $this->bucket_name;
243 + $old_item = $this->get($source_id, $source_type);
162 244
245 + // update data from $old_item if not exists in $data
246 + if (isset($old_item) && !empty($old_item)) {
247 + foreach ($old_item as $key => $value) {
248 + if (!isset($data[$key])) {
249 + $data[$key] = $value;
250 + }
251 + }
252 + }
253 +
163 254 // Do some pre-update actions
164 - $this->pre_update_item($source_id, $data, $source_type);
255 + $this->pre_update_item($source_id, $data, $old_item, $source_type);
165 256
166 - $rows = $wpdb->update(Db::get_table_name(), $data, array('source_id' => $source_id, 'source_type' => $source_type));
257 + // Moving to a different identity (e.g. bucket_to_bucket migration) can collide
258 + // with a stale row already sitting at that target identity — e.g. an interrupted
259 + // earlier migration attempt, or the same item independently tracked under a
260 + // connection this site used previously. uidx_item_source is UNIQUE on
261 + // (source_id, source_type, provider, storage, region), so the UPDATE below would
262 + // otherwise fail outright. The row being updated here is the current, live one;
263 + // a pre-existing row already at the target is stale by definition — clear it
264 + // first rather than letting the whole update silently fail.
265 + $is_identity_move = $target_identity !== null && (
266 + $data['provider'] !== $this->service ||
267 + $data['storage'] !== $this->bucket_name ||
268 + $data['region'] !== $this->region
269 + );
270 + if ($is_identity_move) {
271 + $target_where = array(
272 + 'source_id' => $source_id,
273 + 'source_type' => $source_type,
274 + 'provider' => $data['provider'],
275 + 'storage' => $data['storage'],
276 + 'region' => $data['region'],
277 + );
278 + $wpdb->delete(Db::get_table_name(), $target_where);
279 + }
280 +
281 + $where = array(
282 + 'source_id' => $source_id,
283 + 'source_type' => $source_type,
284 + 'provider' => $this->service,
285 + 'storage' => $this->bucket_name,
286 + );
287 + if(!empty($this->region)) {
288 + $where['region'] = $this->region;
289 + }
290 + $rows = $wpdb->update(Db::get_table_name(), $data, $where);
167 291 if ($wpdb->last_error || false === $rows) {
168 292 return false;
169 293 }
170 294
@@ -170,10 +294,14 @@
170 294
171 295 Integration::delete_meta($source_id, 'item', false, $source_type);
172 296 Cache::delete_item_cache($source_id.'_item_'.$source_type);
173 297
174 - // Reset cache
175 - $this->get($source_id, $source_type);
298 + if ($target_identity === null) {
299 + // Reset cache — skipped when moving to a different identity: this instance's
300 + // get() would search under the now-stale source identity and cache a false
301 + // negative; the delete_item_cache() above is sufficient on its own there.
302 + $this->get($source_id, $source_type);
303 + }
176 304 // Do some post-update actions
177 305 $this->post_update_item($source_id, $data, $source_type);
178 306
179 307 return true;
@@ -245,79 +373,217 @@
245 373 if(Utils::is_empty($item)) {
246 374 return false;
247 375 }
248 376
249 - if ($item['provider'] == $this->service) {
377 + if (
378 + $item['provider'] == $this->service &&
379 + $item['storage'] == $this->bucket_name &&
380 + $item['source_type'] == $source_type
381 + ) {
250 382 if(
251 383 ($check_rewrite && (isset($this->settings['rewrite_url']) && $this->settings['rewrite_url'])) ||
252 384 !$check_rewrite
253 385 ) {
386 + if ($check_rewrite) {
387 + return (bool) apply_filters('wpmcs_is_available_from_provider', true, $attachment_id, $source_type);
388 + }
254 389 return true;
255 - }
390 + }
256 391 }
257 392 return false;
258 393 }
259 394
260 395 /**
261 - * Get items by paths
396 + * Get items by source paths
397 + *
398 + * @param array|string $paths
399 + * @param bool $exact_match Use exact paths or greedy match
400 + * @param bool $first_only Return only the first matched item
401 + *
402 + * @return array
262 403 */
263 - public function get_items_by_source_paths( $paths = [] ) {
404 + public function get_items_by_paths( $paths, $exact_match = true, $first_only = false, $type = 'source' ) {
264 405 global $wpdb;
265 406
266 - $items = [];
407 + if ( ! is_array( $paths ) && is_string( $paths ) && ! empty( $paths ) ) {
408 + $paths = [ $paths ];
409 + }
267 410
268 - if (!Utils::is_empty($paths)) {
269 - // Ensure paths are properly sanitized and ready for the query
270 - $like_conditions = array();
271 - foreach ($paths as $path) {
272 - // Prepare the SQL conditions for exact match in source_path and partial match in extras column
273 - $like_conditions[] = $wpdb->prepare("(source_path = %s OR extra LIKE %s)", $path, '%' . $wpdb->esc_like($path) . '%');
274 - }
411 + if ( Utils::is_empty( $paths ) ) {
412 + return [];
413 + }
275 414
276 - // Combine the conditions with OR
277 - $where_clause = implode(' OR ', $like_conditions);
415 + /**
416 + * Field => Index mapping
417 + * field_name => index_name
418 + */
419 + switch ( $type ) {
420 + case 'key':
421 + $fields = [
422 + 'key' => 'uidx_key',
423 + 'original_key' => 'uidx_original_key',
424 + ];
425 + break;
278 426
279 - // Execute the SQL query to fetch source_id instead of source_path
280 - $item_table = Db::get_table_name();
281 - $sql = "SELECT DISTINCT source_id, source_type FROM " . $item_table . " WHERE " . $where_clause;
282 - $results = $wpdb->get_results($sql, ARRAY_A);
427 + default:
428 + $fields = [
429 + 'source_path' => 'uidx_source_path',
430 + 'original_source_path' => 'uidx_original_source_path',
431 + ];
432 + break;
433 + }
283 434
284 - if ($wpdb->last_error || Utils::is_empty($results)) {
285 - return [];
435 + if ( empty( $fields ) ) {
436 + return [];
437 + }
438 +
439 + // Normalize & deduplicate
440 + $paths = array_unique( $paths );
441 +
442 + $table = Db::get_table_name();
443 +
444 + // Build USE INDEX clause from field map
445 + $index_list = implode( ', ', array_values( $fields ) );
446 +
447 + $sql = "
448 + SELECT DISTINCT source_id, source_type
449 + FROM {$table} USE INDEX ({$index_list})
450 + WHERE provider = %s
451 + AND storage = %s
452 + ";
453 +
454 + $params = [
455 + $this->service,
456 + $this->bucket_name,
457 + ];
458 +
459 + // Optional region
460 + if ( ! empty( $this->region ) ) {
461 + $sql .= " AND region = %s";
462 + $params[] = $this->region;
463 + }
464 +
465 + /**
466 + * Path conditions
467 + */
468 + $conditions = [];
469 +
470 + if ( $exact_match ) {
471 + $placeholders = implode( ',', array_fill( 0, count( $paths ), '%s' ) );
472 +
473 + foreach ( array_keys( $fields ) as $column ) {
474 + $conditions[] = "`{$column}` IN ({$placeholders})";
475 + foreach ( $paths as $path ) {
476 + $params[] = $path;
477 + }
286 478 }
479 + } else {
480 + foreach ( $paths as $path ) {
481 + $ext = pathinfo( $path, PATHINFO_EXTENSION );
482 + $base = $ext
483 + ? substr_replace( $path, '%', -strlen( $ext ) - 1 )
484 + : $path . '%';
287 485
288 - $source_ids = array();
289 - foreach ($results as $row) {
290 - $item = $this->get((int)$row['source_id'], $row['source_type']);
291 - if(!Utils::is_empty($item)) {
292 - $items[] = $item;
486 + foreach ( array_keys( $fields ) as $column ) {
487 + $conditions[] = "`{$column}` LIKE %s";
488 + $params[] = $base;
293 489 }
294 490 }
295 - return $items;
296 491 }
492 +
493 + if ( ! empty( $conditions ) ) {
494 + $sql .= " AND ( " . implode( ' OR ', $conditions ) . " )";
495 + }
496 +
497 + // First only
498 + if ( $first_only ) {
499 + $sql .= " ORDER BY source_id ASC LIMIT 1";
500 + }
501 +
502 + $prepared = $wpdb->prepare( $sql, $params );
503 + $results = $wpdb->get_results( $prepared, ARRAY_A );
504 +
505 + if ( $wpdb->last_error || empty( $results ) ) {
506 + return [];
507 + }
508 +
509 + // Hydration
510 + if ( $first_only ) {
511 + return $this->get(
512 + (int) $results[0]['source_id'],
513 + $results[0]['source_type']
514 + );
515 + }
516 +
517 + $items = [];
518 +
519 + foreach ( $results as $row ) {
520 + $item = $this->get(
521 + (int) $row['source_id'],
522 + $row['source_type']
523 + );
524 +
525 + if ( ! Utils::is_empty( $item ) ) {
526 + $items[] = $item;
527 + }
528 + }
529 +
297 530 return $items;
298 531 }
299 532
300 533
301 534 /**
302 - * Get similar existing files like origin path
535 + * Get similar existing files by source path prefix
536 + * Searches in source_path and original_source_path
537 + *
303 538 * @since 1.0.0
304 539 */
305 - public function get_similar_files_by_path($path){
540 + public function get_similar_files_by_path( $path ) {
306 541 global $wpdb;
307 - if (isset($path) && !empty($path)) {
308 - $item_table = Db::get_table_name();
309 - $results = $wpdb->get_results("SELECT source_path FROM " . $item_table . " WHERE source_path LIKE '$path%'", ARRAY_A);
310 - if ($wpdb->last_error || null === $results || !(isset($results) && !empty($results))) {
311 - return false;
312 - }
313 - $source_paths = array();
314 - foreach ($results as $row) {
315 - $source_paths[] = $row['source_path'];
316 - }
317 - return $source_paths;
542 +
543 + if ( Utils::is_empty( $path ) ) {
544 + return false;
318 545 }
319 - return false;
546 +
547 + $table = Db::get_table_name();
548 + $like = $wpdb->esc_like( $path ) . '%';
549 +
550 + $base_where = "
551 + provider = %s
552 + AND storage = %s
553 + " . ( ! empty( $this->region ) ? "AND region = %s" : '' );
554 +
555 + $params = [ $this->service, $this->bucket_name ];
556 + if ( ! empty( $this->region ) ) {
557 + $params[] = $this->region;
558 + }
559 +
560 + $sql = "
561 + (
562 + SELECT source_path
563 + FROM {$table} USE INDEX (idx_source_path_provider)
564 + WHERE {$base_where}
565 + AND source_path LIKE %s
566 + )
567 + UNION DISTINCT
568 + (
569 + SELECT original_source_path AS source_path
570 + FROM {$table} USE INDEX (uidx_original_source_path)
571 + WHERE {$base_where}
572 + AND original_source_path LIKE %s
573 + )
574 + ";
575 +
576 + $params = array_merge( $params, [ $like ], $params, [ $like ] );
577 +
578 + $results = $wpdb->get_results(
579 + $wpdb->prepare( $sql, $params ),
580 + ARRAY_A
581 + );
582 +
583 + return ( $wpdb->last_error || empty( $results ) )
584 + ? false
585 + : array_column( $results, 'source_path' );
320 586 }
321 587
322 588
323 589 /**
@@ -326,25 +592,21 @@
326 592 * @param
327 593 */
328 594 public function get_url($source_id, $size = 'full', $source_type = 'media_library'){
329 595 if ($data = $this->get($source_id, $source_type)) {
330 - $extras = $this->get_extras($source_id, false, $source_type) ?: [];
331 - $key = '';
332 - $url = '';
596 + $key = '';
333 597 switch($size) {
334 598 case 'full':
335 599 $key = $data['key'];
336 - $url = $data['url'];
337 600 break;
338 601 case 'original':
339 - if(
340 - isset($extras) && !empty($extras) &&
341 - isset($extras['original']) && !empty($extras['original'])
342 - ) {
343 - $key = $extras['original']['key'];
344 - $url = $extras['original']['url'];
602 + if( isset($data['original_key']) && !empty($data['original_key']) ) {
603 + $key = $data['original_key'];
345 604 }
605 + break;
346 606 default:
607 + // Only named sizes need extras — skip fetching them for 'full'/'original'.
608 + $extras = $this->get_extras($source_id, false, $source_type) ?: [];
347 609 if(
348 610 isset($extras) && !empty($extras) &&
349 611 isset($extras['sizes']) && !empty($extras['sizes']) &&
350 612 isset($extras['sizes'][$size]) && !empty($extras['sizes'][$size])
@@ -349,34 +611,36 @@
349 611 isset($extras['sizes']) && !empty($extras['sizes']) &&
350 612 isset($extras['sizes'][$size]) && !empty($extras['sizes'][$size])
351 613 ) {
352 614 $key = $extras['sizes'][$size]['key'];
353 - $url = $extras['sizes'][$size]['url'];
354 615 }
355 616 }
356 617
357 618 if(!empty($key)){
358 - if (
359 - isset($this->settings['enable_presigned']) && $this->settings['enable_presigned'] &&
360 - isset($this->settings['presigned_expire']) && !empty($this->settings['presigned_expire'])
361 - ) {
362 - $preSignedUrl = Integration::get_meta( $source_id, 'presigned_url_'.$size, false, false, false, $source_type );
363 - if ($preSignedUrl === false) {
364 - $new_url = Service::instance()->get_presigned_url($key);
619 + if (isset($data['is_private']) && $data['is_private']) {
620 + $privateUrl = Integration::get_meta( $source_id, 'private_url_'.$size, false, false, false, $source_type );
621 + if ($privateUrl === false) {
622 + $new_url = Service::instance()->get_private_url($key);
365 623
366 624 if (!Utils::is_empty($new_url)) {
367 - $preSignedUrl = Cdn::may_generate_cdn_url($new_url, $key);
368 - $expireMinutes = (int)(isset($this->settings['presigned_expire']) && !empty($this->settings['presigned_expire']))
369 - ? $this->settings['presigned_expire']
625 + // No hook (Pro inactive, or the current delivery provider hasn't
626 + // implemented one) means passthrough — same URL, unmodified. Real
627 + // per-CDN rewriting (e.g. CloudFront signed URLs) is a Pro concern.
628 + $privateUrl = apply_filters( 'wpmcs_generate_private_url', $new_url, $key );
629 + $expireMinutes = (int)(isset($this->settings['private_url_expire']) && !empty($this->settings['private_url_expire']))
630 + ? $this->settings['private_url_expire']
370 631 : 20;
371 632 $expireSeconds = $expireMinutes * 60;
372 633
373 - Integration::update_meta($source_id, 'presigned_url_'.$size, $preSignedUrl, false, $expireSeconds, $source_type);
634 + Integration::update_meta($source_id, 'private_url_'.$size, $privateUrl, false, $expireSeconds, $source_type);
374 635 }
375 636 }
376 - return $preSignedUrl;
637 + return $privateUrl;
377 638 } else {
378 - return Cdn::may_generate_cdn_url($url, $key);
639 + $url = Service::instance()->get_url($key);
640 + if(!Utils::is_empty($url)) {
641 + return Cdn::may_generate_cdn_url($url, $key);
642 + }
379 643 }
380 644 }
381 645 }
382 646 return false;
@@ -382,144 +646,323 @@
382 646 return false;
383 647 }
384 648
385 649
386 - /**
387 - * Get service path of item from database
388 - * @since 1.0.0
389 - * @param
650 +
651 + /**
652 + * Move file to server, given item id and size
653 + * If $all is true, it will move all files to server
654 + * If $backup is true, it will move backup file to server
655 + * @param int $source_id source id of item
656 + * @param string $size size of the file, default is full
657 + * @param string $source_type source type of item, default is media_library
658 + * @param bool $all if true, it will move all files to server
659 + * @param bool $backup if true, it will move backup file to server
660 + * @param string $log_type error-log bucket to write to on failure — lets a caller other
661 + * than the "Restore to Server" job (e.g. "Remove from Cloud",
662 + * which also restores as a safety step) attribute failures to
663 + * its own error list instead of Restore to Server's.
664 + * @return array an array of server file paths
390 665 */
391 - public function moveToServer($source_id, $size = 'full', $all = false, $source_type = 'media_library') {
392 - $server_files = array();
666 + public function moveToServer($source_id, $size = 'full', $source_type = 'media_library', $all = false, $backup = false, $log_type = 'restore_to_server'){
667 + $server_files = [];
393 668 $server_file = false;
394 - $upload_dir = wp_get_upload_dir();
395 669 $source_id = (int)$source_id;
670 + $item = $this->get($source_id, $source_type);
396 671
397 - if ($data = $this->get($source_id, $source_type)) {
398 - $wpmcsService = Service::instance();
399 - if($all) {
400 - if (
401 - isset($data['source_path']) && !empty($data['source_path']) &&
402 - isset($data['key']) && !empty($data['key'])
403 - ) {
404 - $file_path = trailingslashit($upload_dir['basedir']) . $data['source_path'];
405 - if(file_exists($file_path) || $wpmcsService->object_to_server($data['key'], $file_path)) {
406 - $server_files['full'] = $file_path;
407 - }
672 + // Remove log if exists before move to server
673 + Logger::instance()->remove_log($log_type, $source_id, $source_type);
674 +
675 + if ( isset($item) && !empty($item) ) {
676 + $files = $this->moveToServerByItem($item, $size, $all, $log_type);
677 + if (isset($files) && !empty($files)) {
678 + $server_files = $all ? array_merge($server_files, $files) : $files;
679 + }
680 + }
681 + if( $all && $backup ) {
682 + $backupItem = $this->get_backup($source_id, $source_type);
683 + if (isset($backupItem) && !empty($backupItem)) {
684 + $files = $this->moveToServerByItem($backupItem, $size, $all, $log_type);
685 + if (isset($files) && !empty($files)) {
686 + $server_files['backup'] = $files;
408 687 }
409 - $extras = $this->get_extras($source_id, false, $source_type) ?: [];
410 - if (isset($extras['original']) && !empty($extras['original'])) {
411 - $original = $extras['original'];
412 - if(!empty($original)) {
413 - $original_file_path = trailingslashit($upload_dir['basedir']) . $original['source_path'];
414 - if(file_exists($original_file_path) || $wpmcsService->object_to_server($original['key'], $original_file_path)) {
415 - $server_files['original'] = $original_file_path;
416 - }
417 - }
688 + }
689 + }
690 + return $server_files;
691 + }
692 +
693 +
694 + /**
695 + * Copy back an item from the service to the server
696 + *
697 + * @since 1.0.0
698 + * @param array $item
699 + * @param string $size
700 + * @param bool $all
701 + * @param string $log_type error-log bucket to write to on failure
702 + * @return array|string
703 + */
704 + public function moveToServerByItem( $item = [], $size = 'full', $all = false, $log_type = 'restore_to_server' ) {
705 + $source_id = (int) ( $item['source_id'] ?? 0 );
706 + // Validate source ID
707 + if( $source_id <= 0 ) {
708 + return false;
709 + }
710 +
711 + $source_type = $item['source_type'] ?? 'media_library';
712 + $extras = ! empty( $item['extra'] ) ? Utils::maybe_unserialize( $item['extra'] ) : [];
713 +
714 + // Build file map once
715 + $files = [
716 + 'full' => [
717 + 'key' => $item['key'] ?? null,
718 + 'path' => $item['source_path'] ?? null,
719 + ],
720 + 'original' => [
721 + 'key' => $item['original_key'] ?? null,
722 + 'path' => $item['original_source_path'] ?? null,
723 + ],
724 + ];
725 +
726 + if ( ! empty( $extras['sizes'] ) ) {
727 + foreach ( $extras['sizes'] as $name => $data ) {
728 + $files[ $name ] = [
729 + 'key' => $data['key'] ?? null,
730 + 'path' => $data['source_path'] ?? null,
731 + ];
732 + }
733 + }
734 +
735 + if ( ! empty( $extras['additional_files'] ) ) {
736 + foreach ( $extras['additional_files'] as $name => $data ) {
737 + $files[ $name ] = [
738 + 'key' => $data['key'] ?? null,
739 + 'path' => $data['source_path'] ?? null,
740 + ];
741 + }
742 + }
743 +
744 + // ALL files
745 + if ( $all ) {
746 + $results = [];
747 +
748 + foreach ( $files as $label => $data ) {
749 + if ( $file = $this->move_to_server_by_key_and_path(
750 + $data['key'],
751 + $data['path'],
752 + $source_id,
753 + $source_type,
754 + $log_type
755 + ) ) {
756 + $results[ $label ] = $file;
418 757 }
419 - if (isset($extras['sizes']) && !empty($extras['sizes'])) {
420 - $sizes = $extras['sizes'];
421 - foreach($sizes as $sub_size => $sub_file) {
422 - if(!empty($sub_file)) {
423 - $sub_file_path = trailingslashit($upload_dir['basedir']) . $sub_file['source_path'];
424 - if(file_exists($sub_file_path) || $wpmcsService->object_to_server($sub_file['key'], $sub_file_path)) {
425 - $server_files[$sub_size] = $sub_file_path;
426 - }
427 - }
428 - }
429 - }
430 - return !empty($server_files) ? $server_files : false;
431 - } else {
432 - if($size === 'full') {
433 - if (
434 - isset($data['source_path']) && !empty($data['source_path']) &&
435 - isset($data['key']) && !empty($data['key'])
436 - ) {
437 - $file_path = trailingslashit($upload_dir['basedir']) . $data['source_path'];
438 - if(file_exists($file_path) || $wpmcsService->object_to_server($data['key'], $file_path)) {
439 - $server_file = $file_path;
440 - }
441 - }
442 - } else if($size === 'original') {
443 - $extras = $this->get_extras($source_id, false, $source_type) ?: [];
444 - if (isset($extras['original']) && !empty($extras['original'])) {
445 - $original = $extras['original'];
446 - if(!empty($original)) {
447 - $original_file_path = trailingslashit($upload_dir['basedir']) . $original['source_path'];
448 - if(file_exists($original_file_path) || $wpmcsService->object_to_server($original['key'], $original_file_path)) {
449 - $server_file = $original_file_path;
450 - }
451 - }
452 - }
453 - } else {
454 - $extras = $this->get_extras($source_id, false, $source_type) ?: [];
455 - if (isset($extras['sizes']) && !empty($extras['sizes'])) {
456 - $sizes = $extras['sizes'];
457 - if(isset($sizes[$size]) && !empty($sizes[$size])) {
458 - $sub_file_path = trailingslashit($upload_dir['basedir']) . $sizes[$size]['source_path'];
459 - if(file_exists($sub_file_path) || $wpmcsService->object_to_server($sizes[$size]['key'], $sub_file_path)) {
460 - $server_file = $sub_file_path;
461 - }
462 - }
463 - }
464 - }
465 - return $server_file;
466 758 }
759 +
760 + return $results;
467 761 }
762 +
763 + // SINGLE file
764 + if ( isset( $files[ $size ] ) ) {
765 + return $this->move_to_server_by_key_and_path(
766 + $files[ $size ]['key'],
767 + $files[ $size ]['path'],
768 + $source_id,
769 + $source_type,
770 + $log_type
771 + );
772 + }
773 +
468 774 return false;
469 775 }
470 -
471 776
472 777 /**
778 + * Whether a moveToServer(..., $all=true, $backup=true) result actually restored
779 + * everything this item is expected to have (every size, the original if present, and
780 + * the backup entry if one exists) — moveToServer()'s return silently drops any single
781 + * file that failed, so a plain non-empty check on it isn't enough to safely delete the
782 + * cloud copies afterward.
783 + * @since 1.4.1
784 + */
785 + public function verify_full_restore( $source_id, $source_type, $moved ) {
786 + $row = $this->get( $source_id, $source_type );
787 + if ( empty( $row ) ) {
788 + return false;
789 + }
790 +
791 + $expected = $this->expected_restore_labels( $row );
792 + $restored = array_diff( array_keys( (array) $moved ), [ 'backup' ] );
793 + if ( ! empty( array_diff( $expected, $restored ) ) ) {
794 + return false;
795 + }
796 +
797 + $backup_item = $this->get_backup( $source_id, $source_type );
798 + if ( empty( $backup_item ) ) {
799 + return true;
800 + }
801 +
802 + $expected_backup = $this->expected_restore_labels( $backup_item );
803 + $restored_backup = ! empty( $moved['backup'] ) ? array_keys( $moved['backup'] ) : [];
804 + return empty( array_diff( $expected_backup, $restored_backup ) );
805 + }
806 +
807 + /**
808 + * File labels (full, original, each named size, each additional file) a given item row is expected to have.
809 + */
810 + private function expected_restore_labels( $item_row ) {
811 + $expected = [ 'full' ];
812 + if ( ! empty( $item_row['original_key'] ) || ! empty( $item_row['original_source_path'] ) ) {
813 + $expected[] = 'original';
814 + }
815 +
816 + $extras = ! empty( $item_row['extra'] ) ? Utils::maybe_unserialize( $item_row['extra'] ) : [];
817 + if ( ! empty( $extras['sizes'] ) ) {
818 + $expected = array_merge( $expected, array_keys( $extras['sizes'] ) );
819 + }
820 + if ( ! empty( $extras['additional_files'] ) ) {
821 + $expected = array_merge( $expected, array_keys( $extras['additional_files'] ) );
822 + }
823 +
824 + return $expected;
825 + }
826 +
827 +
828 +
829 + /**
473 830 * Get service path of item from database by source url
474 831 * @since 1.0.0
475 - * @param
832 + * @param int $source_id
833 + * @param string $file
834 + * @param string $source_type
835 + * @return bool
476 836 */
477 - public function moveToServerBySourcePath($source_id, $file, $source_type = 'media_library'){
478 - $server_files = array();
479 - $server_file = false;
480 - $upload_dir = wp_get_upload_dir();
481 - $source_id = (int)$source_id;
837 + public function moveToServerBySourcePath( $source_id, $file, $source_type = 'media_library' ) {
838 + $source_id = (int) $source_id;
482 839
483 - if ($data = $this->get($source_id, $source_type)) {
484 - $source_path = Utils::get_attachment_source_path($file);
840 + $item = $this->get( $source_id, $source_type );
841 + if ( Utils::is_empty( $item ) ) {
842 + return false;
843 + }
485 844
486 - if( isset($data['source_path']) && !empty($data['source_path']) && $data['source_path'] == $source_path ) {
487 - $file_path = trailingslashit($upload_dir['basedir']) . $data['source_path'];
488 - if(file_exists($file_path) || Service::instance()->object_to_server($data['key'], $file_path)) {
845 + $source_path = Utils::get_attachment_source_path( $file );
846 + if ( empty( $source_path ) ) {
847 + return false;
848 + }
849 +
850 + // 1. Check main file
851 + if (
852 + isset( $item['source_path'] ) &&
853 + ! empty( $item['source_path'] ) &&
854 + $item['source_path'] === $source_path &&
855 + $this->move_to_server_by_key_and_path(
856 + $item['key'] ?? null,
857 + $item['source_path'],
858 + $source_id,
859 + $source_type
860 + )
861 + ) {
862 + return true;
863 + }
864 +
865 +
866 + // 2. Check original
867 + if (
868 + isset( $item['original_source_path'] ) && ! empty( $item['original_source_path'] ) &&
869 + $item['original_source_path'] === $source_path &&
870 + $this->move_to_server_by_key_and_path(
871 + $item['original_key'] ?? null,
872 + $item['original_source_path'],
873 + $source_id,
874 + $source_type
875 + )
876 + ) {
877 + return true;
878 + }
879 +
880 + $extras = $this->get_extras( $source_id, false, $source_type ) ?: [];
881 +
882 + // 3. Check sizes
883 + if ( ! empty( $extras['sizes'] ) ) {
884 + foreach ( $extras['sizes'] as $size ) {
885 + if (
886 + isset( $size['source_path'] ) &&
887 + ! empty( $size['source_path'] ) &&
888 + $size['source_path'] === $source_path &&
889 + $this->move_to_server_by_key_and_path(
890 + $size['key'] ?? null,
891 + $size['source_path'],
892 + $source_id,
893 + $source_type
894 + )
895 + ) {
489 896 return true;
490 897 }
491 - } else {
492 - // Check in extras
493 - $extras = $this->get_extras($source_id, false, $source_type) ?: [];
494 - if (isset($extras['original']) && !empty($extras['original']) && $extras['original']['source_path'] == $source_path) {
495 - $original = $extras['original'];
496 - if(!empty($original)) {
497 - $original_file_path = trailingslashit($upload_dir['basedir']) . $original['source_path'];
498 - if(file_exists($original_file_path) || Service::instance()->object_to_server($original['key'], $original_file_path)) {
499 - return true;
500 - }
501 - }
502 - }
898 + }
899 + }
503 900
504 - // Check in sizes
505 - $sizes = isset($extras['sizes']) && !empty($extras['sizes']) ? $extras['sizes'] : [];
506 - if(isset($sizes) && !empty($sizes)) {
507 - foreach($sizes as $sub_size => $sub_file) {
508 - if(!empty($sub_file) && $sub_file['source_path'] == $source_path) {
509 - $sub_file_path = trailingslashit($upload_dir['basedir']) . $sub_file['source_path'];
510 - if(file_exists($sub_file_path) || Service::instance()->object_to_server($sub_file['key'], $sub_file_path)) {
511 - return true;
512 - }
513 - }
514 - }
901 + // 4. Check additional files (HEIC source, animated-GIF video/poster)
902 + if ( ! empty( $extras['additional_files'] ) ) {
903 + foreach ( $extras['additional_files'] as $additional_file ) {
904 + if (
905 + isset( $additional_file['source_path'] ) &&
906 + ! empty( $additional_file['source_path'] ) &&
907 + $additional_file['source_path'] === $source_path &&
908 + $this->move_to_server_by_key_and_path(
909 + $additional_file['key'] ?? null,
910 + $additional_file['source_path'],
911 + $source_id,
912 + $source_type
913 + )
914 + ) {
915 + return true;
515 916 }
516 917 }
517 918 }
919 +
518 920 return false;
519 921 }
520 922
923 + /**
924 + * Copy back a file from the service to the server
925 + *
926 + * @param string $key
927 + * @param string $relative_path
928 + * @param int $source_id
929 + * @param string $source_type
930 + * @param string $log_type error-log bucket to write to on failure
931 + *
932 + * @return string|false
933 + */
934 + protected function move_to_server_by_key_and_path( $key, $relative_path, $source_id = 0, $source_type = 'media_library', $log_type = 'restore_to_server' ) {
935 + if ( empty( $key ) || empty( $relative_path ) ) {
936 + return false;
937 + }
521 938
939 + $upload_dir = wp_get_upload_dir();
940 + $file = trailingslashit( $upload_dir['basedir'] ) . $relative_path;
941 +
942 + if ( file_exists( $file ) ) {
943 + return $file;
944 + }
945 +
946 + // Checked on disk rather than trusting the return value alone — at least one
947 + // provider (Cloudflare R2) has been observed writing the file successfully while
948 + // still reporting failure (an SDK-level error thrown after the save completes).
949 + Service::instance()->object_to_server( $key, $file );
950 +
951 + if ( file_exists( $file ) ) {
952 + return $file;
953 + }
954 +
955 + Logger::instance()->add_log( $log_type, $source_id, $source_type, [
956 + 'message' => __( 'The file could not be copied to the server. Please try again.', 'media-cloud-sync' ),
957 + 'file' => $key,
958 + 'code' => 404,
959 + ] );
960 +
961 + return false;
962 + }
963 +
964 +
522 965 /**
523 966 * Move original file to server
524 967 *
525 968 * @param int $source_id
@@ -529,13 +972,15 @@
529 972 public function moveOriginalToServer($source_id, $source_type = 'media_library') {
530 973 $data = $this->get($source_id, $source_type);
531 974 if ($data) {
532 975 $size = 'full';
533 - $original = $this->get_extras($source_id, 'original', $source_type);
534 - if ($original && isset($original['key']) && !empty($original['key'])) {
976 + if (
977 + isset($data['original_source_path']) && !empty($data['original_source_path']) &&
978 + isset($data['original_key']) && !empty($data['original_key'])
979 + ) {
535 980 $size = 'original';
536 981 }
537 - return $this->moveToServer($source_id, $size, false, $source_type);
982 + return $this->moveToServer($source_id, $size, $source_type);
538 983 }
539 984 return false;
540 985 }
541 986
@@ -543,8 +988,13 @@
543 988 /**
544 989 * Delete media item
545 990 */
546 991 public function delete_attachments_by_item($item, $delete_backup = true) {
992 + // Lets an integration veto the delete when another row still relies on the same key.
993 + if (!apply_filters('wpmcs_should_delete_cloud_files', true, $item)) {
994 + return;
995 + }
996 +
547 997 $upload_dir = wp_get_upload_dir();
548 998
549 999 if (isset($item['extra']) && !empty($item['extra'])) {
550 1000 $extras = Utils::maybe_unserialize($item['extra']);
@@ -560,11 +1010,15 @@
560 1010 }
561 1011
562 1012 if (
563 1013 isset($extras) && !empty($extras) &&
564 - isset($extras['original']) && !empty($extras['original'])
1014 + isset($extras['additional_files']) && !empty($extras['additional_files'])
565 1015 ) {
566 - Service::instance()->deleteSingle($extras['original']['key']);
1016 + foreach ($extras['additional_files'] as $file) {
1017 + if (isset($file['key']) && !empty($file['key'])) {
1018 + Service::instance()->deleteSingle($file['key']);
1019 + }
1020 + }
567 1021 }
568 1022
569 1023 if (
570 1024 isset($extras) && !empty($extras) &&
@@ -576,8 +1030,15 @@
576 1030 $this->delete_attachments_by_item($backup, false);
577 1031 }
578 1032 }
579 1033 }
1034 +
1035 + if (
1036 + isset($item['original_key']) && !empty($item['original_key'])
1037 + ) {
1038 + Service::instance()->deleteSingle($item['original_key']);
1039 + }
1040 +
580 1041 if (isset($item['key']) && !empty($item['key'])) {
581 1042 Service::instance()->deleteSingle($item['key']);
582 1043 }
583 1044 }
@@ -583,8 +1044,25 @@
583 1044 }
584 1045
585 1046
586 1047 /**
1048 + * Delete Cloud Files by Keys
1049 + * @since 1.3.6
1050 + */
1051 + public function delete_cloud_files_by_keys( $keys = [] ) {
1052 + if (Utils::is_empty($keys) || !is_array($keys)) {
1053 + return false;
1054 + }
1055 +
1056 + foreach ($keys as $key) {
1057 + Service::instance()->deleteSingle( $key );
1058 + }
1059 + return true;
1060 + }
1061 +
1062 +
1063 +
1064 + /**
587 1065 * Pre-update item actions
588 1066 * @since 1.2.13
589 1067 * @param int $source_id
590 1068 * @param array $data
@@ -589,11 +1067,19 @@
589 1067 * @param int $source_id
590 1068 * @param array $data
591 1069 * @param string $source_type
592 1070 */
593 - public function pre_update_item($source_id, $data, $source_type = 'media_library') {
1071 + public function pre_update_item($source_id, $new_item, $old_item = [], $source_type = 'media_library') {
594 1072 // Hook for pre-update actions
595 - do_action('wpmcs_pre_update_item', $source_id, $data, $source_type);
1073 + do_action('wpmcs_pre_update_item', $source_id, $new_item, $old_item, $source_type);
1074 +
1075 + // Additional filter to modify files to be removed from server if needed
1076 + $files_to_remove = apply_filters('wpmcs_pre_update_item_additional_files_to_remove_from_server', [], $source_id, $new_item, $old_item, $source_type);
1077 +
1078 + // Delete files if any
1079 + if (!Utils::is_empty($files_to_remove)) {
1080 + $this->may_be_delete_server_files_by_source_paths($files_to_remove);
1081 + }
596 1082 }
597 1083
598 1084
599 1085 /**
@@ -616,12 +1102,79 @@
616 1102 public function post_update_item($source_id, $data, $source_type = 'media_library') {
617 1103 // Hook for post-update actions
618 1104 do_action('wpmcs_post_update_item', $source_id, $data, $source_type);
619 1105
1106 + // May be delete server files
620 1107 $this->may_be_delete_server_files_by_id($source_id, $source_type, true, true);
621 1108 }
622 1109
623 1110 /**
1111 + * Track paths restored from cloud (by any integration) so they get removed
1112 + * again later, honoring "Remove from server" the way the normal sync pipeline
1113 + * would. Fed into the pre-update pipeline if a save happens this request (fast
1114 + * path — matches how the item's own pending removals already work), with a
1115 + * shutdown fallback (priority 1, ahead of most other plugins' shutdown hooks)
1116 + * for requests where nothing ever triggers a save.
1117 + *
1118 + * @param string[] $paths Absolute paths of the restored files.
1119 + * @return void
1120 + * @since 1.4.0
1121 + */
1122 + public function track_restored_for_cleanup(array $paths) {
1123 + foreach ($paths as $path) {
1124 + if (!in_array($path, $this->pending_restored_files, true)) {
1125 + $this->pending_restored_files[] = $path;
1126 + }
1127 + }
1128 +
1129 + if ($this->pending_cleanup_hooked) {
1130 + return;
1131 + }
1132 + $this->pending_cleanup_hooked = true;
1133 +
1134 + add_filter('wpmcs_pre_update_item_additional_files_to_remove_from_server', function ($files_to_remove) {
1135 + $files_to_remove = array_merge((array) $files_to_remove, $this->pending_restored_files);
1136 + $this->pending_restored_files = [];
1137 + return $files_to_remove;
1138 + });
1139 +
1140 + add_action('shutdown', array($this, 'flush_pending_restored_files'), 1);
1141 + }
1142 +
1143 + /**
1144 + * Shutdown fallback for track_restored_for_cleanup() — removes anything the
1145 + * pre-update pipeline didn't already pick up this request.
1146 + *
1147 + * @return void
1148 + * @since 1.4.0
1149 + */
1150 + public function flush_pending_restored_files() {
1151 + if (empty($this->pending_restored_files)) {
1152 + return;
1153 + }
1154 + $this->may_be_delete_server_files_by_source_paths($this->pending_restored_files);
1155 + $this->pending_restored_files = [];
1156 + }
1157 +
1158 +
1159 + public function may_be_delete_server_files_by_source_paths($source_paths) {
1160 + if (Utils::is_empty($source_paths) || !is_array($source_paths)) {
1161 + return false;
1162 + }
1163 +
1164 + if( !( isset($this->settings['remove_from_server']) && $this->settings['remove_from_server'] ) ) {
1165 + return false;
1166 + }
1167 +
1168 + foreach ($source_paths as $path) {
1169 + if(file_exists($path)) {
1170 + wp_delete_file($path, true);
1171 + }
1172 + }
1173 + return true;
1174 + }
1175 +
1176 + /**
624 1177 * Function to remove media from server by id
625 1178 * @param int $attachment_id
626 1179 * @param string $source_type
627 1180 * @param bool $delete_main_file
@@ -669,9 +1222,9 @@
669 1222 }
670 1223 }
671 1224 }
672 1225
673 - $this->may_be_delete_server_files_by_item($item, $delete_main_file, $delete_backup);
1226 + $this->may_be_delete_server_files_by_item($item, $delete_main_file);
674 1227
675 1228 return true;
676 1229 }
677 1230
@@ -686,8 +1239,16 @@
686 1239 if( !( isset($this->settings['remove_from_server']) && $this->settings['remove_from_server'] ) ) {
687 1240 return false;
688 1241 }
689 1242
1243 + return $this->delete_server_files_by_item( $item, $delete_main_file );
1244 + }
1245 +
1246 +
1247 + /**
1248 + * Function to remove media from server by item
1249 + */
1250 + public function delete_server_files_by_item( $item, $delete_main_file=false ) {
690 1251 $upload_dir = wp_get_upload_dir();
691 1252 $has_original = false;
692 1253 $files_to_remove = array();
693 1254
@@ -707,20 +1268,42 @@
707 1268 }
708 1269 }
709 1270 }
710 1271 }
711 -
712 1272 if (
713 1273 isset($extras) && !empty($extras) &&
714 - isset($extras['original']) && !empty($extras['original'])
1274 + isset($extras['additional_files']) && !empty($extras['additional_files'])
715 1275 ) {
716 - $has_original = true;
717 - $file = trailingslashit($upload_dir['basedir']).$extras['original']['source_path'];
718 - if(file_exists($file) && $delete_main_file) {
719 - $files_to_remove[] = $file;
1276 + // animated_video/animated_video_poster are resolved client-side by slicing
1277 + // the main image's own (possibly presigned) source_url down to its directory
1278 + // and appending the file's filename — verified in WP core's shipped JS
1279 + // (block-library.js). That trick drops any presigned-URL query string, so for
1280 + // a private item it produces an unsigned request to a private S3 key, which
1281 + // fails once the local copy is gone. Keep these two local for private items;
1282 + // source_image is unaffected (never fetched by any client, private or not).
1283 + $unsafe_when_private = ['animated_video', 'animated_video_poster'];
1284 + foreach ($extras['additional_files'] as $name => $additional_file) {
1285 + if (!empty($item['is_private']) && in_array($name, $unsafe_when_private, true)) {
1286 + continue;
1287 + }
1288 + if (isset($additional_file['source_path']) && !empty($additional_file['source_path'])) {
1289 + $file = trailingslashit($upload_dir['basedir']) . $additional_file['source_path'];
1290 + if(file_exists($file)) {
1291 + $files_to_remove[] = $file;
1292 + }
1293 + }
720 1294 }
721 1295 }
722 1296 }
1297 + if (
1298 + isset($item['original_source_path']) && !empty($item['original_source_path'])
1299 + ) {
1300 + $has_original = true;
1301 + $file = trailingslashit($upload_dir['basedir']).$item['original_source_path'];
1302 + if(file_exists($file) && $delete_main_file) {
1303 + $files_to_remove[] = $file;
1304 + }
1305 + }
723 1306 if(file_exists($file_path)) {
724 1307 if ($has_original || (!$has_original && $delete_main_file)) {
725 1308 $files_to_remove[] = $file_path;
726 1309 }
@@ -736,9 +1319,8 @@
736 1319 }
737 1320
738 1321 return true;
739 1322 }
740 -
741 1323
742 1324 /**
743 1325 * Ensures only one instance of Class is loaded or can be loaded.
744 1326 *