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

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

1,337 lines 47.8 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 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;
757 }
758 }
759
760 return $results;
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
774 return false;
775 }
776
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 /**
830 * Get service path of item from database by source url
831 * @since 1.0.0
832 * @param int $source_id
833 * @param string $file
834 * @param string $source_type
835 * @return bool
836 */
837 public function moveToServerBySourcePath( $source_id, $file, $source_type = 'media_library' ) {
838 $source_id = (int) $source_id;
839
840 $item = $this->get( $source_id, $source_type );
841 if ( Utils::is_empty( $item ) ) {
842 return false;
843 }
844
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 ) {
896 return true;
897 }
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;
916 }
917 }
918 }
919
920 return false;
921 }
922
923 /**
924 * Copy back a file from the service to the server
925 *
926 * @param string $key
927 * @param string $relative_path
928 * @param int $source_id
929 * @param string $source_type
930 * @param string $log_type error-log bucket to write to on failure
931 *
932 * @return string|false
933 */
934 protected function move_to_server_by_key_and_path( $key, $relative_path, $source_id = 0, $source_type = 'media_library', $log_type = 'restore_to_server' ) {
935 if ( empty( $key ) || empty( $relative_path ) ) {
936 return false;
937 }
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 /**
989 * Delete media item
990 */
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
997 $upload_dir = wp_get_upload_dir();
998
999 if (isset($item['extra']) && !empty($item['extra'])) {
1000 $extras = Utils::maybe_unserialize($item['extra']);
1001 if (
1002 isset($extras) && !empty($extras) &&
1003 isset($extras['sizes']) && !empty($extras['sizes'])
1004 ) {
1005 foreach ($extras['sizes'] as $sub_image) {
1006 if (isset($sub_image['key']) && !empty($sub_image['key'])) {
1007 Service::instance()->deleteSingle($sub_image['key']);
1008 }
1009 }
1010 }
1011
1012 if (
1013 isset($extras) && !empty($extras) &&
1014 isset($extras['additional_files']) && !empty($extras['additional_files'])
1015 ) {
1016 foreach ($extras['additional_files'] as $file) {
1017 if (isset($file['key']) && !empty($file['key'])) {
1018 Service::instance()->deleteSingle($file['key']);
1019 }
1020 }
1021 }
1022
1023 if (
1024 isset($extras) && !empty($extras) &&
1025 isset($extras['backup']) && !empty($extras['backup']) &&
1026 $delete_backup
1027 ) {
1028 $backup = Utils::maybe_unserialize($extras['backup']);
1029 if (isset($backup) && !empty($backup)) {
1030 $this->delete_attachments_by_item($backup, false);
1031 }
1032 }
1033 }
1034
1035 if (
1036 isset($item['original_key']) && !empty($item['original_key'])
1037 ) {
1038 Service::instance()->deleteSingle($item['original_key']);
1039 }
1040
1041 if (isset($item['key']) && !empty($item['key'])) {
1042 Service::instance()->deleteSingle($item['key']);
1043 }
1044 }
1045
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 /**
1177 * Function to remove media from server by id
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.
1201 */
1202 public function may_be_delete_server_files_by_id($attachment_id, $source_type = 'media_library', $delete_main_file=false, $delete_backup = false) {
1203 $item = $this->get($attachment_id, $source_type);
1204 if(Utils::is_empty($item)) {
1205 return false;
1206 }
1207
1208 if( !( isset($this->settings['remove_from_server']) && $this->settings['remove_from_server'] ) ) {
1209 return false;
1210 }
1211
1212 if (isset($item['extra']) && !empty($item['extra'])) {
1213 $extras = Utils::maybe_unserialize($item['extra']);
1214 if (
1215 isset($extras) && !empty($extras) &&
1216 isset($extras['backup']) && !empty($extras['backup']) &&
1217 $delete_backup
1218 ) {
1219 $backup = Utils::maybe_unserialize($extras['backup']);
1220 if (isset($backup) && !empty($backup)) {
1221 $this->may_be_delete_server_files_by_item($backup, $delete_main_file);
1222 }
1223 }
1224 }
1225
1226 $this->may_be_delete_server_files_by_item($item, $delete_main_file);
1227
1228 return true;
1229 }
1230
1231 /**
1232 * Function to remove media from server by item
1233 */
1234 public function may_be_delete_server_files_by_item( $item, $delete_main_file=false ) {
1235 if(Utils::is_empty($item)) {
1236 return false;
1237 }
1238
1239 if( !( isset($this->settings['remove_from_server']) && $this->settings['remove_from_server'] ) ) {
1240 return false;
1241 }
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 ) {
1251 $upload_dir = wp_get_upload_dir();
1252 $has_original = false;
1253 $files_to_remove = array();
1254
1255 $file_path = trailingslashit($upload_dir['basedir']) . $item['source_path'];
1256
1257 if (isset($item['extra']) && !empty($item['extra'])) {
1258 $extras = Utils::maybe_unserialize($item['extra']);
1259 if (
1260 isset($extras) && !empty($extras) &&
1261 isset($extras['sizes']) && !empty($extras['sizes'])
1262 ) {
1263 foreach ($extras['sizes'] as $sub_image) {
1264 if (isset($sub_image['source_path']) && !empty($sub_image['source_path'])) {
1265 $file = trailingslashit($upload_dir['basedir']) . $sub_image['source_path'];
1266 if(file_exists($file)) {
1267 $files_to_remove[] = $file;
1268 }
1269 }
1270 }
1271 }
1272 if (
1273 isset($extras) && !empty($extras) &&
1274 isset($extras['additional_files']) && !empty($extras['additional_files'])
1275 ) {
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 }
1294 }
1295 }
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 }
1306 if(file_exists($file_path)) {
1307 if ($has_original || (!$has_original && $delete_main_file)) {
1308 $files_to_remove[] = $file_path;
1309 }
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
1321 return true;
1322 }
1323
1324 /**
1325 * Ensures only one instance of Class is loaded or can be loaded.
1326 *
1327 * @return Item Class instance
1328 * @since 1.0.0
1329 * @static
1330 */
1331 public static function instance(){
1332 if (is_null(self::$instance)) {
1333 self::$instance = new self();
1334 }
1335 return self::$instance;
1336 }
1337 }