Logging Failed Redirects

WordPress has a built-in function called wp_safe_redirect().  This allows you to create redirects in code, but only to whitelisted domains (via the allowed_redirect_hosts filter).

The downside to this is that you have to remember to whitelist the domains.  It’s easy to forget if you’re doing a lot of redirects, for instance with the WPCOM Legacy Redirector plugin.

When this happens, all un-whitelisted redirects will be redirected by default to /wp-admin/ instead, and can cause a headache trying to figure out what’s going wrong.

I had an idea to solve this problem.  A simple logging plugin that logs failed redirects and adds a dashboard widget to show the domains and number of times the redirect has failed:

The code behind this:

<?php
class Emrikol_WSRD_Dashboard {
	public static function instance() {
		static $instance = false;
		if ( ! $instance ) {
			$instance = new Emrikol_WSRD_Dashboard();
		}
		return $instance;
	}

	public function __construct() {
		add_action( 'init', array( $this, 'init' ) );
		add_filter( 'allowed_redirect_hosts', array( $this, 'check_redirect' ), PHP_INT_MAX, 2 );
	}

	public function init() {
		if ( $this->is_admin() && isset( $_GET['wsrd_delete'] ) && check_admin_referer( 'wsrd_delete' ) && isset( $_GET['ID'] ) ) {
			$post_id = (int) $_GET['ID'];

			if ( 'wsrd' !== get_post_type( $post_id ) ) {
				// This isn't the right post type, abort!
				add_action( 'admin_notices', array( $this, 'message_log_not_deleted' ) );
				return;
			}

			$delete = wp_delete_post( $post_id, true );
			wp_cache_delete( 'wsrd_report' );

			if ( $delete ) {
				add_action( 'admin_notices', array( $this, 'message_log_deleted' ) );
			} else {
				add_action( 'admin_notices', array( $this, 'message_log_not_deleted' ) );
			}
		}

		$args = array(
			'supports' => array( 'title' ),
			'public'   => false,
		);
		register_post_type( 'wsrd', $args );

		add_action( 'wp_dashboard_setup', array( $this, 'add_dashboard_widgets' ) );
	}

	public function add_dashboard_widgets() {
		if ( $this->is_admin() ) {
			wp_add_dashboard_widget( 'emrikol_wsrd_dashboard', 'Failed Safe Redirects', array( $this, 'show_admin_dashboard' ) );
		}
	}

	public function check_redirect( $allowed_hosts, $redirect_host ) {
		if ( ! in_array( $redirect_host, $allowed_hosts, true ) ) {
			// No redirect, please record.
			$found_host = new WP_Query( array(
				'fields'                 => 'ids',
				'name'                   => md5( $redirect_host ),
				'post_type'              => 'wsrd',
				'post_status'            => 'any',
				'no_found_rows'          => true,
				'posts_per_page'         => 1,
				'update_post_term_cache' => false,
				'update_post_meta_cache' => false,
			) );

			if ( empty( $found_host->posts ) ) {
				// No past redirect log found, create one.
				$args   = array(
					'post_name'  => md5( $redirect_host ),
					'post_title' => $redirect_host,
					'post_type'  => 'wsrd',
					'meta_input' => array(
						'count' => 1,
					),
				);
				$insert = wp_insert_post( $args );
			} else {
				// Found!  Update count.
				$count = absint( get_post_meta( $found_host->posts[0], 'count', true ) );
				$count++;
				update_post_meta( $found_host->posts[0], 'count', $count );
			}
		}
		// We don't want to modify, always return allowed hosts unharmed.
		return $allowed_hosts;
	}

	public function show_admin_dashboard() {
		global $wpdb;

		$report = wp_cache_get( 'wsrd_report' );
		if ( false === $report ) {
			$report = $wpdb->get_results( "SELECT ID, post_title AS host, meta_value AS count FROM $wpdb->posts LEFT JOIN $wpdb->postmeta ON ( $wpdb->posts.ID = $wpdb->postmeta.post_id ) WHERE post_type='wsrd'  ORDER BY ABS( count ) DESC LIMIT 20;" );
			wp_cache_set( 'wsrd_report', $report, 'default', MINUTE_IN_SECONDS * 5 );
		}

		?>
		<style>
			table#wsrd {
				border-collapse: collapse;
				width: 100%;
			}
			table#wsrd th {
				background: #f5f5f5;
			}

			table#wsrd th, table#wsrd td {
				border: 1px solid #f5f5f5;
				padding: 8px;
			}

			table#wsrd tr:nth-child(even) {
				background: #fafafa;
			}
		</style>
		<div class="activity-block">
			<?php if ( empty( $report ) ) : ?>
			<p><strong>None Found!</strong></p>
			<?php else : ?>
			<table id="wsrd">
				<thead>
					<tr>
						<th>Domain</th>
						<th>Count</th>
						<th>Control</th>
					</tr>
				</thead>
				<tbody>
					<?php foreach ( $report as $line ) : ?>
						<tr>
							<td><?php echo esc_html( $line->host ); ?></td>
							<td><?php echo esc_html( $line->count ); ?></td>
							<td><a href="<?php echo esc_url( wp_nonce_url( add_query_arg( array( 'wsrd_delete' => true, 'ID' => rawurlencode( $line->ID ) ), admin_url() ), 'wsrd_delete' ) ); ?>">Delete</a></td>
						</tr>
					<?php endforeach; ?>
				</tbody>
			</table>
			<?php endif; ?>
		</div>
		<?php
	}

	public function message_log_deleted() {
		echo '<div id="message" class="notice notice-success is-dismissible"><p>Redirect log deleted!</p></div>';
	}

	public function message_log_not_deleted() {
		echo '<div id="message" class="notice notice-error is-dismissible"><p>Redirect log delete failed!</p></div>';
	}


	private function is_admin() {
		if ( current_user_can( 'manage_options' ) ) {
			return true;
		}
		return false;
	}
}
Emrikol_WSRD_Dashboard::instance();

Purging All The Caches!

One of the best ways to ensure that a WordPress site–well any site really–stays performant and not broken is by leveraging caching.

WordPress by default doesn’t do much caching other than some in-memory caching of objects, and the odd database caching via the Transients API.

This site currently has three layers of caching:

This means I have three different plugins that I have to manage with these caches:

So if I am doing some development and want to purge one or more caches, I need to go dig around three different places to purge these, and that’s not fun.  To help combat this, I made myself a simple Admin Dashboard widget with quick access to purge each of these:

Here’s the code:

<?php
class Emrikol_Cache_Dashboard {
	public static function instance() {
		static $instance = false;
		if ( ! $instance ) {
			$instance = new Emrikol_Cache_Dashboard();
		}
		return $instance;
	}

	public function __construct() {
		add_action( 'init', array( $this, 'init' ) );
	}

	public function init() {
		if ( $this->is_admin() && isset( $_GET['ead_purge_object_cache'] ) && check_admin_referer( 'manual_purge' ) ) {
			$did_flush = wp_cache_flush();
			if ( $did_flush ) {
				add_action( 'admin_notices', array( $this, 'message_object_cache_purge_success' ) );
			} else {
				add_action( 'admin_notices', array( $this, 'message_object_cache_purge_failure' ) );
			}
		} elseif ( $this->is_admin() && isset( $_GET['ead_purge_wp_super_cache'] ) && check_admin_referer( 'manual_purge' ) ) {
			global $file_prefix;
			wp_cache_clean_cache( $file_prefix, true );
			add_action( 'admin_notices', array( $this, 'message_wp_super_cache_purge_success' ) );
		} elseif ( $this->is_admin() && isset( $_GET['ead_purge_OPcache'] ) && check_admin_referer( 'manual_purge' ) ) {
			// Taken from: https://wordpress.org/plugins/flush-OPcache/
			// Check if file cache is enabled and delete it if enabled.
			// phpcs:ignore WordPress.VIP.FileSystemWritesDisallow.file_ops_is_writable
			if ( ini_get( 'OPcache.file_cache' ) && is_writable( ini_get( 'OPcache.file_cache' ) ) ) {
				$files = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( ini_get( 'OPcache.file_cache' ), RecursiveDirectoryIterator::SKIP_DOTS ), RecursiveIteratorIterator::CHILD_FIRST );
				foreach ( $files as $fileinfo ) {
					$todo = ( $fileinfo->isDir() ? 'rmdir' : 'unlink' );
					$todo( $fileinfo->getRealPath() );
				}
			}

			// Flush OPcache.
			$did_flush = OPcache_reset();
			if ( $did_flush ) {
				add_action( 'admin_notices', array( $this, 'message_OPcache_purge_success' ) );
			} else {
				add_action( 'admin_notices', array( $this, 'message_OPcache_purge_failure' ) );
			}
		}

		add_action( 'wp_dashboard_setup', array( $this, 'add_dashboard_widgets' ) );
	}

	public function add_dashboard_widgets() {
		if ( $this->is_admin() ) {
			wp_add_dashboard_widget( 'emrikol_admin_dashboard', 'Cache Control', array( $this, 'show_admin_dashboard' ) );
		}
	}

	public function show_admin_dashboard() {
		if ( false === get_parent_class( $GLOBALS['wp_object_cache'] ) ) {
			// Persistent Object Cache detected.
			?>
			<div class="activity-block">
				<span class="button"><a href="<?php echo esc_url( wp_nonce_url( admin_url( '?ead_purge_object_cache' ), 'manual_purge' ) ); ?>"><strong>Purge Object Cache</strong></a></span>
				<p>Force a purge of your entire site's object cache.</p>
			</div>
			<?php
		} else {
			// Transients!
			?>
			<div class="activity-block">
				<h3>Transients</h3>
				<p>Transients cannot currently be removed manually.</p>
			</div>
			<?php
		}
		if ( function_exists( 'wp_cache_clean_cache' ) ) {
			// WP Super Cache!
			?>
			<div class="activity-block">
				<span class="button"><a href="<?php echo esc_url( wp_nonce_url( admin_url( '?ead_purge_wp_super_cache' ), 'manual_purge' ) ); ?>"><strong>Purge Page Cache</strong></a></span>
				<p>Force a purge of your entire site's page cache.</p>
			</div>
			<?php
		}
		if ( function_exists( 'OPcache_reset' ) ) {
			// PHP OPcache.
			?>
			<div class="activity-block">
				<span class="button"><a href="<?php echo esc_url( wp_nonce_url( admin_url( '?ead_purge_OPcache' ), 'manual_purge' ) ); ?>"><strong>Purge PHP OPcache</strong></a></span>
				<p>Force a purge of your entire site's PHP OPcache.</p>
			</div>
			<?php
		}
	}

	public function message_wp_super_cache_purge_success() {
		echo '<div id="message" class="notice notice-success is-dismissible"><p>Page Cache purged!</p></div>';
	}

	public function message_object_cache_purge_success() {
		echo '<div id="message" class="notice notice-success is-dismissible"><p>Object Cache purged!</p></div>';
	}

	public function message_object_cache_purge_failure() {
		echo '<div id="message" class="notice notice-error is-dismissible"><p>Object Cache purge failed!</p></div>';
	}

	public function message_OPcache_purge_success() {
		echo '<div id="message" class="notice notice-success is-dismissible"><p>PHP OPcache purged!</p></div>';
	}

	public function message_OPcache_purge_failure() {
		echo '<div id="message" class="notice notice-error is-dismissible"><p>PHP OPcache purge failed!</p></div>';
	}

	private function is_admin() {
		if ( current_user_can( 'manage_options' ) ) {
			return true;
		}
		return false;
	}
}
Emrikol_Cache_Dashboard::instance();

Quick Tip: Viewing Headers With Curl

Something that I do often at work is to check HTTP headers for random things such as redirects, cache headers, proxies, ssl, etc.

A common way this is done is by using the -I (--header) switch:

$ curl -I http://example.com/
HTTP/1.1 200 OK
Content-Encoding: gzip
Accept-Ranges: bytes
Cache-Control: max-age=604800
Content-Type: text/html
Date: Wed, 27 Jun 2018 22:03:57 GMT
Etag: "1541025663+gzip"
Expires: Wed, 04 Jul 2018 22:03:57 GMT
Last-Modified: Fri, 09 Aug 2013 23:54:35 GMT
Server: ECS (atl/FC94)
X-Cache: HIT
Content-Length: 606

The downside to this is that it uses an HTTP HEAD request, which can sometimes return different headers or different information than a standard GET request.  This can be fixed by using the -X (--request) switch.  This overrides the default HEAD request with whatever you choose:

$ curl -I -XGET http://example.com/
HTTP/1.1 200 OK
Accept-Ranges: bytes
Cache-Control: max-age=604800
Content-Type: text/html
Date: Wed, 27 Jun 2018 22:07:47 GMT
Etag: "1541025663"
Expires: Wed, 04 Jul 2018 22:07:47 GMT
Last-Modified: Fri, 09 Aug 2013 23:54:35 GMT
Server: ECS (atl/FC90)
Vary: Accept-Encoding
X-Cache: HIT
Content-Length: 1270

I like to just combine them into one quick command: curl -IXGET http://example.com/

Disabling WordPress Faux Cron

The WordPress WP-Cron system is a decently okay faux cron system, but it has its problems, such as running on frontend requests and not running if no requests are coming through.

WP-Cron works by: on every page load, a list of scheduled tasks is checked to see what needs to be run. Any tasks scheduled to be run will be run during that page load. WP-Cron does not run constantly as the system cron does; it is only triggered on page load. Scheduling errors could occur if you schedule a task for 2:00PM and no page loads occur until 5:00PM.

From the WordPress Plugin Handbook

These are problems because:

  1. A heavy cron event can cause severe slowdown on random frontend requests, hurting page speeds.
  2. Not running without requests can be bad for sites that are infrequently updated and heavily cached.

The solution to this is to disable the built-in cron firing that’s done with pageviews, and use a system cron (or other service) to poll for cron events.

Disabling the cron firing is done by adding this to the wp-config.php file:

define( 'DISABLE_WP_CRON', true );

For this site specifically, I use the “Cron Jobs” system of DreamHost to run this WP-CLI command every 10 minutes:

wp cron event run --due-now --path=/path/to/derrick.blog/ --url=https://derrick.blog/

This forces the cron to run and check for ready jobs every 10 minutes.  It’s possible that some cron events might run later than they “should” but in practice, I’ve seen this running more cron jobs than if I relied on page loads.

Quick Tip: Force Enable Auto-Updates in WordPress

I know that auto-updates are a bit of a (#wpdrama) touchy subject, but I believe in them.

In an mu-plugin I enable all auto-updates like so:

<?php
// Turn on auto-updates for everything
if ( ! defined( 'IS_PRESSABLE' ) || ! IS_PRESSABLE ) {
	add_filter( 'allow_major_auto_core_updates', '__return_true' );
	add_filter( 'allow_minor_auto_core_updates', '__return_true' );
}

add_filter( 'auto_update_core', '__return_true' );
add_filter( 'auto_update_plugin', '__return_true' );
add_filter( 'auto_update_theme', '__return_true' );
add_filter( 'auto_update_translation', '__return_true' );

Query Caching (and a little extra)

By default, WordPress does not cache WP_Query queries.  Doing so can greatly improve performance.  The way I do this is via the Advanced Post Cache plugin:

By running this plugin (hopefully as an mu-plugin) with a persistent object cache, WP_Query calls, along with get_post() calls (only if suppress_filters is false) will be cached.

Bonus!

Now that we’re caching queries, here’s how I do a little extra caching to squeeze out a tiny bit more performance:

<?php
// By default Jetpack does not cache responses from Instagram oembeds.
add_filter( 'instagram_cache_oembed_api_response_body', '__return_true' );

// Cache WP Dashboard Recent Posts Query
add_filter( 'dashboard_recent_posts_query_args', 'cache_dashboard_recent_posts_query_args', 10, 1 );
function cache_dashboard_recent_posts_query_args( $query_args ) {
	$query_args['cache_results'] = true;
	$query_args['suppress_filters'] = false;
	return $query_args;
}

// Cache WP Dashboard Recent Drafts Query
add_filter( 'dashboard_recent_drafts_query_args', 'cache_dashboard_recent_drafts_query_args', 10, 1 );
function cache_dashboard_recent_drafts_query_args( $query_args ) {
	$query_args['suppress_filters'] = false;
	return $query_args;
}

// Cache comment counts, https://github.com/Automattic/vip-code-performance/blob/master/core-fix-comment-counts-caching.php
add_filter( 'wp_count_comments', 'wpcom_vip_cache_full_comment_counts', 10, 2 );
function wpcom_vip_cache_full_comment_counts( $counts = false , $post_id = 0 ){
	//We are only caching the global comment counts for now since those are often in the millions while the per page one is usually more reasonable.
	if ( 0 !== $post_id ) {
		return $counts;
	}

	$cache_key = "vip-comments-{$post_id}";
	$stats_object = wp_cache_get( $cache_key );

	//retrieve comments in the same way wp_count_comments() does
	if ( false === $stats_object ) {
		$stats = get_comment_count( $post_id );
		$stats['moderated'] = $stats['awaiting_moderation'];
		unset( $stats['awaiting_moderation'] );
		$stats_object = (object) $stats;

		wp_cache_set( $cache_key, $stats_object, 'default', 30 * MINUTE_IN_SECONDS );
	}

	return $stats_object;
}

// Cache monthly media array.
add_filter( 'media_library_months_with_files', 'wpcom_vip_media_library_months_with_files' );
function wpcom_vip_media_library_months_with_files() {
	$months = wp_cache_get( 'media_library_months_with_files', 'extra-caching' );

	if ( false === $months ) {
		global $wpdb;
		$months = $wpdb->get_results( $wpdb->prepare( "
			SELECT DISTINCT YEAR( post_date ) AS year, MONTH( post_date ) AS month
			FROM $wpdb->posts
			WHERE post_type = %s
			ORDER BY post_date DESC
			", 'attachment' )
		);
		wp_cache_set( 'media_library_months_with_files', $months, 'extra-caching' );
	}

	return $months;
}

add_action( 'add_attachment', 'media_library_months_with_files_bust_cache' );
function media_library_months_with_files_bust_cache( $post_id ) {
	if ( defined( 'WP_IMPORTING' ) && WP_IMPORTING ) {
		return;
	}

	// What month/year is the most recent attachment?
	global $wpdb;
	$months = $wpdb->get_results( $wpdb->prepare( "
			SELECT DISTINCT YEAR( post_date ) AS year, MONTH( post_date ) AS month
			FROM $wpdb->posts
			WHERE post_type = %s
			ORDER BY post_date DESC
			LIMIT 1
		", 'attachment' )
	);

	// Simplify by assigning the object to $months
	$months = array_shift( array_values( $months ) );

	// Compare the dates of the new, and most recent, attachment
	if (
		! $months->year == get_the_time( 'Y', $post_id ) &&
		! $months->month == get_the_time( 'm', $post_id )
	) {
		// the new attachment is not in the same month/year as the
		// most recent attachment, so we need to refresh the transient
		wp_cache_delete( 'media_library_months_with_files', 'extra-caching' );
	}
}

Auto-enable WP_DEBUG with a cookie

One of the most important things to do when working on new themes, plugins, or debugging issues in WordPress is to turn on WP_DEBUG.  According to the Codex:

WP_DEBUG is a PHP constant (a permanent global variable) that can be used to trigger the “debug” mode throughout WordPress. It is assumed to be false by default and is usually set to true in the wp-config.php file on development copies of WordPress.

It’s common to edit the wp-config.php file every time you want to turn this off and on, but another way to do it is via a secret cookie:

if ( isset( $_COOKIE[ 'wp_secret_debug_cookie' ] ) ) {
	define( 'WP_DEBUG', true );
}

I also like to pair my WP_DEBUG with these extra settings:

if ( defined( 'WP_DEBUG' ) && true === WP_DEBUG ) {
	define( 'WP_DEBUG_LOG', true );
	define( 'SCRIPT_DEBUG', true );
	define( 'WP_DEBUG_DISPLAY', true );
	define( 'CONCATENATE_SCRIPTS', false );
	define( 'SAVEQUERIES', true );
}

I set the cookie in an mu-plugin like this (Note: This code could definitely be improved)

function emrikol_admin_debug() {
	if ( is_super_admin() ) {
		setcookie( 'wp_secret_debug_cookie', 'on', time() + 86400, '/', wp_parse_url( get_site_url(), PHP_URL_HOST ) );
		setcookie( 'wp_secret_debug_cookie', 'on', time() + 86400, '/', wp_parse_url( get_home_url(), PHP_URL_HOST ) );
		setcookie( 'wp_secret_debug_cookie', 'on', time() + 86400, '/', wp_parse_url( get_admin_url(), PHP_URL_HOST ) );

		// Allow sites to set extra domains to add cookie to--good for subdomain multisite.
		$extra_domains = apply_filters( 'emrikol_admin_debug_domain', false );
		if ( false !== $extra_domains ) {
			if ( is_array( $extra_domains ) ) {
				foreach ( $extra_domains as $extra_domain ) {
					setcookie( 'wp_secret_debug_cookie', 'on', time() + 86400, '/', $extra_domain );
				}
			} else {
					setcookie( 'wp_secret_debug_cookie', 'on', time() + 86400, '/', $extra_domains );
			}
		}
	}
}
add_action( 'init', 'emrikol_admin_debug', 10 );
add_action( 'admin_init', 'emrikol_admin_debug', 10 );

Windows, SFTP, and the Registry

One of the tasks that I have to do often at work is copying data to and from an SFTP directory.  Previously I had a constant domain and port that I was able to connect to, an I could save this in a WinSCP profile for ease of use.  Due to some recent architectural changes though, we’re now dynamically generating IPs and ports to connect to, which caused a bit of a headache.  Luckily though, we do get a really nice sftp://user@domain.example.com:1234 URI that gives us this information, and some terminal clients even allow you to click it (ConEmu).

Unfortunately, I can’t just register WinSCP as the default handler for sftp URIs because I needed to provide other data, such as a private key and proxy information.

To fix this,  I created a wrapper, poorly named scp.cmd that does all of this work:

 @echo off
:: Set some necessary path variables.
:: I would recommend WinSCP Portable, but that's just me.
set WINSCP_PATH="C:\Path To\winscp.exe"
set PRIVKEY_PPK="C:\Path To\Private Key.ppk"
:: Run the actual SCP command.
%WINSCP_PATH% %1 /privatekey=%PRIVKEY_PPK% /rawsettings ProxyMethod=2 ProxyHost="127.0.0.1" ProxyPort=8080
:: Unset the variables now that we don't need them.
set WINSCP_PATH=
set PRIVKEY_PPK=

From here I can create a Windows Registry file (or manually do it with regedit.exe but that’s crazy) to register the sftp URI handler and point it to my scp.cmd file:

Windows Registry Editor Version 5.00

[HKEY_CLASSES_ROOT\sftp\shell\open\command]
@="\"C:\\Windows\\scp.cmd\" \"%1\""

Now I can easily click on sftp links for work, I can paste them into the Windows Run dialog, or even open them via the command line with start.  This is a wonderful time saver!

Gutenberg, Code, and Highlighting

One of the great things about Gutenberg is the ability to compartmentalize different types of content within blocks.  One of the blocks that I’ve been using a lot of recently is the code block.  This block by default will render something like this:

#include <stdio.h>
int main()
{
   // printf() displays the string inside quotation
   printf("Hello, World!");
   return 0;
}

While this is acceptable, it’s not very pretty.  I used to use the SyntaxHighlighter Evolved.  Unfortunately this doesn’t work perfectly with Gutenberg at the moment, and I was hoping for something in a block.  Luckily I found this…

Marcus Kazmierczak has made a plugin to extend the core code block to allow syntax highlighting:

#include <stdio.h>
int main()
{
   // printf() displays the string inside quotation
   printf("Hello, World!");
   return 0;
}

I really like this and I think it compliments Gutenberg nicely 🙂

Securing WordPress Plugins with more Plugins

I’ve written before about disabling plugin deactivation in WordPress, but I’ve finally used that knowledge in practice–on this site.

The Problem

Let’s say you’re going along your day, developing things, and fixing things, and making the world a better place when all of a sudden you get a call from a client that their website is broken!!  After a bit of panicking and digging around, it turns out that they’ve been “optimizing” things by disabling random, but critical, plugins on the site.

The Solution

One way that you might fix this is to not install the plugins via the WordPress UI and require() them either in the theme directory, or as an mu-plugin.  The downside with this is that you lose the ability to easily and auto-update the plugins (if you’re okay with that).  You also the ability to easily see what plugins are active and installed in the admin UI.

The way I’ve got around this is to create this helper function inside an mu-plugin that allows the plugins to be installed and managed in the UI, but not disabled:

<?php
/**
 * Secures a plugin from accidental disabling in the UI.
 *
 * If a plugin is necessary for a site to function, it should not be disabled.
 * This functionc can also optionally "force" activate a plugin without having to
 * activate it in the plugin UI.  Forcing activation will cause it to skip all
 * core plugin activation hooks.
 *
 * @param string  $plugin              Plugin file to secure.
 * @param boolean $force_activation    Optional. Whether to force load the plugin. Default false.
 */
function emrikol_secure_plugin( $plugin, $force_activation = false ) {
	$proper_plugin_name = false;

	// Match if properly named: wp-plugin (wp-plugin/wp-plugin.php).
	if ( file_exists( WP_PLUGIN_DIR . '/' . $plugin . '/' . $plugin . '.php' ) && is_file( WP_PLUGIN_DIR . '/' . $plugin . '/' . $plugin . '.php' ) ) {
		$proper_plugin_name = $plugin . '/' . $plugin . '.php';
	} else {
		// Match if improperly named: wp-plugin/cool-plugin.php.
		if ( file_exists( WP_PLUGIN_DIR . '/' . $plugin ) && is_file( WP_PLUGIN_DIR . '/' . $plugin ) ) {
			$proper_plugin_name = $plugin;
		}
	}

	if ( false !== $proper_plugin_name ) {
		if ( true === $force_activation ) {
			// Always list the plugin as active.
			add_filter( 'option_active_plugins', function( $active_plugins ) use ( $proper_plugin_name ) {
				// Crappy hack to prevent infinite loops.  Surely there's a better way.
				global $emrikol_is_updating_active_plugins;

				if ( true === $emrikol_is_updating_active_plugins ) {
					unset( $emrikol_is_updating_active_plugins );
					return array_unique( $active_plugins );
				}

				if ( ! in_array( $proper_plugin_name, $active_plugins, true ) ) {
					$active_plugins[]                   = $proper_plugin_name;
					$emrikol_is_updating_active_plugins = true;

					update_option( 'active_plugins', array_unique( $active_plugins ) );
				}
				return array_unique( $active_plugins );
			}, 1000, 1 );
		}

		// Ensure the plugin doesn't get disabled somehow.
		// TODO: Diff arrays.  Only run if the plugin is being removed.
		add_filter( 'pre_update_option_active_plugins', function ( $active_plugins ) use ( $proper_plugin_name ) {
			if ( ! in_array( $proper_plugin_name, $active_plugins, true ) ) {
				$active_plugins[] = $proper_plugin_name;
			}
			return array_unique( $active_plugins );
		}, 1000, 1 );

		// Remove the disable button.
		$plugin_basename = plugin_basename( $proper_plugin_name );
		add_filter( "plugin_action_links_$plugin_basename", function( $links ) use ( $proper_plugin_name, $force_activation ) {
			if ( isset( $links['deactivate'] ) ) {
				$links['deactivate'] = sprintf(
					'<span class="emrikol-secure-plugin wp-ui-text-primary">%s</span>',
					$force_activation ? 'Plugin Activated via Theme Code' : 'Plugin Secured via Theme Code'
				);
			}
			return $links;
		}, 1000, 1 );
	}
}

It’s not perfect, but it’s working for me right now.  Like, right now on this site as you’re reading this. I’ve added it as an mu-plugin like so:

<?php
require_once( plugin_dir_path( __FILE__ ) . 'emrikol-defaults/secure-plugins.php' );

emrikol_secure_plugin( 'akismet' );
emrikol_secure_plugin( 'amp' );
emrikol_secure_plugin( 'jetpack' );
emrikol_secure_plugin( 'wp-super-cache/wp-cache.php' );

As you can see, this completely removes the “Deactivate” link in the UI:

The emrikol_secure_plugin() function takes two arguments:

  1. The plugin to secure.  This can either be the plugin slug (ex. jetpack) or the full plugin path if the plugin doesn’t follow standard naming conventions (wp-super-cache/wp-cache.php)
  2. A boolean, defaults to false.  If it is true the plugin will be forced to activate without user intervention.  This can be used to activate a plugin on a new install without having to manually enable it in the UI or via WP-CLI