| @@ -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() { |
| @@ -46,14 +55,10 @@ | ||
| 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 |
| @@ -377,10 +382,13 @@ | ||
| 377 | 382 | if( |
| 378 | 383 | ($check_rewrite && (isset($this->settings['rewrite_url']) && $this->settings['rewrite_url'])) || |
| 379 | 384 | !$check_rewrite |
| 380 | 385 | ) { |
| 386 | + if ($check_rewrite) { | |
| 387 | + return (bool) apply_filters('wpmcs_is_available_from_provider', true, $attachment_id, $source_type); | |
| 388 | + } | |
| 381 | 389 | return true; |
| 382 | - } | |
| 390 | + } | |
| 383 | 391 | } |
| 384 | 392 | return false; |
| 385 | 393 | } |
| 386 | 394 | |
| @@ -613,9 +621,12 @@ | ||
| 613 | 621 | if ($privateUrl === false) { |
| 614 | 622 | $new_url = Service::instance()->get_private_url($key); |
| 615 | 623 | |
| 616 | 624 | if (!Utils::is_empty($new_url)) { |
| 617 | - $privateUrl = Cdn::may_generate_cdn_url($new_url, $key); | |
| 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 ); | |
| 618 | 629 | $expireMinutes = (int)(isset($this->settings['private_url_expire']) && !empty($this->settings['private_url_expire'])) |
| 619 | 630 | ? $this->settings['private_url_expire'] |
| 620 | 631 | : 20; |
| 621 | 632 | $expireSeconds = $expireMinutes * 60; |
| @@ -645,11 +656,15 @@ | ||
| 645 | 656 | * @param string $size size of the file, default is full |
| 646 | 657 | * @param string $source_type source type of item, default is media_library |
| 647 | 658 | * @param bool $all if true, it will move all files to server |
| 648 | 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. | |
| 649 | 664 | * @return array an array of server file paths |
| 650 | 665 | */ |
| 651 | - public function moveToServer($source_id, $size = 'full', $source_type = 'media_library', $all = false, $backup = false){ | |
| 666 | + public function moveToServer($source_id, $size = 'full', $source_type = 'media_library', $all = false, $backup = false, $log_type = 'restore_to_server'){ | |
| 652 | 667 | $server_files = []; |
| 653 | 668 | $server_file = false; |
| 654 | 669 | $source_id = (int)$source_id; |
| 655 | 670 | $item = $this->get($source_id, $source_type); |
| @@ -654,12 +669,12 @@ | ||
| 654 | 669 | $source_id = (int)$source_id; |
| 655 | 670 | $item = $this->get($source_id, $source_type); |
| 656 | 671 | |
| 657 | 672 | // Remove log if exists before move to server |
| 658 | - Logger::instance()->remove_log('restore_to_server', $source_id, $source_type); | |
| 673 | + Logger::instance()->remove_log($log_type, $source_id, $source_type); | |
| 659 | 674 | |
| 660 | 675 | if ( isset($item) && !empty($item) ) { |
| 661 | - $files = $this->moveToServerByItem($item, $size, $all); | |
| 676 | + $files = $this->moveToServerByItem($item, $size, $all, $log_type); | |
| 662 | 677 | if (isset($files) && !empty($files)) { |
| 663 | 678 | $server_files = $all ? array_merge($server_files, $files) : $files; |
| 664 | 679 | } |
| 665 | 680 | } |
| @@ -665,9 +680,9 @@ | ||
| 665 | 680 | } |
| 666 | 681 | if( $all && $backup ) { |
| 667 | 682 | $backupItem = $this->get_backup($source_id, $source_type); |
| 668 | 683 | if (isset($backupItem) && !empty($backupItem)) { |
| 669 | - $files = $this->moveToServerByItem($backupItem, $size, $all); | |
| 684 | + $files = $this->moveToServerByItem($backupItem, $size, $all, $log_type); | |
| 670 | 685 | if (isset($files) && !empty($files)) { |
| 671 | 686 | $server_files['backup'] = $files; |
| 672 | 687 | } |
| 673 | 688 | } |
| @@ -682,11 +697,12 @@ | ||
| 682 | 697 | * @since 1.0.0 |
| 683 | 698 | * @param array $item |
| 684 | 699 | * @param string $size |
| 685 | 700 | * @param bool $all |
| 701 | + * @param string $log_type error-log bucket to write to on failure | |
| 686 | 702 | * @return array|string |
| 687 | 703 | */ |
| 688 | - public function moveToServerByItem( $item = [], $size = 'full', $all = false ) { | |
| 704 | + public function moveToServerByItem( $item = [], $size = 'full', $all = false, $log_type = 'restore_to_server' ) { | |
| 689 | 705 | $source_id = (int) ( $item['source_id'] ?? 0 ); |
| 690 | 706 | // Validate source ID |
| 691 | 707 | if( $source_id <= 0 ) { |
| 692 | 708 | return false; |
| @@ -715,8 +731,17 @@ | ||
| 715 | 731 | ]; |
| 716 | 732 | } |
| 717 | 733 | } |
| 718 | 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 | + | |
| 719 | 744 | // ALL files |
| 720 | 745 | if ( $all ) { |
| 721 | 746 | $results = []; |
| 722 | 747 | |
| @@ -724,9 +749,10 @@ | ||
| 724 | 749 | if ( $file = $this->move_to_server_by_key_and_path( |
| 725 | 750 | $data['key'], |
| 726 | 751 | $data['path'], |
| 727 | 752 | $source_id, |
| 728 | - $source_type | |
| 753 | + $source_type, | |
| 754 | + $log_type | |
| 729 | 755 | ) ) { |
| 730 | 756 | $results[ $label ] = $file; |
| 731 | 757 | } |
| 732 | 758 | } |
| @@ -739,9 +765,10 @@ | ||
| 739 | 765 | return $this->move_to_server_by_key_and_path( |
| 740 | 766 | $files[ $size ]['key'], |
| 741 | 767 | $files[ $size ]['path'], |
| 742 | 768 | $source_id, |
| 743 | - $source_type | |
| 769 | + $source_type, | |
| 770 | + $log_type | |
| 744 | 771 | ); |
| 745 | 772 | } |
| 746 | 773 | |
| 747 | 774 | return false; |
| @@ -746,11 +773,61 @@ | ||
| 746 | 773 | |
| 747 | 774 | return false; |
| 748 | 775 | } |
| 749 | 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 | + } | |
| 750 | 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 | + } | |
| 751 | 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 | + | |
| 752 | 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 | + /** | |
| 753 | 830 | * Get service path of item from database by source url |
| 754 | 831 | * @since 1.0.0 |
| 755 | 832 | * @param int $source_id |
| 756 | 833 | * @param string $file |
| @@ -800,9 +877,9 @@ | ||
| 800 | 877 | return true; |
| 801 | 878 | } |
| 802 | 879 | |
| 803 | 880 | $extras = $this->get_extras( $source_id, false, $source_type ) ?: []; |
| 804 | - | |
| 881 | + | |
| 805 | 882 | // 3. Check sizes |
| 806 | 883 | if ( ! empty( $extras['sizes'] ) ) { |
| 807 | 884 | foreach ( $extras['sizes'] as $size ) { |
| 808 | 885 | if ( |
| @@ -820,8 +897,27 @@ | ||
| 820 | 897 | } |
| 821 | 898 | } |
| 822 | 899 | } |
| 823 | 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 | + | |
| 824 | 920 | return false; |
| 825 | 921 | } |
| 826 | 922 | |
| 827 | 923 | /** |
| @@ -830,12 +926,13 @@ | ||
| 830 | 926 | * @param string $key |
| 831 | 927 | * @param string $relative_path |
| 832 | 928 | * @param int $source_id |
| 833 | 929 | * @param string $source_type |
| 930 | + * @param string $log_type error-log bucket to write to on failure | |
| 834 | 931 | * |
| 835 | 932 | * @return string|false |
| 836 | 933 | */ |
| 837 | - protected function move_to_server_by_key_and_path( $key, $relative_path, $source_id = 0, $source_type = 'media_library' ) { | |
| 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' ) { | |
| 838 | 935 | if ( empty( $key ) || empty( $relative_path ) ) { |
| 839 | 936 | return false; |
| 840 | 937 | } |
| 841 | 938 | |
| @@ -845,13 +942,18 @@ | ||
| 845 | 942 | if ( file_exists( $file ) ) { |
| 846 | 943 | return $file; |
| 847 | 944 | } |
| 848 | 945 | |
| 849 | - if ( Service::instance()->object_to_server( $key, $file ) ) { | |
| 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 ) ) { | |
| 850 | 952 | return $file; |
| 851 | 953 | } |
| 852 | 954 | |
| 853 | - Logger::instance()->add_log( 'restore_to_server', $source_id, $source_type, [ | |
| 955 | + Logger::instance()->add_log( $log_type, $source_id, $source_type, [ | |
| 854 | 956 | 'message' => __( 'The file could not be copied to the server. Please try again.', 'media-cloud-sync' ), |
| 855 | 957 | 'file' => $key, |
| 856 | 958 | 'code' => 404, |
| 857 | 959 | ] ); |
| @@ -886,8 +988,13 @@ | ||
| 886 | 988 | /** |
| 887 | 989 | * Delete media item |
| 888 | 990 | */ |
| 889 | 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 | + | |
| 890 | 997 | $upload_dir = wp_get_upload_dir(); |
| 891 | 998 | |
| 892 | 999 | if (isset($item['extra']) && !empty($item['extra'])) { |
| 893 | 1000 | $extras = Utils::maybe_unserialize($item['extra']); |
| @@ -903,8 +1010,19 @@ | ||
| 903 | 1010 | } |
| 904 | 1011 | |
| 905 | 1012 | if ( |
| 906 | 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) && | |
| 907 | 1025 | isset($extras['backup']) && !empty($extras['backup']) && |
| 908 | 1026 | $delete_backup |
| 909 | 1027 | ) { |
| 910 | 1028 | $backup = Utils::maybe_unserialize($extras['backup']); |
| @@ -988,9 +1106,57 @@ | ||
| 988 | 1106 | // May be delete server files |
| 989 | 1107 | $this->may_be_delete_server_files_by_id($source_id, $source_type, true, true); |
| 990 | 1108 | } |
| 991 | 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 | + } | |
| 992 | 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 | + | |
| 993 | 1159 | public function may_be_delete_server_files_by_source_paths($source_paths) { |
| 994 | 1160 | if (Utils::is_empty($source_paths) || !is_array($source_paths)) { |
| 995 | 1161 | return false; |
| 996 | 1162 | } |
| @@ -1096,8 +1262,32 @@ | ||
| 1096 | 1262 | ) { |
| 1097 | 1263 | foreach ($extras['sizes'] as $sub_image) { |
| 1098 | 1264 | if (isset($sub_image['source_path']) && !empty($sub_image['source_path'])) { |
| 1099 | 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']; | |
| 1100 | 1290 | if(file_exists($file)) { |
| 1101 | 1291 | $files_to_remove[] = $file; |
| 1102 | 1292 | } |
| 1103 | 1293 | } |