Skip to content

Instantly share code, notes, and snippets.

@felixarntz
Last active October 10, 2022 12:21
Show Gist options
  • Save felixarntz/daff4006112b60dfea677ca08fc0b31c to your computer and use it in GitHub Desktop.
Save felixarntz/daff4006112b60dfea677ca08fc0b31c to your computer and use it in GitHub Desktop.
WP Plugin MU Loader
<?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();
@felixarntz
Copy link
Author

felixarntz commented Jun 27, 2018

WP Plugin MU Loader

Loads regular plugins from the plugins directory as must-use plugins, enforcing their activity while maintaining the typical update flow. This file will take care of all necessary logic, including preventing activation/deactivation/deletion of those plugins as regular plugins.

Benefits

  • enforce plugins to be active throughout the entire installation
  • continue receiving automated update notifications
  • be able to comfortably update those plugins from the WordPress dashboard
  • the plugin activation, deactivation, and uninstallation routines are executed as usual

Requirements

  • WordPress 4.9

Usage

  • Put this file into your wp-content/mu-plugins directory. Create the directory if it does not exist.
  • You can then pass basenames of the plugins you would like to load as MU plugins to the constructor call in the wp_plugin_mu_loader() function, as an array.
  • A plugin basename consists of the plugin directory name, a trailing slash, and the plugin main file name, for example wordpress-seo/wp-seo.php, jetpack/jetpack.php, or woocommerce/woocommerce.php.
  • Alternatively, if you don't want to tweak the code of the function itself, you can also access the loader from the outside: Retrieve the instance via wp_plugin_mu_loader() and then call its load_plugin() method, passing a single plugin basename string to it.

Example

wp_plugin_mu_loader()->load_plugin( 'wordpress-seo/wp-seo.php' );

@thefrosty
Copy link

Question, why would you want to filter out "mu" plugins from the option_active_plugins using array_diff instead of array_merge?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment