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 +898 -197 1.2.12 → 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,15 +69,16 @@
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;
73 80 $item_id = false;
74 -
75 81 $data = array(
76 82 'provider' => $this->service,
77 83 'region' => $this->region,
78 84 'storage' => $this->bucket_name,
@@ -80,19 +86,58 @@
80 86 'source_path' => $source_path,
81 87 'source_type' => $source_type,
82 88 'url' => $url,
83 89 'key' => $key,
90 + 'original_source_path' => $original_source_path,
91 + 'original_key' => $original_key,
84 92 'is_private' => $is_private,
85 - 'extra' => maybe_serialize($meta),
93 + 'extra' => Utils::maybe_serialize($meta),
86 94 );
87 - if ($wpdb->insert(Db::get_table_name(), $data)) {
88 - $item_id = $wpdb->insert_id;
95 +
96 + // Do some pre-update actions
97 + $this->pre_update_item($source_id, $data, [], $source_type);
98 +
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;
89 127 $data['id'] = $item_id;
90 128 Integration::update_meta($source_id, 'item', $data, false, false, $source_type);
91 129 Cache::update_item_cache($source_id.'_item_'.$source_type, $data);
92 - 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 + }
93 135 }
94 136
137 + // Do some post-update actions
138 + $this->post_update_item($source_id, $data, $source_type);
139 +
95 140 return $item_id;
96 141 }
97 142
98 143
@@ -105,9 +150,22 @@
105 150 if($item === false) {
106 151 $item = Integration::get_meta($source_id, 'item', false, false, false, $source_type);
107 152 if($item == false){
108 153 $item_table = Db::get_table_name();
109 - $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);
110 168
111 169 if($wpdb->last_error || null === $item || !(isset($item) && !empty($item))) {
112 170 Cache::update_item_cache($source_id.'_item_'.$source_type, '');
113 171 return false;
@@ -118,9 +176,16 @@
118 176 // Update cache
119 177 Cache::update_item_cache($source_id.'_item_'.$source_type, $item == false ? '' : $item);
120 178 }
121 179
122 - 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 );
123 188 }
124 189
125 190 /**
126 191 * Function to delete item from data base
@@ -128,9 +193,22 @@
128 193 */
129 194 public function delete($source_id, $source_type = 'media_library'){
130 195 global $wpdb;
131 196 if (isset($source_id) && !Utils::is_empty($source_id)) {
132 - $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 );
133 211 if ($wpdb->last_error || false === $rows) {
134 212 return false;
135 213 }
136 214 Integration::delete_meta($source_id, 'item', false, $source_type);
@@ -135,8 +213,12 @@
135 213 }
136 214 Integration::delete_meta($source_id, 'item', false, $source_type);
137 215 Cache::delete_item_cache($source_id.'_item_'.$source_type);
138 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 +
139 221 return true;
140 222 }
141 223 return false;
142 224 }
@@ -144,18 +226,69 @@
144 226
145 227 /**
146 228 * Function to update item in data base
147 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
148 235 */
149 - public function update($source_id, $data, $source_type = 'media_library') {
236 + public function update($source_id, $data, $source_type = 'media_library', $target_identity = null) {
150 237 global $wpdb;
151 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;
152 242
153 - $data['provider'] = $this->service;
154 - $data['region'] = $this->region;
155 - $data['storage'] = $this->bucket_name;
243 + $old_item = $this->get($source_id, $source_type);
156 244
157 - $rows = $wpdb->update(Db::get_table_name(), $data, array('source_id' => $source_id, 'source_type' => $source_type));
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 +
254 + // Do some pre-update actions
255 + $this->pre_update_item($source_id, $data, $old_item, $source_type);
256 +
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);
158 291 if ($wpdb->last_error || false === $rows) {
159 292 return false;
160 293 }
161 294
@@ -161,10 +294,17 @@
161 294
162 295 Integration::delete_meta($source_id, 'item', false, $source_type);
163 296 Cache::delete_item_cache($source_id.'_item_'.$source_type);
164 297
165 - // Reset cache
166 - $current_data = $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 + }
304 + // Do some post-update actions
305 + $this->post_update_item($source_id, $data, $source_type);
306 +
167 307 return true;
168 308 }
169 309 return false;
170 310 }
@@ -233,79 +373,217 @@
233 373 if(Utils::is_empty($item)) {
234 374 return false;
235 375 }
236 376
237 - 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 + ) {
238 382 if(
239 383 ($check_rewrite && (isset($this->settings['rewrite_url']) && $this->settings['rewrite_url'])) ||
240 384 !$check_rewrite
241 385 ) {
386 + if ($check_rewrite) {
387 + return (bool) apply_filters('wpmcs_is_available_from_provider', true, $attachment_id, $source_type);
388 + }
242 389 return true;
243 - }
390 + }
244 391 }
245 392 return false;
246 393 }
247 394
248 395 /**
249 - * 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
250 403 */
251 - public function get_items_by_source_paths( $paths = [] ) {
404 + public function get_items_by_paths( $paths, $exact_match = true, $first_only = false, $type = 'source' ) {
252 405 global $wpdb;
253 406
254 - $items = [];
407 + if ( ! is_array( $paths ) && is_string( $paths ) && ! empty( $paths ) ) {
408 + $paths = [ $paths ];
409 + }
255 410
256 - if (!Utils::is_empty($paths)) {
257 - // Ensure paths are properly sanitized and ready for the query
258 - $like_conditions = array();
259 - foreach ($paths as $path) {
260 - // Prepare the SQL conditions for exact match in source_path and partial match in extras column
261 - $like_conditions[] = $wpdb->prepare("(source_path = %s OR extra LIKE %s)", $path, '%' . $wpdb->esc_like($path) . '%');
262 - }
411 + if ( Utils::is_empty( $paths ) ) {
412 + return [];
413 + }
263 414
264 - // Combine the conditions with OR
265 - $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;
266 426
267 - // Execute the SQL query to fetch source_id instead of source_path
268 - $item_table = Db::get_table_name();
269 - $sql = "SELECT DISTINCT source_id, source_type FROM " . $item_table . " WHERE " . $where_clause;
270 - $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 + }
271 434
272 - if ($wpdb->last_error || Utils::is_empty($results)) {
273 - 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 + }
274 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 . '%';
275 485
276 - $source_ids = array();
277 - foreach ($results as $row) {
278 - $item = $this->get((int)$row['source_id'], $row['source_type']);
279 - if(!Utils::is_empty($item)) {
280 - $items[] = $item;
486 + foreach ( array_keys( $fields ) as $column ) {
487 + $conditions[] = "`{$column}` LIKE %s";
488 + $params[] = $base;
281 489 }
282 490 }
283 - return $items;
284 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 +
285 530 return $items;
286 531 }
287 532
288 533
289 534 /**
290 - * 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 + *
291 538 * @since 1.0.0
292 539 */
293 - public function get_similar_files_by_path($path){
540 + public function get_similar_files_by_path( $path ) {
294 541 global $wpdb;
295 - if (isset($path) && !empty($path)) {
296 - $item_table = Db::get_table_name();
297 - $results = $wpdb->get_results("SELECT source_path FROM " . $item_table . " WHERE source_path LIKE '$path%'", ARRAY_A);
298 - if ($wpdb->last_error || null === $results || !(isset($results) && !empty($results))) {
299 - return false;
300 - }
301 - $source_paths = array();
302 - foreach ($results as $row) {
303 - $source_paths[] = $row['source_path'];
304 - }
305 - return $source_paths;
542 +
543 + if ( Utils::is_empty( $path ) ) {
544 + return false;
306 545 }
307 - 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' );
308 586 }
309 587
310 588
311 589 /**
@@ -314,25 +592,21 @@
314 592 * @param
315 593 */
316 594 public function get_url($source_id, $size = 'full', $source_type = 'media_library'){
317 595 if ($data = $this->get($source_id, $source_type)) {
318 - $extras = $this->get_extras($source_id, false, $source_type) ?: [];
319 - $key = '';
320 - $url = '';
596 + $key = '';
321 597 switch($size) {
322 598 case 'full':
323 599 $key = $data['key'];
324 - $url = $data['url'];
325 600 break;
326 601 case 'original':
327 - if(
328 - isset($extras) && !empty($extras) &&
329 - isset($extras['original']) && !empty($extras['original'])
330 - ) {
331 - $key = $extras['original']['key'];
332 - $url = $extras['original']['url'];
602 + if( isset($data['original_key']) && !empty($data['original_key']) ) {
603 + $key = $data['original_key'];
333 604 }
605 + break;
334 606 default:
607 + // Only named sizes need extras — skip fetching them for 'full'/'original'.
608 + $extras = $this->get_extras($source_id, false, $source_type) ?: [];
335 609 if(
336 610 isset($extras) && !empty($extras) &&
337 611 isset($extras['sizes']) && !empty($extras['sizes']) &&
338 612 isset($extras['sizes'][$size]) && !empty($extras['sizes'][$size])
@@ -337,34 +611,36 @@
337 611 isset($extras['sizes']) && !empty($extras['sizes']) &&
338 612 isset($extras['sizes'][$size]) && !empty($extras['sizes'][$size])
339 613 ) {
340 614 $key = $extras['sizes'][$size]['key'];
341 - $url = $extras['sizes'][$size]['url'];
342 615 }
343 616 }
344 617
345 618 if(!empty($key)){
346 - if (
347 - isset($this->settings['enable_presigned']) && $this->settings['enable_presigned'] &&
348 - isset($this->settings['presigned_expire']) && !empty($this->settings['presigned_expire'])
349 - ) {
350 - $preSignedUrl = Integration::get_meta( $source_id, 'presigned_url_'.$size, false, false, false, $source_type );
351 - if ($preSignedUrl === false) {
352 - $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);
353 623
354 624 if (!Utils::is_empty($new_url)) {
355 - $preSignedUrl = Cdn::may_generate_cdn_url($new_url, $key);
356 - $expireMinutes = (int)(isset($this->settings['presigned_expire']) && !empty($this->settings['presigned_expire']))
357 - ? $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']
358 631 : 20;
359 632 $expireSeconds = $expireMinutes * 60;
360 633
361 - 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);
362 635 }
363 636 }
364 - return $preSignedUrl;
637 + return $privateUrl;
365 638 } else {
366 - 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 + }
367 643 }
368 644 }
369 645 }
370 646 return false;
@@ -370,132 +646,355 @@
370 646 return false;
371 647 }
372 648
373 649
374 - /**
375 - * Get service path of item from database
376 - * @since 1.0.0
377 - * @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
378 665 */
379 - public function moveToServer($source_id, $size = 'full', $all = false, $source_type = 'media_library') {
380 - $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 = [];
381 668 $server_file = false;
382 - $upload_dir = wp_get_upload_dir();
383 669 $source_id = (int)$source_id;
670 + $item = $this->get($source_id, $source_type);
384 671
385 - if ($data = $this->get($source_id, $source_type)) {
386 - $wpmcsService = Service::instance();
387 - if($all) {
388 - if (
389 - isset($data['source_path']) && !empty($data['source_path']) &&
390 - isset($data['key']) && !empty($data['key'])
391 - ) {
392 - $file_path = trailingslashit($upload_dir['basedir']) . $data['source_path'];
393 - if(file_exists($file_path) || $wpmcsService->object_to_server($data['key'], $file_path)) {
394 - $server_files['full'] = $file_path;
395 - }
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;
396 687 }
397 - $extras = $this->get_extras($source_id, false, $source_type) ?: [];
398 - if (isset($extras['original']) && !empty($extras['original'])) {
399 - $original = $extras['original'];
400 - if(!empty($original)) {
401 - $original_file_path = trailingslashit($upload_dir['basedir']) . $original['source_path'];
402 - if(file_exists($original_file_path) || $wpmcsService->object_to_server($original['key'], $original_file_path)) {
403 - $server_files['original'] = $original_file_path;
404 - }
405 - }
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;
406 757 }
407 - if (isset($extras['sizes']) && !empty($extras['sizes'])) {
408 - $sizes = $extras['sizes'];
409 - foreach($sizes as $sub_size => $sub_file) {
410 - if(!empty($sub_file)) {
411 - $sub_file_path = trailingslashit($upload_dir['basedir']) . $sub_file['source_path'];
412 - if(file_exists($sub_file_path) || $wpmcsService->object_to_server($sub_file['key'], $sub_file_path)) {
413 - $server_files[$sub_size] = $sub_file_path;
414 - }
415 - }
416 - }
417 - }
418 - return !empty($server_files) ? $server_files : false;
419 - } else {
420 - if($size === 'full') {
421 - if (
422 - isset($data['source_path']) && !empty($data['source_path']) &&
423 - isset($data['key']) && !empty($data['key'])
424 - ) {
425 - $file_path = trailingslashit($upload_dir['basedir']) . $data['source_path'];
426 - if(file_exists($file_path) || $wpmcsService->object_to_server($data['key'], $file_path)) {
427 - $server_file = $file_path;
428 - }
429 - }
430 - } else if($size === 'original') {
431 - $extras = $this->get_extras($source_id, false, $source_type) ?: [];
432 - if (isset($extras['original']) && !empty($extras['original'])) {
433 - $original = $extras['original'];
434 - if(!empty($original)) {
435 - $original_file_path = trailingslashit($upload_dir['basedir']) . $original['source_path'];
436 - if(file_exists($original_file_path) || $wpmcsService->object_to_server($original['key'], $original_file_path)) {
437 - $server_file = $original_file_path;
438 - }
439 - }
440 - }
441 - } else {
442 - $extras = $this->get_extras($source_id, false, $source_type) ?: [];
443 - if (isset($extras['sizes']) && !empty($extras['sizes'])) {
444 - $sizes = $extras['sizes'];
445 - if(isset($sizes[$size]) && !empty($sizes[$size])) {
446 - $sub_file_path = trailingslashit($upload_dir['basedir']) . $sizes[$size]['source_path'];
447 - if(file_exists($sub_file_path) || $wpmcsService->object_to_server($sizes[$size]['key'], $sub_file_path)) {
448 - $server_file = $sub_file_path;
449 - }
450 - }
451 - }
452 - }
453 - return $server_file;
454 758 }
759 +
760 + return $results;
455 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 +
456 774 return false;
457 775 }
458 -
459 776
460 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 + /**
461 830 * Get service path of item from database by source url
462 831 * @since 1.0.0
463 - * @param
832 + * @param int $source_id
833 + * @param string $file
834 + * @param string $source_type
835 + * @return bool
464 836 */
465 - public function moveToServerBySourcePath($source_id, $file, $source_type = 'media_library'){
466 - $server_files = array();
467 - $server_file = false;
468 - $upload_dir = wp_get_upload_dir();
469 - $source_id = (int)$source_id;
837 + public function moveToServerBySourcePath( $source_id, $file, $source_type = 'media_library' ) {
838 + $source_id = (int) $source_id;
470 839
471 - if ($data = $this->get($source_id, $source_type)) {
472 - $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 + }
473 844
474 - if( isset($data['source_path']) && !empty($data['source_path']) && $data['source_path'] == $source_path ) {
475 - $file_path = trailingslashit($upload_dir['basedir']) . $data['source_path'];
476 - 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 + ) {
477 896 return true;
478 897 }
479 - } else {
480 - $sizes = $this->get_extras($source_id, 'sizes', $source_type) ?: [];
481 - foreach($sizes as $sub_size => $sub_file) {
482 - if(!empty($sub_file) && $sub_file['source_path'] == $source_path) {
483 - $sub_file_path = trailingslashit($upload_dir['basedir']) . $sub_file['source_path'];
484 - if(file_exists($sub_file_path) || Service::instance()->object_to_server($sub_file['key'], $sub_file_path)) {
485 - return true;
486 - }
487 - }
898 + }
899 + }
900 +
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;
488 916 }
489 917 }
490 918 }
919 +
491 920 return false;
492 921 }
493 922
494 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 + }
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 +
965 + /**
966 + * Move original file to server
967 + *
968 + * @param int $source_id
969 + * @param string $source_type
970 + * @return bool
971 + */
972 + public function moveOriginalToServer($source_id, $source_type = 'media_library') {
973 + $data = $this->get($source_id, $source_type);
974 + if ($data) {
975 + $size = 'full';
976 + if (
977 + isset($data['original_source_path']) && !empty($data['original_source_path']) &&
978 + isset($data['original_key']) && !empty($data['original_key'])
979 + ) {
980 + $size = 'original';
981 + }
982 + return $this->moveToServer($source_id, $size, $source_type);
983 + }
984 + return false;
985 + }
986 +
987 +
988 + /**
495 989 * Delete media item
496 990 */
497 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 +
498 997 $upload_dir = wp_get_upload_dir();
499 998
500 999 if (isset($item['extra']) && !empty($item['extra'])) {
501 1000 $extras = Utils::maybe_unserialize($item['extra']);
@@ -511,11 +1010,15 @@
511 1010 }
512 1011
513 1012 if (
514 1013 isset($extras) && !empty($extras) &&
515 - isset($extras['original']) && !empty($extras['original'])
1014 + isset($extras['additional_files']) && !empty($extras['additional_files'])
516 1015 ) {
517 - 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 + }
518 1021 }
519 1022
520 1023 if (
521 1024 isset($extras) && !empty($extras) &&
@@ -527,17 +1030,175 @@
527 1030 $this->delete_attachments_by_item($backup, false);
528 1031 }
529 1032 }
530 1033 }
1034 +
1035 + if (
1036 + isset($item['original_key']) && !empty($item['original_key'])
1037 + ) {
1038 + Service::instance()->deleteSingle($item['original_key']);
1039 + }
1040 +
531 1041 if (isset($item['key']) && !empty($item['key'])) {
532 1042 Service::instance()->deleteSingle($item['key']);
533 1043 }
534 1044 }
535 1045
536 - /**
1046 +
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 + /**
1065 + * Pre-update item actions
1066 + * @since 1.2.13
1067 + * @param int $source_id
1068 + * @param array $data
1069 + * @param string $source_type
1070 + */
1071 + public function pre_update_item($source_id, $new_item, $old_item = [], $source_type = 'media_library') {
1072 + // Hook for pre-update actions
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 + }
1082 + }
1083 +
1084 +
1085 + /**
1086 + * Post-update item actions
1087 + * @since 1.2.13
1088 + * @param int $source_id
1089 + * @param array $data
1090 + * @param string $source_type
1091 + * This function is called after an item has been updated in the database.
1092 + * It triggers a WordPress action hook 'wpmcs_post_update_item' to allow other functions to hook into this event.
1093 + * After that, it calls may_be_delete_server_files_by_id to potentially delete server files associated with the item.
1094 + *
1095 + * @example
1096 + * $item = Item::instance();
1097 + * $item->post_update_item(123, $data, 'media_library');
1098 + *
1099 + * This example will trigger the post-update actions for the item with ID 123.
1100 + * It will execute any functions hooked to 'wpmcs_post_update_item' and may delete server files if the settings allow it.
1101 + */
1102 + public function post_update_item($source_id, $data, $source_type = 'media_library') {
1103 + // Hook for post-update actions
1104 + do_action('wpmcs_post_update_item', $source_id, $data, $source_type);
1105 +
1106 + // May be delete server files
1107 + $this->may_be_delete_server_files_by_id($source_id, $source_type, true, true);
1108 + }
1109 +
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 + /**
537 1177 * Function to remove media from server by id
538 - * @since 1.0.0
539 - *
1178 + * @param int $attachment_id
1179 + * @param string $source_type
1180 + * @param bool $delete_main_file
1181 + * @param bool $delete_backup
1182 + *
1183 + * This function checks if the item exists and if the setting to remove from server is enabled.
1184 + * If so, it deletes the main file and any backup files associated with the item.
1185 + * It also checks if the item has any extra data, and if so, it attempts to delete the backup files if specified.
1186 + * Finally, it deletes the main file associated with the item.
1187 + *
1188 + * @since 1.2.13
1189 + * @return bool Returns true if the deletion process was initiated, false otherwise.
1190 + *
1191 + * @throws \Exception If the item does not exist or if the removal from server setting is not enabled.
1192 + *
1193 + * @example
1194 + * $item = Item::instance();
1195 + * $item->may_be_delete_server_files_by_id(123, 'media_library', true, true);
1196 + *
1197 + * This example will attempt to delete the server files for the attachment with ID 123,
1198 + * including the main file and any backup files, if the settings allow it.
1199 + *
1200 + * @see Item::may_be_delete_server_files_by_item() for the function that actually performs the deletion.
540 1201 */
541 1202 public function may_be_delete_server_files_by_id($attachment_id, $source_type = 'media_library', $delete_main_file=false, $delete_backup = false) {
542 1203 $item = $this->get($attachment_id, $source_type);
543 1204 if(Utils::is_empty($item)) {
@@ -561,9 +1222,9 @@
561 1222 }
562 1223 }
563 1224 }
564 1225
565 - $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);
566 1227
567 1228 return true;
568 1229 }
569 1230
@@ -578,10 +1239,19 @@
578 1239 if( !( isset($this->settings['remove_from_server']) && $this->settings['remove_from_server'] ) ) {
579 1240 return false;
580 1241 }
581 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 ) {
582 1251 $upload_dir = wp_get_upload_dir();
583 1252 $has_original = false;
1253 + $files_to_remove = array();
584 1254
585 1255 $file_path = trailingslashit($upload_dir['basedir']) . $item['source_path'];
586 1256
587 1257 if (isset($item['extra']) && !empty($item['extra'])) {
@@ -593,33 +1263,64 @@
593 1263 foreach ($extras['sizes'] as $sub_image) {
594 1264 if (isset($sub_image['source_path']) && !empty($sub_image['source_path'])) {
595 1265 $file = trailingslashit($upload_dir['basedir']) . $sub_image['source_path'];
596 1266 if(file_exists($file)) {
597 - wp_delete_file($file);
1267 + $files_to_remove[] = $file;
598 1268 }
599 1269 }
600 1270 }
601 1271 }
602 -
603 1272 if (
604 1273 isset($extras) && !empty($extras) &&
605 - isset($extras['original']) && !empty($extras['original'])
1274 + isset($extras['additional_files']) && !empty($extras['additional_files'])
606 1275 ) {
607 - $has_original = true;
608 - $file = trailingslashit($upload_dir['basedir']).$extras['original']['source_path'];
609 - if(file_exists($file) && $delete_main_file) {
610 - wp_delete_file($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 + }
611 1294 }
612 1295 }
613 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 + }
614 1306 if(file_exists($file_path)) {
615 1307 if ($has_original || (!$has_original && $delete_main_file)) {
616 - wp_delete_file($file_path);
1308 + $files_to_remove[] = $file_path;
617 1309 }
618 1310 }
1311 +
1312 +
1313 + $files_to_remove = apply_filters('wpmcs_files_to_remove_from_server', array_unique($files_to_remove), $item['source_id'], $item);
1314 +
1315 + if (!Utils::is_empty($files_to_remove)) {
1316 + foreach ($files_to_remove as $file) {
1317 + wp_delete_file($file, true);
1318 + }
1319 + }
1320 +
619 1321 return true;
620 1322 }
621 -
622 1323
623 1324 /**
624 1325 * Ensures only one instance of Class is loaded or can be loaded.
625 1326 *