Make WordPress Core

Ticket #53255: 53255.3.diff

File 53255.3.diff, 14.2 KB (added by tippl, 7 weeks ago)

Added a warning to the delete confirmation screen in the Network Admin as suggested

  • 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..4fdcdfb535 100644
    a b class WP_Plugins_List_Table extends WP_List_Table {  
    2424         */
    2525        protected $show_autoupdates = true;
    2626
     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
    2738        /**
    2839         * Constructor.
    2940         *
    class WP_Plugins_List_Table extends WP_List_Table {  
    729740                }
    730741        }
    731742
     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                /**
     763                 * Filters the maximum number of sites queried when checking whether a plugin
     764                 * is active on any individual site before showing the delete action in the
     765                 * Network Admin plugins list. Defaults to 100 to avoid unbounded queries on
     766                 * very large networks. Increase the limit (or set to 0 for no limit) when
     767                 * accuracy across all sites is required.
     768                 *
     769                 * @since 6.8.0
     770                 *
     771                 * @param int $sites_query_limit Maximum number of sites to query. Default 100.
     772                 */
     773                $sites_query_limit = apply_filters( 'delete_plugin_subsite_check_limit', 100 );
     774
     775                $sites = get_sites(
     776                        array(
     777                                'fields' => 'ids',
     778                                'number' => $sites_query_limit,
     779                        )
     780                );
     781
     782                foreach ( $sites as $site_id ) {
     783                        $site_active_plugins = (array) get_blog_option( $site_id, 'active_plugins', array() );
     784                        foreach ( $site_active_plugins as $active_plugin_file ) {
     785                                if ( ! isset( $this->subsite_active_counts[ $active_plugin_file ] ) ) {
     786                                        $this->subsite_active_counts[ $active_plugin_file ] = 0;
     787                                }
     788                                ++$this->subsite_active_counts[ $active_plugin_file ];
     789                        }
     790                }
     791
     792                return $this->subsite_active_counts;
     793        }
     794
    732795        /**
    733796         * @global string $status
    734797         * @global int $page
    class WP_Plugins_List_Table extends WP_List_Table {  
    878941                                                }
    879942                                        }
    880943
    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                                                                 '&amp;checked[]=' . urlencode( $plugin_file ) .
    890                                                                 '&amp;plugin_status=' . $context .
    891                                                                 '&amp;paged=' . $page .
    892                                                                 '&amp;s=' . $s;
    893 
     944                                        if ( current_user_can( 'delete_plugins' ) ) {
     945                                                $subsite_counts    = $this->get_subsite_active_plugin_counts();
     946                                                $active_site_count = $subsite_counts[ $plugin_file ] ?? 0;
     947
     948                                                if ( $active_site_count > 0 ) {
     949                                                        /*
     950                                                         * Show a non-linked warning whenever the plugin is active on
     951                                                         * any individual site — including the main site. This check is
     952                                                         * intentionally placed before the is_plugin_active() guard so
     953                                                         * that a plugin active on the main site *and* subsites still
     954                                                         * shows the warning rather than a live delete link.
     955                                                         */
    894956                                                        $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' )
     957                                                                '<span aria-label="%1$s">%2$s</span>',
     958                                                                esc_attr(
     959                                                                        sprintf(
     960                                                                                /* translators: 1: Plugin name. 2: Number of sites the plugin is active on. */
     961                                                                                _n(
     962                                                                                        'Cannot delete %1$s. It is active on %2$d site.',
     963                                                                                        'Cannot delete %1$s. It is active on %2$d sites.',
     964                                                                                        $active_site_count
     965                                                                                ),
     966                                                                                $plugin_data['Name'],
     967                                                                                $active_site_count
     968                                                                        )
     969                                                                ),
     970                                                                sprintf(
     971                                                                        /* translators: %d: Number of sites the plugin is active on. */
     972                                                                        _n( 'Active on %d site', 'Active on %d sites', $active_site_count ),
     973                                                                        $active_site_count
     974                                                                )
    901975                                                        );
     976                                                } elseif ( ! is_plugin_active( $plugin_file ) ) {
     977                                                        if ( $has_dependents && ! $has_circular_dependency ) {
     978                                                                $actions['delete'] = __( 'Delete' ) .
     979                                                                        '<span class="screen-reader-text">' .
     980                                                                        __( 'You cannot delete this plugin as other plugins require it.' ) .
     981                                                                        '</span>';
     982                                                        } else {
     983                                                                $delete_url = 'plugins.php?action=delete-selected' .
     984                                                                        '&amp;checked[]=' . urlencode( $plugin_file ) .
     985                                                                        '&amp;plugin_status=' . $context .
     986                                                                        '&amp;paged=' . $page .
     987                                                                        '&amp;s=' . $s;
     988
     989                                                                $actions['delete'] = sprintf(
     990                                                                        '<a href="%s" id="delete-%s" class="delete" aria-label="%s">%s</a>',
     991                                                                        wp_nonce_url( $delete_url, 'bulk-plugins' ),
     992                                                                        esc_attr( $plugin_id_attr ),
     993                                                                        /* translators: %s: Plugin name. */
     994                                                                        esc_attr( sprintf( _x( 'Delete %s', 'plugin' ), $plugin_data['Name'] ) ),
     995                                                                        __( 'Delete' )
     996                                                                );
     997                                                        }
    902998                                                }
    903999                                        }
    9041000                                }
    class WP_Plugins_List_Table extends WP_List_Table {  
    12791375
    12801376                                        echo '</div>';
    12811377
    1282                                         if ( $has_dependents ) {
     1378                                        $row_active_site_count = $screen->in_admin( 'network' )
     1379                                                ? ( $this->get_subsite_active_plugin_counts()[ $plugin_file ] ?? 0 )
     1380                                                : 0;
     1381
     1382                                        if ( $has_dependents && $row_active_site_count > 0 ) {
     1383                                                // Both: dependents note with the subsite sentence appended.
     1384                                                $this->add_dependents_to_dependency_plugin_row( $plugin_file, $row_active_site_count );
     1385                                        } elseif ( $has_dependents ) {
    12831386                                                $this->add_dependents_to_dependency_plugin_row( $plugin_file );
     1387                                        } elseif ( $row_active_site_count > 0 ) {
     1388                                                $this->add_subsite_active_note_to_plugin_row( $row_active_site_count );
    12841389                                        }
    12851390
    12861391                                        if ( WP_Plugin_Dependencies::has_dependencies( $plugin_file ) ) {
    class WP_Plugins_List_Table extends WP_List_Table {  
    15471652         *
    15481653         * @param string $dependency The dependency's filepath, relative to the plugins directory.
    15491654         */
    1550         protected function add_dependents_to_dependency_plugin_row( $dependency ) {
     1655        /**
     1656         * @since 6.5.0
     1657         * @since 6.8.0 Added the `$active_site_count` parameter to optionally append a
     1658         *              subsite-active sentence to the existing note.
     1659         *
     1660         * @param string $dependency       The dependency plugin's filepath, relative to the plugins directory.
     1661         * @param int    $active_site_count Number of individual sites the plugin is active on. When > 0,
     1662         *                                 a sentence is appended to the note. Default 0.
     1663         */
     1664        protected function add_dependents_to_dependency_plugin_row( $dependency, $active_site_count = 0 ) {
    15511665                $dependent_names = WP_Plugin_Dependencies::get_dependent_names( $dependency );
    15521666
    15531667                if ( empty( $dependent_names ) ) {
    class WP_Plugins_List_Table extends WP_List_Table {  
    15561670
    15571671                $dependency_note = __( 'Note: This plugin cannot be deactivated or deleted until the plugins that require it are deactivated or deleted.' );
    15581672
     1673                if ( $active_site_count > 0 ) {
     1674                        $dependency_note .= ' ' . sprintf(
     1675                                /* translators: %d: Number of sites the plugin is active on. */
     1676                                _n(
     1677                                        'It is also active on %d site.',
     1678                                        'It is also active on %d sites.',
     1679                                        $active_site_count
     1680                                ),
     1681                                $active_site_count
     1682                        );
     1683                }
     1684
    15591685                $comma       = wp_get_list_item_separator();
    15601686                $required_by = sprintf(
    15611687                        /* translators: %s: List of dependencies. */
    class WP_Plugins_List_Table extends WP_List_Table {  
    15701696                );
    15711697        }
    15721698
     1699        /**
     1700         * Prints a note that the plugin cannot be deleted because it is active on individual sites.
     1701         * Uses the same HTML structure as {@see add_dependents_to_dependency_plugin_row()}.
     1702         *
     1703         * @since 6.8.0
     1704         *
     1705         * @param int $active_site_count Number of individual sites the plugin is active on.
     1706         */
     1707        protected function add_subsite_active_note_to_plugin_row( $active_site_count ) {
     1708                $note = sprintf(
     1709                        /* translators: %d: Number of sites the plugin is active on. */
     1710                        _n(
     1711                                'Note: This plugin cannot be deleted because it is active on %d site.',
     1712                                'Note: This plugin cannot be deleted because it is active on %d sites.',
     1713                                $active_site_count
     1714                        ),
     1715                        $active_site_count
     1716                );
     1717
     1718                printf( '<div class="required-by"><p>%s</p></div>', $note );
     1719        }
     1720
    15731721        /**
    15741722         * Prints a list of other plugins that the plugin depends on.
    15751723         *
  • 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 = '' ) {  
    908908                return false;
    909909        }
    910910
     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
    911946        $checked = array();
    912947        foreach ( $plugins as $plugin ) {
    913948                $checked[] = 'checked[]=' . $plugin;
  • src/wp-admin/plugins.php

    diff --git a/src/wp-admin/plugins.php b/src/wp-admin/plugins.php
    index 6a359c822f..068209fd8f 100644
    a b if ( $action ) {  
    397397
    398398                                ?>
    399399                                </p>
     400                                <?php if ( is_network_admin() ) : ?>
     401                                <p><?php _e( 'This plugin may be active on individual sites in the network.', 'default' ); ?></p>
     402                                <?php endif; ?>
    400403                                <form method="post" action="<?php echo esc_url( $_SERVER['REQUEST_URI'] ); ?>" style="display:inline;">
    401404                                        <input type="hidden" name="verify-delete" value="1" />
    402405                                        <input type="hidden" name="action" value="delete-selected" />
  • 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 {  
    739739                        rmdir( $di_bu_dir );
    740740                }
    741741        }
     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        }
    742834}