PluginProbe
Media Cloud Sync / 1.4.1
Media Cloud Sync v1.4.1
1.4.1 1.4.0 1.3.12 1.3.11 1.3.10 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.2.0 1.2.10 1.2.11 1.2.12 1.2.13 1.2.2 1.2.3 1.2.4 1.2.5 1.2.6 1.2.7 1.2.8 1.2.9 All 35 releases
← All changes | includes/base/item.php +739 -223 1.2.131.4.1 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,140 +646,288 @@
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 + // ALL files
736 + if ( $all ) {
737 + $results = [];
738 +
739 + foreach ( $files as $label => $data ) {
740 + if ( $file = $this->move_to_server_by_key_and_path(
741 + $data['key'],
742 + $data['path'],
743 + $source_id,
744 + $source_type,
745 + $log_type
746 + ) ) {
747 + $results[ $label ] = $file;
418 748 }
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 749 }
750 +
751 + return $results;
467 752 }
753 +
754 + // SINGLE file
755 + if ( isset( $files[ $size ] ) ) {
756 + return $this->move_to_server_by_key_and_path(
757 + $files[ $size ]['key'],
758 + $files[ $size ]['path'],
759 + $source_id,
760 + $source_type,
761 + $log_type
762 + );
763 + }
764 +
468 765 return false;
469 766 }
470 -
471 767
472 768 /**
769 + * Whether a moveToServer(..., $all=true, $backup=true) result actually restored
770 + * everything this item is expected to have (every size, the original if present, and
771 + * the backup entry if one exists) — moveToServer()'s return silently drops any single
772 + * file that failed, so a plain non-empty check on it isn't enough to safely delete the
773 + * cloud copies afterward.
774 + * @since 1.4.1
775 + */
776 + public function verify_full_restore( $source_id, $source_type, $moved ) {
777 + $row = $this->get( $source_id, $source_type );
778 + if ( empty( $row ) ) {
779 + return false;
780 + }
781 +
782 + $expected = $this->expected_restore_labels( $row );
783 + $restored = array_diff( array_keys( (array) $moved ), [ 'backup' ] );
784 + if ( ! empty( array_diff( $expected, $restored ) ) ) {
785 + return false;
786 + }
787 +
788 + $backup_item = $this->get_backup( $source_id, $source_type );
789 + if ( empty( $backup_item ) ) {
790 + return true;
791 + }
792 +
793 + $expected_backup = $this->expected_restore_labels( $backup_item );
794 + $restored_backup = ! empty( $moved['backup'] ) ? array_keys( $moved['backup'] ) : [];
795 + return empty( array_diff( $expected_backup, $restored_backup ) );
796 + }
797 +
798 + /**
799 + * File labels (full, original, each named size) a given item row is expected to have.
800 + */
801 + private function expected_restore_labels( $item_row ) {
802 + $expected = [ 'full' ];
803 + if ( ! empty( $item_row['original_key'] ) || ! empty( $item_row['original_source_path'] ) ) {
804 + $expected[] = 'original';
805 + }
806 +
807 + $extras = ! empty( $item_row['extra'] ) ? Utils::maybe_unserialize( $item_row['extra'] ) : [];
808 + if ( ! empty( $extras['sizes'] ) ) {
809 + $expected = array_merge( $expected, array_keys( $extras['sizes'] ) );
810 + }
811 +
812 + return $expected;
813 + }
814 +
815 +
816 +
817 + /**
473 818 * Get service path of item from database by source url
474 819 * @since 1.0.0
475 - * @param
820 + * @param int $source_id
821 + * @param string $file
822 + * @param string $source_type
823 + * @return bool
476 824 */
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;
825 + public function moveToServerBySourcePath( $source_id, $file, $source_type = 'media_library' ) {
826 + $source_id = (int) $source_id;
482 827
483 - if ($data = $this->get($source_id, $source_type)) {
484 - $source_path = Utils::get_attachment_source_path($file);
828 + $item = $this->get( $source_id, $source_type );
829 + if ( Utils::is_empty( $item ) ) {
830 + return false;
831 + }
485 832
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)) {
833 + $source_path = Utils::get_attachment_source_path( $file );
834 + if ( empty( $source_path ) ) {
835 + return false;
836 + }
837 +
838 + // 1. Check main file
839 + if (
840 + isset( $item['source_path'] ) &&
841 + ! empty( $item['source_path'] ) &&
842 + $item['source_path'] === $source_path &&
843 + $this->move_to_server_by_key_and_path(
844 + $item['key'] ?? null,
845 + $item['source_path'],
846 + $source_id,
847 + $source_type
848 + )
849 + ) {
850 + return true;
851 + }
852 +
853 +
854 + // 2. Check original
855 + if (
856 + isset( $item['original_source_path'] ) && ! empty( $item['original_source_path'] ) &&
857 + $item['original_source_path'] === $source_path &&
858 + $this->move_to_server_by_key_and_path(
859 + $item['original_key'] ?? null,
860 + $item['original_source_path'],
861 + $source_id,
862 + $source_type
863 + )
864 + ) {
865 + return true;
866 + }
867 +
868 + $extras = $this->get_extras( $source_id, false, $source_type ) ?: [];
869 +
870 + // 3. Check sizes
871 + if ( ! empty( $extras['sizes'] ) ) {
872 + foreach ( $extras['sizes'] as $size ) {
873 + if (
874 + isset( $size['source_path'] ) &&
875 + ! empty( $size['source_path'] ) &&
876 + $size['source_path'] === $source_path &&
877 + $this->move_to_server_by_key_and_path(
878 + $size['key'] ?? null,
879 + $size['source_path'],
880 + $source_id,
881 + $source_type
882 + )
883 + ) {
489 884 return true;
490 885 }
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 - }
886 + }
887 + }
503 888
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 - }
515 - }
516 - }
889 + return false;
890 + }
891 +
892 + /**
893 + * Copy back a file from the service to the server
894 + *
895 + * @param string $key
896 + * @param string $relative_path
897 + * @param int $source_id
898 + * @param string $source_type
899 + * @param string $log_type error-log bucket to write to on failure
900 + *
901 + * @return string|false
902 + */
903 + protected function move_to_server_by_key_and_path( $key, $relative_path, $source_id = 0, $source_type = 'media_library', $log_type = 'restore_to_server' ) {
904 + if ( empty( $key ) || empty( $relative_path ) ) {
905 + return false;
517 906 }
907 +
908 + $upload_dir = wp_get_upload_dir();
909 + $file = trailingslashit( $upload_dir['basedir'] ) . $relative_path;
910 +
911 + if ( file_exists( $file ) ) {
912 + return $file;
913 + }
914 +
915 + // Checked on disk rather than trusting the return value alone — at least one
916 + // provider (Cloudflare R2) has been observed writing the file successfully while
917 + // still reporting failure (an SDK-level error thrown after the save completes).
918 + Service::instance()->object_to_server( $key, $file );
919 +
920 + if ( file_exists( $file ) ) {
921 + return $file;
922 + }
923 +
924 + Logger::instance()->add_log( $log_type, $source_id, $source_type, [
925 + 'message' => __( 'The file could not be copied to the server. Please try again.', 'media-cloud-sync' ),
926 + 'file' => $key,
927 + 'code' => 404,
928 + ] );
929 +
518 930 return false;
519 931 }
520 932
521 933
@@ -529,13 +941,15 @@
529 941 public function moveOriginalToServer($source_id, $source_type = 'media_library') {
530 942 $data = $this->get($source_id, $source_type);
531 943 if ($data) {
532 944 $size = 'full';
533 - $original = $this->get_extras($source_id, 'original', $source_type);
534 - if ($original && isset($original['key']) && !empty($original['key'])) {
945 + if (
946 + isset($data['original_source_path']) && !empty($data['original_source_path']) &&
947 + isset($data['original_key']) && !empty($data['original_key'])
948 + ) {
535 949 $size = 'original';
536 950 }
537 - return $this->moveToServer($source_id, $size, false, $source_type);
951 + return $this->moveToServer($source_id, $size, $source_type);
538 952 }
539 953 return false;
540 954 }
541 955
@@ -543,8 +957,13 @@
543 957 /**
544 958 * Delete media item
545 959 */
546 960 public function delete_attachments_by_item($item, $delete_backup = true) {
961 + // Lets an integration veto the delete when another row still relies on the same key.
962 + if (!apply_filters('wpmcs_should_delete_cloud_files', true, $item)) {
963 + return;
964 + }
965 +
547 966 $upload_dir = wp_get_upload_dir();
548 967
549 968 if (isset($item['extra']) && !empty($item['extra'])) {
550 969 $extras = Utils::maybe_unserialize($item['extra']);
@@ -560,15 +979,8 @@
560 979 }
561 980
562 981 if (
563 982 isset($extras) && !empty($extras) &&
564 - isset($extras['original']) && !empty($extras['original'])
565 - ) {
566 - Service::instance()->deleteSingle($extras['original']['key']);
567 - }
568 -
569 - if (
570 - isset($extras) && !empty($extras) &&
571 983 isset($extras['backup']) && !empty($extras['backup']) &&
572 984 $delete_backup
573 985 ) {
574 986 $backup = Utils::maybe_unserialize($extras['backup']);
@@ -576,8 +988,15 @@
576 988 $this->delete_attachments_by_item($backup, false);
577 989 }
578 990 }
579 991 }
992 +
993 + if (
994 + isset($item['original_key']) && !empty($item['original_key'])
995 + ) {
996 + Service::instance()->deleteSingle($item['original_key']);
997 + }
998 +
580 999 if (isset($item['key']) && !empty($item['key'])) {
581 1000 Service::instance()->deleteSingle($item['key']);
582 1001 }
583 1002 }
@@ -583,8 +1002,25 @@
583 1002 }
584 1003
585 1004
586 1005 /**
1006 + * Delete Cloud Files by Keys
1007 + * @since 1.3.6
1008 + */
1009 + public function delete_cloud_files_by_keys( $keys = [] ) {
1010 + if (Utils::is_empty($keys) || !is_array($keys)) {
1011 + return false;
1012 + }
1013 +
1014 + foreach ($keys as $key) {
1015 + Service::instance()->deleteSingle( $key );
1016 + }
1017 + return true;
1018 + }
1019 +
1020 +
1021 +
1022 + /**
587 1023 * Pre-update item actions
588 1024 * @since 1.2.13
589 1025 * @param int $source_id
590 1026 * @param array $data
@@ -589,11 +1025,19 @@
589 1025 * @param int $source_id
590 1026 * @param array $data
591 1027 * @param string $source_type
592 1028 */
593 - public function pre_update_item($source_id, $data, $source_type = 'media_library') {
1029 + public function pre_update_item($source_id, $new_item, $old_item = [], $source_type = 'media_library') {
594 1030 // Hook for pre-update actions
595 - do_action('wpmcs_pre_update_item', $source_id, $data, $source_type);
1031 + do_action('wpmcs_pre_update_item', $source_id, $new_item, $old_item, $source_type);
1032 +
1033 + // Additional filter to modify files to be removed from server if needed
1034 + $files_to_remove = apply_filters('wpmcs_pre_update_item_additional_files_to_remove_from_server', [], $source_id, $new_item, $old_item, $source_type);
1035 +
1036 + // Delete files if any
1037 + if (!Utils::is_empty($files_to_remove)) {
1038 + $this->may_be_delete_server_files_by_source_paths($files_to_remove);
1039 + }
596 1040 }
597 1041
598 1042
599 1043 /**
@@ -616,12 +1060,79 @@
616 1060 public function post_update_item($source_id, $data, $source_type = 'media_library') {
617 1061 // Hook for post-update actions
618 1062 do_action('wpmcs_post_update_item', $source_id, $data, $source_type);
619 1063
1064 + // May be delete server files
620 1065 $this->may_be_delete_server_files_by_id($source_id, $source_type, true, true);
621 1066 }
622 1067
623 1068 /**
1069 + * Track paths restored from cloud (by any integration) so they get removed
1070 + * again later, honoring "Remove from server" the way the normal sync pipeline
1071 + * would. Fed into the pre-update pipeline if a save happens this request (fast
1072 + * path — matches how the item's own pending removals already work), with a
1073 + * shutdown fallback (priority 1, ahead of most other plugins' shutdown hooks)
1074 + * for requests where nothing ever triggers a save.
1075 + *
1076 + * @param string[] $paths Absolute paths of the restored files.
1077 + * @return void
1078 + * @since 1.4.0
1079 + */
1080 + public function track_restored_for_cleanup(array $paths) {
1081 + foreach ($paths as $path) {
1082 + if (!in_array($path, $this->pending_restored_files, true)) {
1083 + $this->pending_restored_files[] = $path;
1084 + }
1085 + }
1086 +
1087 + if ($this->pending_cleanup_hooked) {
1088 + return;
1089 + }
1090 + $this->pending_cleanup_hooked = true;
1091 +
1092 + add_filter('wpmcs_pre_update_item_additional_files_to_remove_from_server', function ($files_to_remove) {
1093 + $files_to_remove = array_merge((array) $files_to_remove, $this->pending_restored_files);
1094 + $this->pending_restored_files = [];
1095 + return $files_to_remove;
1096 + });
1097 +
1098 + add_action('shutdown', array($this, 'flush_pending_restored_files'), 1);
1099 + }
1100 +
1101 + /**
1102 + * Shutdown fallback for track_restored_for_cleanup() — removes anything the
1103 + * pre-update pipeline didn't already pick up this request.
1104 + *
1105 + * @return void
1106 + * @since 1.4.0
1107 + */
1108 + public function flush_pending_restored_files() {
1109 + if (empty($this->pending_restored_files)) {
1110 + return;
1111 + }
1112 + $this->may_be_delete_server_files_by_source_paths($this->pending_restored_files);
1113 + $this->pending_restored_files = [];
1114 + }
1115 +
1116 +
1117 + public function may_be_delete_server_files_by_source_paths($source_paths) {
1118 + if (Utils::is_empty($source_paths) || !is_array($source_paths)) {
1119 + return false;
1120 + }
1121 +
1122 + if( !( isset($this->settings['remove_from_server']) && $this->settings['remove_from_server'] ) ) {
1123 + return false;
1124 + }
1125 +
1126 + foreach ($source_paths as $path) {
1127 + if(file_exists($path)) {
1128 + wp_delete_file($path, true);
1129 + }
1130 + }
1131 + return true;
1132 + }
1133 +
1134 + /**
624 1135 * Function to remove media from server by id
625 1136 * @param int $attachment_id
626 1137 * @param string $source_type
627 1138 * @param bool $delete_main_file
@@ -669,9 +1180,9 @@
669 1180 }
670 1181 }
671 1182 }
672 1183
673 - $this->may_be_delete_server_files_by_item($item, $delete_main_file, $delete_backup);
1184 + $this->may_be_delete_server_files_by_item($item, $delete_main_file);
674 1185
675 1186 return true;
676 1187 }
677 1188
@@ -686,8 +1197,16 @@
686 1197 if( !( isset($this->settings['remove_from_server']) && $this->settings['remove_from_server'] ) ) {
687 1198 return false;
688 1199 }
689 1200
1201 + return $this->delete_server_files_by_item( $item, $delete_main_file );
1202 + }
1203 +
1204 +
1205 + /**
1206 + * Function to remove media from server by item
1207 + */
1208 + public function delete_server_files_by_item( $item, $delete_main_file=false ) {
690 1209 $upload_dir = wp_get_upload_dir();
691 1210 $has_original = false;
692 1211 $files_to_remove = array();
693 1212
@@ -707,18 +1226,16 @@
707 1226 }
708 1227 }
709 1228 }
710 1229 }
711 -
712 - if (
713 - isset($extras) && !empty($extras) &&
714 - isset($extras['original']) && !empty($extras['original'])
715 - ) {
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;
720 - }
1230 + }
1231 + if (
1232 + isset($item['original_source_path']) && !empty($item['original_source_path'])
1233 + ) {
1234 + $has_original = true;
1235 + $file = trailingslashit($upload_dir['basedir']).$item['original_source_path'];
1236 + if(file_exists($file) && $delete_main_file) {
1237 + $files_to_remove[] = $file;
721 1238 }
722 1239 }
723 1240 if(file_exists($file_path)) {
724 1241 if ($has_original || (!$has_original && $delete_main_file)) {
@@ -736,9 +1253,8 @@
736 1253 }
737 1254
738 1255 return true;
739 1256 }
740 -
741 1257
742 1258 /**
743 1259 * Ensures only one instance of Class is loaded or can be loaded.
744 1260 *