Last active
October 10, 2022 12:21
-
-
Save felixarntz/daff4006112b60dfea677ca08fc0b31c to your computer and use it in GitHub Desktop.
WP Plugin MU Loader
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
/** | |
* Plugin initialization file | |
* | |
* @package WP_Plugin_MU_Loader | |
* @since 1.0.0 | |
* | |
* @wordpress-plugin | |
* Plugin Name: WP Plugin MU Loader | |
* Plugin URI: https://gist.github.com/felixarntz/daff4006112b60dfea677ca08fc0b31c | |
* Description: Loads regular plugins from the plugins directory as must-use plugins, enforcing their activity while maintaining the typical update flow. | |
* Version: 1.0.0 | |
* Author: Felix Arntz | |
* Author URI: https://leaves-and-love.net | |
* License: GNU General Public License v2 (or later) | |
* License URI: http://www.gnu.org/licenses/gpl-2.0.html | |
* Text Domain: wp-plugin-mu-loader | |
*/ | |
defined( 'ABSPATH' ) || exit; | |
if ( wp_installing() ) { | |
return; | |
} | |
/** | |
* Class responsible for loading regular plugins as must-use (MU) plugins. | |
* | |
* @since 1.0.0 | |
*/ | |
class WP_Plugin_MU_Loader { | |
/** | |
* Name of the option to use as internal cache. | |
* | |
* @since 1.0.0 | |
*/ | |
const CACHE_OPTION_NAME = 'wp_plugin_mu_loader'; | |
/** | |
* Prefix to use for the internal AJAX actions. | |
* | |
* @since 1.0.0 | |
*/ | |
const AJAX_ACTION_PREFIX = 'wp_plugin_mu_loader_'; | |
/** | |
* Basenames of the plugins to load as MU plugins. | |
* | |
* @since 1.0.0 | |
* @var array | |
*/ | |
protected $plugin_basenames = array(); | |
/** | |
* Basenames of the plugins to load as MU plugins, as previously stored. | |
* | |
* @since 1.0.0 | |
* @var array | |
*/ | |
protected $old_plugin_basenames = null; | |
/** | |
* Constructor. | |
* | |
* @since 1.0.0 | |
* | |
* @param array $plugin_basenames Optional. Basenames for plugins to load. Alternatively, the | |
* `load_plugin()` method can be used to load a single plugin. | |
* Default empty array. | |
*/ | |
public function __construct( array $plugin_basenames = array() ) { | |
$this->get_cache(); | |
array_walk( $plugin_basenames, array( $this, 'load_plugin' ) ); | |
} | |
/** | |
* Loads a given plugin. | |
* | |
* @since 1.0.0 | |
* | |
* @param string $plugin_basename Plugin basename, consisting of the plugin directory name, | |
* a trailing slash, and the plugin main file name. | |
* | |
* @throws InvalidArgumentException Thrown when the plugin basename is invalid. | |
* @throws RuntimeException Thrown when the plugin with the given basename is not installed. | |
*/ | |
public function load_plugin( $plugin_basename ) { | |
if ( 0 !== validate_file( $plugin_basename ) || '.php' !== substr( $plugin_basename, -4 ) ) { | |
/* translators: %s: plugin basename */ | |
throw new InvalidArgumentException( sprintf( __( '%s is not a valid plugin basename.', 'wp-plugin-mu-loader' ), $plugin_basename ) ); | |
} | |
$plugin_file = WP_PLUGIN_DIR . '/' . $plugin_basename; | |
if ( ! file_exists( $plugin_file ) ) { | |
/* translators: %s: plugin basename */ | |
throw new RuntimeException( sprintf( __( 'The plugin with the basename %s is not installed.', 'wp-plugin-mu-loader' ), $plugin_basename ) ); | |
} | |
$this->plugin_basenames[] = $plugin_basename; | |
$this->require_plugin_file( $plugin_file ); | |
if ( $this->needs_activation( $plugin_basename ) ) { | |
$this->trigger_activation_hook( $plugin_basename ); | |
} | |
} | |
/** | |
* Registers the hooks necessary to load plugins as MU plugins. | |
* | |
* @since 1.0.0 | |
*/ | |
public function register_hooks() { | |
add_filter( 'option_active_plugins', array( $this, 'filter_active_plugins' ), 0 ); | |
add_filter( 'site_option_active_sitewide_plugins', array( $this, 'filter_network_active_plugins' ), 0 ); | |
add_filter( 'map_meta_cap', array( $this, 'filter_plugin_meta_caps' ), 0, 4 ); | |
add_filter( 'plugin_action_links', array( $this, 'filter_plugin_action_links' ), 0, 2 ); | |
add_filter( 'network_admin_plugin_action_links', array( $this, 'filter_plugin_action_links' ), 0, 2 ); | |
add_action( 'admin_footer-plugins.php', array( $this, 'mark_plugins_active' ), 0 ); | |
add_action( 'shutdown', array( $this, 'set_cache' ), 0 ); | |
add_action( 'shutdown', array( $this, 'deactivate_old_plugins' ), 0 ); | |
add_action( 'wp_ajax_' . self::AJAX_ACTION_PREFIX . 'deactivate', array( $this, 'listen_to_ajax_deactivation' ) ); | |
add_action( 'wp_ajax_nopriv_' . self::AJAX_ACTION_PREFIX . 'deactivate', array( $this, 'listen_to_ajax_deactivation' ) ); | |
} | |
/** | |
* Filters the 'active_plugins' option, excluding plugins loaded as MU plugins. | |
* | |
* @since 1.0.0 | |
* | |
* @param array $plugins List of active plugin basenames. | |
* @return array Filtered value of $plugins. | |
*/ | |
public function filter_active_plugins( array $plugins ) { | |
return array_diff( $plugins, $this->plugin_basenames ); | |
} | |
/** | |
* Filters the 'active_sitewide_plugins' network option, excluding plugins loaded as MU plugins. | |
* | |
* @since 1.0.0 | |
* | |
* @param array $plugins Associative array of $plugin_basename => $timestamp pairs. | |
* @return array Filtered value of $plugins. | |
*/ | |
public function filter_network_active_plugins( array $plugins ) { | |
return array_diff_key( $plugins, array_flip( $this->plugin_basenames ) ); | |
} | |
/** | |
* Filters the capabilities for activating and deactivating plugins. | |
* | |
* This method prevents access to those capabilities for plugins loaded as MU plugins. | |
* | |
* @since 1.0.0 | |
* | |
* @param array $caps List of primitive capabilities resolved to in `map_meta_cap()`. | |
* @param string $cap Meta capability actually being checked. | |
* @param int $user_id User ID for which the capability is being checked. | |
* @param array $args Additional arguments passed to the capability check. | |
* @return array Filtered value of $caps. | |
*/ | |
public function filter_plugin_meta_caps( array $caps, $cap, $user_id, array $args ) { | |
switch ( $cap ) { | |
case 'activate_plugin': | |
case 'deactivate_plugin': | |
case 'delete_plugin': | |
if ( in_array( $args[0], $this->plugin_basenames, true ) ) { | |
$caps[] = 'do_not_allow'; | |
} | |
break; | |
/* | |
* Core does not actually have 'delete_plugin' yet, so this is a bad but | |
* necessary hack to prevent deleting one of these plugins loaded as MU. | |
*/ | |
case 'delete_plugins': | |
if ( isset( $_REQUEST['checked'] ) ) { | |
$plugins = wp_unslash( $_REQUEST['checked'] ); | |
if ( array_intersect( $plugins, $this->plugin_basenames ) ) { | |
$caps[] = 'do_not_allow'; | |
} | |
} | |
break; | |
} | |
return $caps; | |
} | |
/** | |
* Filters the plugin action links in the plugins list tables. | |
* | |
* This method removes links to actions that should not be allowed for plugins loaded | |
* as MU plugins and adds a message informing about that status. | |
* | |
* @since 1.0.0 | |
* | |
* @param array $actions Associative array of $action_slug => $markup pairs. | |
* @param string $plugin_basename Plugin basename to which the actions apply. | |
* @return array Filtered value of $actions. | |
*/ | |
public function filter_plugin_action_links( array $actions, $plugin_basename ) { | |
if ( ! in_array( $plugin_basename, $this->plugin_basenames, true ) ) { | |
return $actions; | |
} | |
$disallowed_actions = array( 'activate', 'deactivate', 'delete' ); | |
foreach ( $disallowed_actions as $disallowed_action ) { | |
if ( isset( $actions[ $disallowed_action ] ) ) { | |
unset( $actions[ $disallowed_action ] ); | |
} | |
} | |
// Use 'network_active' as action slug because it is correctly styled by core. | |
$actions['network_active'] = __( 'Must-Use', 'wp-plugin-mu-loader' ); | |
return $actions; | |
} | |
/** | |
* Dynamically applies the 'active' CSS class to the plugins that are loaded as MU plugins. | |
* | |
* This is hacky, but there is no other way of adjusting the CSS classes. | |
* | |
* @since 1.0.0 | |
*/ | |
public function mark_plugins_active() { | |
if ( empty( $this->plugin_basenames ) ) { | |
return; | |
} | |
?> | |
<script type="text/javascript"> | |
( function() { | |
var pluginBasenames = JSON.parse( '<?php echo wp_json_encode( $this->plugin_basenames ); ?>' ); | |
pluginBasenames.forEach( function( pluginBasename ) { | |
var rows = document.querySelectorAll( 'tr[data-plugin="' + pluginBasename + '"]' ); | |
rows.forEach( function( row ) { | |
row.classList.remove( 'inactive' ); | |
row.classList.add( 'active' ); | |
} ); | |
} ); | |
} )(); | |
</script> | |
<?php | |
} | |
/** | |
* Gets the list of plugins to load as MU plugins from the cache. | |
* | |
* @since 1.0.0 | |
* | |
* @return array List of plugin basenames. | |
*/ | |
public function get_cache() { | |
if ( null !== $this->old_plugin_basenames ) { | |
return $this->old_plugin_basenames; | |
} | |
if ( is_multisite() ) { | |
$this->old_plugin_basenames = get_network_option( null, self::CACHE_OPTION_NAME, array() ); | |
return $this->old_plugin_basenames; | |
} | |
$this->old_plugin_basenames = get_option( self::CACHE_OPTION_NAME, array() ); | |
return $this->old_plugin_basenames; | |
} | |
/** | |
* Sets the list of plugins to load as MU plugins in the cache. | |
* | |
* @since 1.0.0 | |
* | |
* @return bool True on success, false on failure. | |
*/ | |
public function set_cache() { | |
if ( $this->plugin_basenames === $this->old_plugin_basenames ) { | |
return true; | |
} | |
if ( is_multisite() ) { | |
return update_network_option( null, self::CACHE_OPTION_NAME, $this->plugin_basenames ); | |
} | |
return update_option( self::CACHE_OPTION_NAME, $this->plugin_basenames ); | |
} | |
/** | |
* Iterates through all plugins that might need to be deactivated and triggers AJAX requests | |
* to run their deactivation routines. | |
* | |
* @since 1.0.0 | |
*/ | |
public function deactivate_old_plugins() { | |
$plugins_to_deactivate = array_diff( $this->old_plugin_basenames, $this->plugin_basenames ); | |
foreach ( $plugins_to_deactivate as $plugin_basename ) { | |
if ( ! $this->needs_deactivation( $plugin_basename ) ) { | |
continue; | |
} | |
wp_remote_post( admin_url( 'admin-ajax.php' ), array( | |
'timeout' => 0.01, | |
'blocking' => false, | |
'cookies' => $_COOKIE, | |
'body' => array( | |
'action' => self::AJAX_ACTION_PREFIX . 'deactivate', | |
'_wpnonce' => wp_create_nonce( self::AJAX_ACTION_PREFIX . 'deactivate_' . $plugin_basename ), | |
'basename' => $plugin_basename, | |
), | |
) ); | |
} | |
} | |
/** | |
* Listens to an AJAX request in which a plugin's deactivation routine should fire. | |
* | |
* @since 1.0.0 | |
*/ | |
public function listen_to_ajax_deactivation() { | |
$plugin_basename = wp_unslash( filter_input( INPUT_POST, 'basename' ) ); | |
$plugin_file = WP_PLUGIN_DIR . '/' . $plugin_basename; | |
check_ajax_referer( self::AJAX_ACTION_PREFIX . 'deactivate_' . $plugin_basename ); | |
if ( ! file_exists( $plugin_file ) ) { | |
return; | |
} | |
$this->require_plugin_file( $plugin_file ); | |
$this->trigger_deactivation_hook( $plugin_basename ); | |
wp_die( '1' ); | |
} | |
/** | |
* Checks whether the plugin of the given basename needs to be activated. | |
* | |
* @since 1.0.0 | |
* | |
* @param string $plugin_basename Plugin basename. | |
* @return bool True if the plugin needs to run its activation routine, false otherwise. | |
*/ | |
protected function needs_activation( $plugin_basename ) { | |
if ( in_array( $plugin_basename, $this->old_plugin_basenames, true ) ) { | |
return false; | |
} | |
return ! in_array( $plugin_basename, $this->get_active_plugins_unfiltered(), true ); | |
} | |
/** | |
* Triggers the activation hook for the plugin of the given basename for the current context. | |
* | |
* If a multisite, the activation hook is triggered network-wide. Otherwise it is triggered | |
* only for the current site. | |
* | |
* @since 1.0.0 | |
* | |
* @param string $plugin_basename Plugin basename. | |
*/ | |
protected function trigger_activation_hook( $plugin_basename ) { | |
/** This action is documented in wp-admin/includes/plugin.php */ | |
do_action( "activate_{$plugin_basename}", is_multisite() ); | |
} | |
/** | |
* Checks whether the plugin of the given basename needs to be deactivated. | |
* | |
* @since 1.0.0 | |
* | |
* @param string $plugin_basename Plugin basename. | |
* @return bool True if the plugin needs to run its deactivation routine, false otherwise. | |
*/ | |
protected function needs_deactivation( $plugin_basename ) { | |
if ( in_array( $plugin_basename, $this->old_plugin_basenames, true ) ) { | |
return true; | |
} | |
return ! in_array( $plugin_basename, $this->get_active_plugins_unfiltered(), true ); | |
} | |
/** | |
* Triggers the deactivation hook for the plugin of the given basename for the current context. | |
* | |
* If a multisite, the deactivation hook is triggered network-wide. Otherwise it is triggered | |
* only for the current site. | |
* | |
* @since 1.0.0 | |
* | |
* @param string $plugin_basename Plugin basename. | |
*/ | |
protected function trigger_deactivation_hook( $plugin_basename ) { | |
/** This action is documented in wp-admin/includes/plugin.php */ | |
do_action( "deactivate_{$plugin_basename}", is_multisite() ); | |
} | |
/** | |
* Gets the unfiltered list of active plugin basenames for the current context. | |
* | |
* If a multisite, the network-active plugin basenames are returned. Otherwise, | |
* the site-active plugin basenames are returned. | |
* | |
* @since 1.0.0 | |
* | |
* @return array List of active plugin basenames. | |
*/ | |
protected function get_active_plugins_unfiltered() { | |
if ( is_multisite() ) { | |
remove_filter( 'site_option_active_sitewide_plugins', array( $this, 'filter_network_active_plugins' ), 0 ); | |
$result = get_network_option( null, 'active_sitewide_plugins', array() ); | |
add_filter( 'site_option_active_sitewide_plugins', array( $this, 'filter_network_active_plugins' ), 0 ); | |
return array_keys( $result ); | |
} | |
remove_filter( 'option_active_plugins', array( $this, 'filter_active_plugins' ), 0 ); | |
$result = get_option( 'active_plugins', array() ); | |
add_filter( 'option_active_plugins', array( $this, 'filter_active_plugins' ), 0 ); | |
return $result; | |
} | |
/** | |
* Requires a given plugin file. | |
* | |
* @since 1.0.0 | |
* | |
* @param string $plugin_file Full path to the plugin main file. | |
*/ | |
protected function require_plugin_file( $plugin_file ) { | |
wp_register_plugin_realpath( $plugin_file ); | |
require_once $plugin_file; | |
} | |
} | |
/** | |
* Gets the main plugin MU loader instance. | |
* | |
* The instance will be created if not yet available, with its hooks registered. | |
* | |
* @since 1.0.0 | |
* | |
* @return WP_Plugin_MU_Loader Plugin MU loader instance. | |
*/ | |
function wp_plugin_mu_loader() { | |
static $loader = null; | |
if ( null === $loader ) { | |
$loader = new WP_Plugin_MU_Loader(); | |
$loader->register_hooks(); | |
} | |
return $loader; | |
} | |
wp_plugin_mu_loader(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Question, why would you want to filter out "mu" plugins from the
option_active_plugins
using array_diff instead of array_merge?