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
media-cloud-sync / includes / base / item.php

item.php in Media Cloud Sync 1.4.1, at includes/base/item.php

1,271 lines 44.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace Dudlewebs\WPMCS;
3
4 defined('ABSPATH') || exit;
5
6 class Item {
7 private static $instance = null;
8 private $assets_url;
9 private $version;
10 private $token;
11
12 protected $config;
13 protected $bucketConfig;
14 protected $settings;
15 protected $credentials;
16 protected $service;
17
18 protected $bucket_name;
19 protected $region = '';
20
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 /**
31 * Admin constructor.
32 * @since 1.0.0
33 */
34 public function __construct() {
35 $this->assets_url = WPMCS_ASSETS_URL;
36 $this->version = WPMCS_VERSION;
37 $this->token = WPMCS_TOKEN;
38
39 // Initialize setup
40 $this->init();
41 }
42
43 /**
44 * Initialize datas
45 */
46 public function init() {
47 $this->settings = Utils::get_settings();
48 $this->credentials = Utils::get_credentials();
49 $this->config = isset($this->credentials['config']) && !empty($this->credentials['config'])
50 ? $this->credentials['config']
51 : [];
52 $this->bucketConfig = isset($this->credentials['bucketConfig']) && !empty($this->credentials['bucketConfig'])
53 ? $this->credentials['bucketConfig']
54 : [];
55 $this->service = isset($this->credentials['service']) && !empty($this->credentials['service'])
56 ? $this->credentials['service']
57 : '';
58
59 $this->bucket_name = isset($this->bucketConfig['bucket_name']) ? $this->bucketConfig['bucket_name'] : '';
60 $this->region = isset($this->config['region']) ? $this->config['region'] : '';
61 }
62
63 /**
64 * Add Item In database
65 * @since 1.0.0
66 * @param
67 */
68 public function add(
69 $source_id,
70 $url,
71 $key,
72 $source_path,
73 $original_source_path = '',
74 $original_key = '',
75 $meta = array(),
76 $source_type = 'media_library',
77 $is_private = 0
78 ) {
79 global $wpdb;
80 $item_id = false;
81 $data = array(
82 'provider' => $this->service,
83 'region' => $this->region,
84 'storage' => $this->bucket_name,
85 'source_id' => $source_id,
86 'source_path' => $source_path,
87 'source_type' => $source_type,
88 'url' => $url,
89 'key' => $key,
90 'original_source_path' => $original_source_path,
91 'original_key' => $original_key,
92 'is_private' => $is_private,
93 'extra' => Utils::maybe_serialize($meta),
94 );
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;
127 $data['id'] = $item_id;
128 Integration::update_meta($source_id, 'item', $data, false, false, $source_type);
129 Cache::update_item_cache($source_id.'_item_'.$source_type, $data);
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 }
135 }
136
137 // Do some post-update actions
138 $this->post_update_item($source_id, $data, $source_type);
139
140 return $item_id;
141 }
142
143
144 /**
145 * Get Item From Db
146 */
147 public function get($source_id, $source_type = 'media_library') {
148 global $wpdb;
149 $item = Cache::get_item_cache($source_id.'_item_'.$source_type, false);
150 if($item === false) {
151 $item = Integration::get_meta($source_id, 'item', false, false, false, $source_type);
152 if($item == false){
153 $item_table = Db::get_table_name();
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);
168
169 if($wpdb->last_error || null === $item || !(isset($item) && !empty($item))) {
170 Cache::update_item_cache($source_id.'_item_'.$source_type, '');
171 return false;
172 }
173
174 Integration::update_meta($source_id, 'item', $item, false, false, $source_type);
175 }
176 // Update cache
177 Cache::update_item_cache($source_id.'_item_'.$source_type, $item == false ? '' : $item);
178 }
179
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 );
188 }
189
190 /**
191 * Function to delete item from data base
192 * @since 1.0.0
193 */
194 public function delete($source_id, $source_type = 'media_library'){
195 global $wpdb;
196 if (isset($source_id) && !Utils::is_empty($source_id)) {
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 );
211 if ($wpdb->last_error || false === $rows) {
212 return false;
213 }
214 Integration::delete_meta($source_id, 'item', false, $source_type);
215 Cache::delete_item_cache($source_id.'_item_'.$source_type);
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
221 return true;
222 }
223 return false;
224 }
225
226
227 /**
228 * Function to update item in data base
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
235 */
236 public function update($source_id, $data, $source_type = 'media_library', $target_identity = null) {
237 global $wpdb;
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;
242
243 $old_item = $this->get($source_id, $source_type);
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
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);
291 if ($wpdb->last_error || false === $rows) {
292 return false;
293 }
294
295 Integration::delete_meta($source_id, 'item', false, $source_type);
296 Cache::delete_item_cache($source_id.'_item_'.$source_type);
297
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
307 return true;
308 }
309 return false;
310 }
311
312 /**
313 * Get extra values of item from database
314 * @since 1.0.0
315 * @param
316 */
317 public function get_extras($source_id, $field = false, $source_type = 'media_library'){
318 $data = $this->get($source_id, $source_type);
319 if ($data) {
320 if (isset($data['extra']) && !empty($data['extra'])) {
321
322 if(!Utils::is_empty($field)) {
323 $extras = Utils::maybe_unserialize($data['extra']);
324 return isset($extras[$field]) && !empty($extras[$field]) ? $extras[$field] : false;
325 }
326
327 return Utils::maybe_unserialize($data['extra']);
328 }
329 }
330 return false;
331 }
332
333
334 /**
335 * Get column of item from database
336 * @since 1.0.0
337 * @param
338 */
339 public function get_field($source_id, $field, $source_type = 'media_library'){
340 $data = $this->get($source_id, $source_type);
341 if ($data) {
342 if (isset($data[$field]) && !empty($data[$field])) {
343 return $data[$field];
344 }
345 }
346 return false;
347 }
348
349
350 /**
351 * Get backup values of item from database
352 * @since 1.0.0
353 * @param
354 */
355 public function get_backup($source_id, $source_type = 'media_library'){
356 $data = $this->get_extras($source_id, false, $source_type);
357 if ($data) {
358 if (isset($data['backup']) && !empty($data['backup'])) {
359 return Utils::maybe_unserialize($data['backup']);
360 }
361 }
362 return false;
363 }
364
365
366 /**
367 * Checking Item that is served by provider And Is rewrite URL is enabled
368 * @since 1.0.0
369 * @param
370 */
371 public function is_available_from_provider($attachment_id, $check_rewrite = true, $source_type = 'media_library') {
372 $item = $this->get($attachment_id, $source_type);
373 if(Utils::is_empty($item)) {
374 return false;
375 }
376
377 if (
378 $item['provider'] == $this->service &&
379 $item['storage'] == $this->bucket_name &&
380 $item['source_type'] == $source_type
381 ) {
382 if(
383 ($check_rewrite && (isset($this->settings['rewrite_url']) && $this->settings['rewrite_url'])) ||
384 !$check_rewrite
385 ) {
386 if ($check_rewrite) {
387 return (bool) apply_filters('wpmcs_is_available_from_provider', true, $attachment_id, $source_type);
388 }
389 return true;
390 }
391 }
392 return false;
393 }
394
395 /**
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
403 */
404 public function get_items_by_paths( $paths, $exact_match = true, $first_only = false, $type = 'source' ) {
405 global $wpdb;
406
407 if ( ! is_array( $paths ) && is_string( $paths ) && ! empty( $paths ) ) {
408 $paths = [ $paths ];
409 }
410
411 if ( Utils::is_empty( $paths ) ) {
412 return [];
413 }
414
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;
426
427 default:
428 $fields = [
429 'source_path' => 'uidx_source_path',
430 'original_source_path' => 'uidx_original_source_path',
431 ];
432 break;
433 }
434
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 }
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 . '%';
485
486 foreach ( array_keys( $fields ) as $column ) {
487 $conditions[] = "`{$column}` LIKE %s";
488 $params[] = $base;
489 }
490 }
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
530 return $items;
531 }
532
533
534 /**
535 * Get similar existing files by source path prefix
536 * Searches in source_path and original_source_path
537 *
538 * @since 1.0.0
539 */
540 public function get_similar_files_by_path( $path ) {
541 global $wpdb;
542
543 if ( Utils::is_empty( $path ) ) {
544 return false;
545 }
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' );
586 }
587
588
589 /**
590 * Get service url of item from database
591 * @since 1.0.0
592 * @param
593 */
594 public function get_url($source_id, $size = 'full', $source_type = 'media_library'){
595 if ($data = $this->get($source_id, $source_type)) {
596 $key = '';
597 switch($size) {
598 case 'full':
599 $key = $data['key'];
600 break;
601 case 'original':
602 if( isset($data['original_key']) && !empty($data['original_key']) ) {
603 $key = $data['original_key'];
604 }
605 break;
606 default:
607 // Only named sizes need extras — skip fetching them for 'full'/'original'.
608 $extras = $this->get_extras($source_id, false, $source_type) ?: [];
609 if(
610 isset($extras) && !empty($extras) &&
611 isset($extras['sizes']) && !empty($extras['sizes']) &&
612 isset($extras['sizes'][$size]) && !empty($extras['sizes'][$size])
613 ) {
614 $key = $extras['sizes'][$size]['key'];
615 }
616 }
617
618 if(!empty($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);
623
624 if (!Utils::is_empty($new_url)) {
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']
631 : 20;
632 $expireSeconds = $expireMinutes * 60;
633
634 Integration::update_meta($source_id, 'private_url_'.$size, $privateUrl, false, $expireSeconds, $source_type);
635 }
636 }
637 return $privateUrl;
638 } else {
639 $url = Service::instance()->get_url($key);
640 if(!Utils::is_empty($url)) {
641 return Cdn::may_generate_cdn_url($url, $key);
642 }
643 }
644 }
645 }
646 return false;
647 }
648
649
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
665 */
666 public function moveToServer($source_id, $size = 'full', $source_type = 'media_library', $all = false, $backup = false, $log_type = 'restore_to_server'){
667 $server_files = [];
668 $server_file = false;
669 $source_id = (int)$source_id;
670 $item = $this->get($source_id, $source_type);
671
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;
687 }
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;
748 }
749 }
750
751 return $results;
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
765 return false;
766 }
767
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 /**
818 * Get service path of item from database by source url
819 * @since 1.0.0
820 * @param int $source_id
821 * @param string $file
822 * @param string $source_type
823 * @return bool
824 */
825 public function moveToServerBySourcePath( $source_id, $file, $source_type = 'media_library' ) {
826 $source_id = (int) $source_id;
827
828 $item = $this->get( $source_id, $source_type );
829 if ( Utils::is_empty( $item ) ) {
830 return false;
831 }
832
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 ) {
884 return true;
885 }
886 }
887 }
888
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;
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
930 return false;
931 }
932
933
934 /**
935 * Move original file to server
936 *
937 * @param int $source_id
938 * @param string $source_type
939 * @return bool
940 */
941 public function moveOriginalToServer($source_id, $source_type = 'media_library') {
942 $data = $this->get($source_id, $source_type);
943 if ($data) {
944 $size = 'full';
945 if (
946 isset($data['original_source_path']) && !empty($data['original_source_path']) &&
947 isset($data['original_key']) && !empty($data['original_key'])
948 ) {
949 $size = 'original';
950 }
951 return $this->moveToServer($source_id, $size, $source_type);
952 }
953 return false;
954 }
955
956
957 /**
958 * Delete media item
959 */
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
966 $upload_dir = wp_get_upload_dir();
967
968 if (isset($item['extra']) && !empty($item['extra'])) {
969 $extras = Utils::maybe_unserialize($item['extra']);
970 if (
971 isset($extras) && !empty($extras) &&
972 isset($extras['sizes']) && !empty($extras['sizes'])
973 ) {
974 foreach ($extras['sizes'] as $sub_image) {
975 if (isset($sub_image['key']) && !empty($sub_image['key'])) {
976 Service::instance()->deleteSingle($sub_image['key']);
977 }
978 }
979 }
980
981 if (
982 isset($extras) && !empty($extras) &&
983 isset($extras['backup']) && !empty($extras['backup']) &&
984 $delete_backup
985 ) {
986 $backup = Utils::maybe_unserialize($extras['backup']);
987 if (isset($backup) && !empty($backup)) {
988 $this->delete_attachments_by_item($backup, false);
989 }
990 }
991 }
992
993 if (
994 isset($item['original_key']) && !empty($item['original_key'])
995 ) {
996 Service::instance()->deleteSingle($item['original_key']);
997 }
998
999 if (isset($item['key']) && !empty($item['key'])) {
1000 Service::instance()->deleteSingle($item['key']);
1001 }
1002 }
1003
1004
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 /**
1023 * Pre-update item actions
1024 * @since 1.2.13
1025 * @param int $source_id
1026 * @param array $data
1027 * @param string $source_type
1028 */
1029 public function pre_update_item($source_id, $new_item, $old_item = [], $source_type = 'media_library') {
1030 // Hook for pre-update actions
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 }
1040 }
1041
1042
1043 /**
1044 * Post-update item actions
1045 * @since 1.2.13
1046 * @param int $source_id
1047 * @param array $data
1048 * @param string $source_type
1049 * This function is called after an item has been updated in the database.
1050 * It triggers a WordPress action hook 'wpmcs_post_update_item' to allow other functions to hook into this event.
1051 * After that, it calls may_be_delete_server_files_by_id to potentially delete server files associated with the item.
1052 *
1053 * @example
1054 * $item = Item::instance();
1055 * $item->post_update_item(123, $data, 'media_library');
1056 *
1057 * This example will trigger the post-update actions for the item with ID 123.
1058 * It will execute any functions hooked to 'wpmcs_post_update_item' and may delete server files if the settings allow it.
1059 */
1060 public function post_update_item($source_id, $data, $source_type = 'media_library') {
1061 // Hook for post-update actions
1062 do_action('wpmcs_post_update_item', $source_id, $data, $source_type);
1063
1064 // May be delete server files
1065 $this->may_be_delete_server_files_by_id($source_id, $source_type, true, true);
1066 }
1067
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 /**
1135 * Function to remove media from server by id
1136 * @param int $attachment_id
1137 * @param string $source_type
1138 * @param bool $delete_main_file
1139 * @param bool $delete_backup
1140 *
1141 * This function checks if the item exists and if the setting to remove from server is enabled.
1142 * If so, it deletes the main file and any backup files associated with the item.
1143 * It also checks if the item has any extra data, and if so, it attempts to delete the backup files if specified.
1144 * Finally, it deletes the main file associated with the item.
1145 *
1146 * @since 1.2.13
1147 * @return bool Returns true if the deletion process was initiated, false otherwise.
1148 *
1149 * @throws \Exception If the item does not exist or if the removal from server setting is not enabled.
1150 *
1151 * @example
1152 * $item = Item::instance();
1153 * $item->may_be_delete_server_files_by_id(123, 'media_library', true, true);
1154 *
1155 * This example will attempt to delete the server files for the attachment with ID 123,
1156 * including the main file and any backup files, if the settings allow it.
1157 *
1158 * @see Item::may_be_delete_server_files_by_item() for the function that actually performs the deletion.
1159 */
1160 public function may_be_delete_server_files_by_id($attachment_id, $source_type = 'media_library', $delete_main_file=false, $delete_backup = false) {
1161 $item = $this->get($attachment_id, $source_type);
1162 if(Utils::is_empty($item)) {
1163 return false;
1164 }
1165
1166 if( !( isset($this->settings['remove_from_server']) && $this->settings['remove_from_server'] ) ) {
1167 return false;
1168 }
1169
1170 if (isset($item['extra']) && !empty($item['extra'])) {
1171 $extras = Utils::maybe_unserialize($item['extra']);
1172 if (
1173 isset($extras) && !empty($extras) &&
1174 isset($extras['backup']) && !empty($extras['backup']) &&
1175 $delete_backup
1176 ) {
1177 $backup = Utils::maybe_unserialize($extras['backup']);
1178 if (isset($backup) && !empty($backup)) {
1179 $this->may_be_delete_server_files_by_item($backup, $delete_main_file);
1180 }
1181 }
1182 }
1183
1184 $this->may_be_delete_server_files_by_item($item, $delete_main_file);
1185
1186 return true;
1187 }
1188
1189 /**
1190 * Function to remove media from server by item
1191 */
1192 public function may_be_delete_server_files_by_item( $item, $delete_main_file=false ) {
1193 if(Utils::is_empty($item)) {
1194 return false;
1195 }
1196
1197 if( !( isset($this->settings['remove_from_server']) && $this->settings['remove_from_server'] ) ) {
1198 return false;
1199 }
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 ) {
1209 $upload_dir = wp_get_upload_dir();
1210 $has_original = false;
1211 $files_to_remove = array();
1212
1213 $file_path = trailingslashit($upload_dir['basedir']) . $item['source_path'];
1214
1215 if (isset($item['extra']) && !empty($item['extra'])) {
1216 $extras = Utils::maybe_unserialize($item['extra']);
1217 if (
1218 isset($extras) && !empty($extras) &&
1219 isset($extras['sizes']) && !empty($extras['sizes'])
1220 ) {
1221 foreach ($extras['sizes'] as $sub_image) {
1222 if (isset($sub_image['source_path']) && !empty($sub_image['source_path'])) {
1223 $file = trailingslashit($upload_dir['basedir']) . $sub_image['source_path'];
1224 if(file_exists($file)) {
1225 $files_to_remove[] = $file;
1226 }
1227 }
1228 }
1229 }
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;
1238 }
1239 }
1240 if(file_exists($file_path)) {
1241 if ($has_original || (!$has_original && $delete_main_file)) {
1242 $files_to_remove[] = $file_path;
1243 }
1244 }
1245
1246
1247 $files_to_remove = apply_filters('wpmcs_files_to_remove_from_server', array_unique($files_to_remove), $item['source_id'], $item);
1248
1249 if (!Utils::is_empty($files_to_remove)) {
1250 foreach ($files_to_remove as $file) {
1251 wp_delete_file($file, true);
1252 }
1253 }
1254
1255 return true;
1256 }
1257
1258 /**
1259 * Ensures only one instance of Class is loaded or can be loaded.
1260 *
1261 * @return Item Class instance
1262 * @since 1.0.0
1263 * @static
1264 */
1265 public static function instance(){
1266 if (is_null(self::$instance)) {
1267 self::$instance = new self();
1268 }
1269 return self::$instance;
1270 }
1271 }