Ticket #53255: 53255.diff
| File 53255.diff, 13.0 KB (added by , 5 weeks ago) |
|---|
-
src/wp-admin/includes/class-wp-plugins-list-table.php
diff --git a/src/wp-admin/includes/class-wp-plugins-list-table.php b/src/wp-admin/includes/class-wp-plugins-list-table.php index c4866076f9..37ec9895c1 100644
a b class WP_Plugins_List_Table extends WP_List_Table { 24 24 */ 25 25 protected $show_autoupdates = true; 26 26 27 /** 28 * Map of plugin file to the number of individual sites it is active on. 29 * 30 * Null until first accessed; populated lazily by get_subsite_active_plugin_counts(). 31 * 32 * @since 6.8.0 33 * 34 * @var array<string,int>|null 35 */ 36 protected $subsite_active_counts = null; 37 27 38 /** 28 39 * Constructor. 29 40 * … … class WP_Plugins_List_Table extends WP_List_Table { 729 740 } 730 741 } 731 742 743 /** 744 * Returns a map of plugin file paths to the number of individual (non-network) sites 745 * they are active on. Computed once per page load and cached in $subsite_active_counts. 746 * 747 * @since 6.8.0 748 * 749 * @return array<string,int> Plugin file => site count. Empty array on non-multisite. 750 */ 751 protected function get_subsite_active_plugin_counts() { 752 if ( null !== $this->subsite_active_counts ) { 753 return $this->subsite_active_counts; 754 } 755 756 $this->subsite_active_counts = array(); 757 758 if ( ! is_multisite() ) { 759 return $this->subsite_active_counts; 760 } 761 762 $sites = get_sites( 763 array( 764 'fields' => 'ids', 765 'number' => 0, 766 ) 767 ); 768 769 foreach ( $sites as $site_id ) { 770 $site_active_plugins = (array) get_blog_option( $site_id, 'active_plugins', array() ); 771 foreach ( $site_active_plugins as $active_plugin_file ) { 772 if ( ! isset( $this->subsite_active_counts[ $active_plugin_file ] ) ) { 773 $this->subsite_active_counts[ $active_plugin_file ] = 0; 774 } 775 ++$this->subsite_active_counts[ $active_plugin_file ]; 776 } 777 } 778 779 return $this->subsite_active_counts; 780 } 781 732 782 /** 733 783 * @global string $status 734 784 * @global int $page … … class WP_Plugins_List_Table extends WP_List_Table { 878 928 } 879 929 } 880 930 881 if ( current_user_can( 'delete_plugins' ) && ! is_plugin_active( $plugin_file ) ) { 882 if ( $has_dependents && ! $has_circular_dependency ) { 883 $actions['delete'] = __( 'Delete' ) . 884 '<span class="screen-reader-text">' . 885 __( 'You cannot delete this plugin as other plugins require it.' ) . 886 '</span>'; 887 } else { 888 $delete_url = 'plugins.php?action=delete-selected' . 889 '&checked[]=' . urlencode( $plugin_file ) . 890 '&plugin_status=' . $context . 891 '&paged=' . $page . 892 '&s=' . $s; 893 931 if ( current_user_can( 'delete_plugins' ) ) { 932 $subsite_counts = $this->get_subsite_active_plugin_counts(); 933 $active_site_count = $subsite_counts[ $plugin_file ] ?? 0; 934 935 if ( $active_site_count > 0 ) { 936 /* 937 * Show a non-linked warning whenever the plugin is active on 938 * any individual site — including the main site. This check is 939 * intentionally placed before the is_plugin_active() guard so 940 * that a plugin active on the main site *and* subsites still 941 * shows the warning rather than a live delete link. 942 */ 894 943 $actions['delete'] = sprintf( 895 '<a href="%s" id="delete-%s" class="delete" aria-label="%s">%s</a>', 896 wp_nonce_url( $delete_url, 'bulk-plugins' ), 897 esc_attr( $plugin_id_attr ), 898 /* translators: %s: Plugin name. */ 899 esc_attr( sprintf( _x( 'Delete %s', 'plugin' ), $plugin_data['Name'] ) ), 900 __( 'Delete' ) 944 '<span aria-label="%1$s">%2$s</span>', 945 esc_attr( 946 sprintf( 947 /* translators: 1: Plugin name. 2: Number of sites the plugin is active on. */ 948 _n( 949 'Cannot delete %1$s. It is active on %2$d site.', 950 'Cannot delete %1$s. It is active on %2$d sites.', 951 $active_site_count 952 ), 953 $plugin_data['Name'], 954 $active_site_count 955 ) 956 ), 957 sprintf( 958 /* translators: %d: Number of sites the plugin is active on. */ 959 _n( 'Active on %d site', 'Active on %d sites', $active_site_count ), 960 $active_site_count 961 ) 901 962 ); 963 } elseif ( ! is_plugin_active( $plugin_file ) ) { 964 if ( $has_dependents && ! $has_circular_dependency ) { 965 $actions['delete'] = __( 'Delete' ) . 966 '<span class="screen-reader-text">' . 967 __( 'You cannot delete this plugin as other plugins require it.' ) . 968 '</span>'; 969 } else { 970 $delete_url = 'plugins.php?action=delete-selected' . 971 '&checked[]=' . urlencode( $plugin_file ) . 972 '&plugin_status=' . $context . 973 '&paged=' . $page . 974 '&s=' . $s; 975 976 $actions['delete'] = sprintf( 977 '<a href="%s" id="delete-%s" class="delete" aria-label="%s">%s</a>', 978 wp_nonce_url( $delete_url, 'bulk-plugins' ), 979 esc_attr( $plugin_id_attr ), 980 /* translators: %s: Plugin name. */ 981 esc_attr( sprintf( _x( 'Delete %s', 'plugin' ), $plugin_data['Name'] ) ), 982 __( 'Delete' ) 983 ); 984 } 902 985 } 903 986 } 904 987 } … … class WP_Plugins_List_Table extends WP_List_Table { 1279 1362 1280 1363 echo '</div>'; 1281 1364 1282 if ( $has_dependents ) { 1365 $row_active_site_count = $screen->in_admin( 'network' ) 1366 ? ( $this->get_subsite_active_plugin_counts()[ $plugin_file ] ?? 0 ) 1367 : 0; 1368 1369 if ( $has_dependents && $row_active_site_count > 0 ) { 1370 // Both: dependents note with the subsite sentence appended. 1371 $this->add_dependents_to_dependency_plugin_row( $plugin_file, $row_active_site_count ); 1372 } elseif ( $has_dependents ) { 1283 1373 $this->add_dependents_to_dependency_plugin_row( $plugin_file ); 1374 } elseif ( $row_active_site_count > 0 ) { 1375 $this->add_subsite_active_note_to_plugin_row( $row_active_site_count ); 1284 1376 } 1285 1377 1286 1378 if ( WP_Plugin_Dependencies::has_dependencies( $plugin_file ) ) { … … class WP_Plugins_List_Table extends WP_List_Table { 1547 1639 * 1548 1640 * @param string $dependency The dependency's filepath, relative to the plugins directory. 1549 1641 */ 1550 protected function add_dependents_to_dependency_plugin_row( $dependency ) { 1642 /** 1643 * @since 6.5.0 1644 * @since 6.8.0 Added the `$active_site_count` parameter to optionally append a 1645 * subsite-active sentence to the existing note. 1646 * 1647 * @param string $dependency The dependency plugin's filepath, relative to the plugins directory. 1648 * @param int $active_site_count Number of individual sites the plugin is active on. When > 0, 1649 * a sentence is appended to the note. Default 0. 1650 */ 1651 protected function add_dependents_to_dependency_plugin_row( $dependency, $active_site_count = 0 ) { 1551 1652 $dependent_names = WP_Plugin_Dependencies::get_dependent_names( $dependency ); 1552 1653 1553 1654 if ( empty( $dependent_names ) ) { … … class WP_Plugins_List_Table extends WP_List_Table { 1556 1657 1557 1658 $dependency_note = __( 'Note: This plugin cannot be deactivated or deleted until the plugins that require it are deactivated or deleted.' ); 1558 1659 1660 if ( $active_site_count > 0 ) { 1661 $dependency_note .= ' ' . sprintf( 1662 /* translators: %d: Number of sites the plugin is active on. */ 1663 _n( 1664 'It is also active on %d site.', 1665 'It is also active on %d sites.', 1666 $active_site_count 1667 ), 1668 $active_site_count 1669 ); 1670 } 1671 1559 1672 $comma = wp_get_list_item_separator(); 1560 1673 $required_by = sprintf( 1561 1674 /* translators: %s: List of dependencies. */ … … class WP_Plugins_List_Table extends WP_List_Table { 1570 1683 ); 1571 1684 } 1572 1685 1686 /** 1687 * Prints a note that the plugin cannot be deleted because it is active on individual sites. 1688 * Uses the same HTML structure as {@see add_dependents_to_dependency_plugin_row()}. 1689 * 1690 * @since 6.8.0 1691 * 1692 * @param int $active_site_count Number of individual sites the plugin is active on. 1693 */ 1694 protected function add_subsite_active_note_to_plugin_row( $active_site_count ) { 1695 $note = sprintf( 1696 /* translators: %d: Number of sites the plugin is active on. */ 1697 _n( 1698 'Note: This plugin cannot be deleted because it is active on %d site.', 1699 'Note: This plugin cannot be deleted because it is active on %d sites.', 1700 $active_site_count 1701 ), 1702 $active_site_count 1703 ); 1704 1705 printf( '<div class="required-by"><p>%s</p></div>', $note ); 1706 } 1707 1573 1708 /** 1574 1709 * Prints a list of other plugins that the plugin depends on. 1575 1710 * -
src/wp-admin/includes/plugin.php
diff --git a/src/wp-admin/includes/plugin.php b/src/wp-admin/includes/plugin.php index 94d4a585a8..94f80cbf2e 100644
a b function delete_plugins( $plugins, $deprecated = '' ) { 908 908 return false; 909 909 } 910 910 911 /* 912 * In Multisite, do not delete a plugin that is still active on any individual 913 * site. The check was dropped in 4.6 (changeset 37714 / Shiny Updates) when 914 * the filesystem workflow was restructured, but the protection is still needed. 915 * 916 * @see https://core.trac.wordpress.org/ticket/53255 917 */ 918 if ( is_multisite() ) { 919 $active_on_sites = array(); 920 921 $sites = get_sites( 922 array( 923 'fields' => 'ids', 924 'number' => 0, 925 ) 926 ); 927 928 foreach ( $sites as $site_id ) { 929 $site_active_plugins = (array) get_blog_option( $site_id, 'active_plugins', array() ); 930 foreach ( $plugins as $plugin_file ) { 931 if ( in_array( $plugin_file, $site_active_plugins, true ) ) { 932 $active_on_sites[ $plugin_file ][] = $site_id; 933 } 934 } 935 } 936 937 if ( ! empty( $active_on_sites ) ) { 938 return new WP_Error( 939 'active_plugin', 940 __( 'Cannot delete a plugin while it is active on a site.' ), 941 $active_on_sites 942 ); 943 } 944 } 945 911 946 $checked = array(); 912 947 foreach ( $plugins as $plugin ) { 913 948 $checked[] = 'checked[]=' . $plugin; -
tests/phpunit/tests/admin/includesPlugin.php
diff --git a/tests/phpunit/tests/admin/includesPlugin.php b/tests/phpunit/tests/admin/includesPlugin.php index 4d47fb7db0..466c223ede 100644
a b class Tests_Admin_IncludesPlugin extends WP_UnitTestCase { 739 739 rmdir( $di_bu_dir ); 740 740 } 741 741 } 742 743 /** 744 * Tests that delete_plugins() returns a WP_Error when the plugin is active on a subsite. 745 * 746 * @ticket 53255 747 * @group ms-required 748 * 749 * @covers ::delete_plugins 750 */ 751 public function test_delete_plugins_returns_wp_error_when_plugin_active_on_subsite() { 752 $plugin = $this->_create_plugin(); 753 $site_id = self::factory()->blog->create(); 754 755 update_blog_option( $site_id, 'active_plugins', array( $plugin[0] ) ); 756 757 $result = delete_plugins( array( $plugin[0] ) ); 758 759 // Clean up. 760 update_blog_option( $site_id, 'active_plugins', array() ); 761 if ( file_exists( $plugin[1] ) ) { 762 unlink( $plugin[1] ); 763 } 764 765 $this->assertWPError( $result ); 766 $this->assertSame( 'active_plugin', $result->get_error_code() ); 767 $this->assertArrayHasKey( $plugin[0], $result->get_error_data() ); 768 $this->assertContains( $site_id, $result->get_error_data()[ $plugin[0] ] ); 769 } 770 771 /** 772 * Tests that delete_plugins() does not block deletion when the plugin is not active on any subsite. 773 * 774 * @ticket 53255 775 * @group ms-required 776 * 777 * @covers ::delete_plugins 778 */ 779 public function test_delete_plugins_does_not_block_when_plugin_not_active_on_any_subsite() { 780 $plugin = $this->_create_plugin(); 781 782 $result = delete_plugins( array( $plugin[0] ) ); 783 784 // Clean up if the file was not removed (e.g. no filesystem credentials in test env). 785 if ( file_exists( $plugin[1] ) ) { 786 unlink( $plugin[1] ); 787 } 788 789 // The function must not return the active_plugin error. 790 if ( is_wp_error( $result ) ) { 791 $this->assertNotSame( 792 'active_plugin', 793 $result->get_error_code(), 794 'delete_plugins() should not block deletion of an inactive plugin.' 795 ); 796 } else { 797 // null (no credentials) or true (deleted) are both acceptable. 798 $this->assertNotFalse( $result ); 799 } 800 } 801 802 /** 803 * Tests that delete_plugins() returns a WP_Error listing all affected plugins and sites. 804 * 805 * @ticket 53255 806 * @group ms-required 807 * 808 * @covers ::delete_plugins 809 */ 810 public function test_delete_plugins_error_data_contains_all_active_plugins() { 811 $plugin_a = $this->_create_plugin( "<?php\n/*\nPlugin Name: Test A\n*/" ); 812 $plugin_b = $this->_create_plugin( "<?php\n/*\nPlugin Name: Test B\n*/" ); 813 $site_id = self::factory()->blog->create(); 814 815 update_blog_option( $site_id, 'active_plugins', array( $plugin_a[0], $plugin_b[0] ) ); 816 817 $result = delete_plugins( array( $plugin_a[0], $plugin_b[0] ) ); 818 819 // Clean up. 820 update_blog_option( $site_id, 'active_plugins', array() ); 821 foreach ( array( $plugin_a[1], $plugin_b[1] ) as $file ) { 822 if ( file_exists( $file ) ) { 823 unlink( $file ); 824 } 825 } 826 827 $this->assertWPError( $result ); 828 $this->assertSame( 'active_plugin', $result->get_error_code() ); 829 830 $data = $result->get_error_data(); 831 $this->assertArrayHasKey( $plugin_a[0], $data, 'Error data should include plugin A.' ); 832 $this->assertArrayHasKey( $plugin_b[0], $data, 'Error data should include plugin B.' ); 833 } 742 834 }
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)