Tag: php

  • Logging Failed Redirects

    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();
    Code language: HTML, XML (xml)

  • Purging All The Caches!

    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();
    Code language: HTML, XML (xml)
  • Auto-enable WP_DEBUG with a cookie

    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 );
    }Code language: PHP (php)

    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 );
    }Code language: JavaScript (javascript)

    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 );Code language: PHP (php)
  • CSS & JS Concatenation in WordPress

    CSS & JS Concatenation in WordPress

    At WordPress.com VIP one of the features we have on our platform is automated concatenation of Javascript and CSS files when registered through the core WordPress wp_enqueue__*() functions.

    We do this using the nginx-http-concat plugin:

    This plugin was written to work with nginx, but the server running derrick.blog is Apache.  I’ve worked around this and have nginx-http-concat running fully in WordPress, with added caching.

    The bulk of the plugin is this file, which does all of the work of caching and calling the nignx-http-concat plugin:

    <?php
    // phpcs:disable WordPress.VIP.SuperGlobalInputUsage.AccessDetected, WordPress.Security.ValidatedSanitizedInput, WordPress.VIP.FileSystemWritesDisallow, WordPress.VIP.RestrictedFunctions.file_get_contents_file_get_contents, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPress.WP.AlternativeFunctions.file_system_read_file_get_contents, WordPress.WP.AlternativeFunctions.file_system_read_file_put_contents, WordPress.WP.AlternativeFunctions.json_encode_json_encode
    if ( isset( $_SERVER['REQUEST_URI'] ) && '/_static/' === substr( $_SERVER['REQUEST_URI'], 0, 9 ) ) {
    	$cache_file      = WP_HTTP_CONCAT_CACHE . '/' . md5( $_SERVER['REQUEST_URI'] );
    	$cache_file_meta = WP_HTTP_CONCAT_CACHE . '/meta-' . md5( $_SERVER['REQUEST_URI'] );
    
    	if ( file_exists( $cache_file ) ) {
    		if ( time() - filemtime( $cache_file ) > 2 * 3600 ) {
    			// file older than 2 hours, delete cache.
    			unlink( $cache_file );
    			unlink( $cache_file_meta );
    		} else {
    			// file younger than 2 hours, return cache.
    			if ( file_exists( $cache_file_meta ) ) {
    				$meta = json_decode( file_get_contents( $cache_file_meta ) );
    				if ( null !== $meta && isset( $meta->headers ) ) {
    					foreach ( $meta->headers as $header ) {
    						header( $header );
    					}
    				}
    			}
    			$etag = '"' . md5( file_get_contents( $cache_file ) ) . '"';
    
    			ob_start( 'ob_gzhandler' );
    			header( 'x-http-concat: cached' );
    			header( 'Cache-Control: max-age=' . 31536000 );
    			header( 'ETag: ' . $etag );
    
    			if ( isset( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ) {
    				if ( strtotime( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) < filemtime( $cache_file ) ) {
    					header( 'HTTP/1.1 304 Not Modified' );
    					exit;
    				}
    			}
    
    			echo file_get_contents( $cache_file ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- We need to trust this unfortunately.
    			$output = ob_get_clean();
    			echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- We need to trust this unfortunately.
    			die();
    		}
    	}
    	ob_start( 'ob_gzhandler' );
    	require_once 'nginx-http-concat/ngx-http-concat.php';
    
    	$output = ob_get_clean();
    	$etag  = '"' . md5( file_get_contents( $output ) ) . '"';
    	$meta   = array(
    		'headers' => headers_list(),
    	);
    
    	header( 'x-http-concat: uncached' );
    	header( 'Cache-Control: max-age=' . 31536000 );
    	header( 'ETag: ' . $etag );
    
    	if ( isset( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ) {
    		if ( strtotime( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) < filemtime( $cache_file ) ) {
    			header( 'HTTP/1.1 304 Not Modified' );
    			exit;
    		}
    	}
    
    	file_put_contents( $cache_file, $output );
    	file_put_contents( $cache_file_meta, json_encode( $meta ) );
    	echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- We need to trust this unfortunately.
    	die();
    }
    Code language: HTML, XML (xml)

    This little bit of code in wp-config.php is what calls the above file, before WordPress even initializes, to make this as speedy as possible:

    define( 'WP_HTTP_CONCAT_CACHE', dirname(__FILE__) . '/wp-content/cache/http-concat-cache' );
    require_once dirname(__FILE__) . '/wp-content/mu-plugins/emrikol-defaults/config-nginx-http-concat.php';Code language: PHP (php)

    Finally, in an mu-plugin these lines enable the nginx-http-concat plugin:

    require_once( plugin_dir_path( __FILE__ ) . 'emrikol-defaults/nginx-http-concat/cssconcat.php' );
    	require_once( plugin_dir_path( __FILE__ ) . 'emrikol-defaults/nginx-http-concat/jsconcat.php' );Code language: PHP (php)

    All of this could definitely be packed into a legit plugin, and even leave room for other features, such as:

    • An admin UI for enabling/disabling under certain condition
    • A “clear cache” button
    • A cron event to regularly delete expired cache items

    As it is now though, I’m just leaving it be to see how well it works.  Wish me luck ๐Ÿ™‚

  • Disabling plugin deactivation in WordPress

    Disabling plugin deactivation in WordPress

    The problem came up recently about how to make sure plugins activated in the WordPress plugin UI don’t get deactivated if they are necessary for a site to function.  I thought that was an interesting thought puzzle worth spending 15 minutes on, so I came up with this function as a solution:

    function dt_force_plugin_active( $plugin ) {
    add_filter( 'pre_update_option_active_plugins', function ( $active_plugins ) use ( $plugin ) {
    // Match if properly named: wp-plugin (wp-plugin/wp-plugin.php).
    $proper_plugin_name = $plugin . '/' . $plugin . '.php';
    <pre><code>    if (
            file_exists( WP_PLUGIN_DIR . '/' . $proper_plugin_name )
            &amp;amp;&amp;amp; is_file( WP_PLUGIN_DIR . '/' . $proper_plugin_name )
            &amp;amp;&amp;amp; ! in_array( $proper_plugin_name, $active_plugins, true )
        ) {
            $active_plugins[] = $proper_plugin_name;
            return array_unique( $active_plugins );
        }
    
        // Match if improperly named: wp-plugin/cool-plugin.php.
        if (
            file_exists( WP_PLUGIN_DIR . '/' . $plugin )
            &amp;amp;&amp;amp; is_file( WP_PLUGIN_DIR . '/' . $plugin )
            &amp;amp;&amp;amp; ! in_array( $plugin, $active_plugins, true )
        ) {
            $active_plugins[] = $plugin;
            return array_unique( $active_plugins );
        }
    
        return array_unique( $active_plugins );
    }, 1000, 1 );Code language: PHP (php)

    Which can be activated in your theme’s functions.php like so:

    dt_force_plugin_active( 'akismet' ); or dt_force_plugin_active( 'wordpress-seo/wp-seo.php' );

    The only downside that I’ve seen so far is that you still get the Plugin deactivated. message in the admin notices.

  • Auto-Upgrading users in WordPress

    Auto-Upgrading users in WordPress

    I made a small site recently where I wanted all newly registered users from a specific email domain to automatically be administrators (this is a terrible idea, don’t do it).  The user registration was restricted by Single-Sign-On and 2-Factor Authentication, so I felt relatively safe doing this, especially since it was only a “for fun” project.

    The interesting bit of code that upgraded users to admins is as follows:

    add_action( 'user_register', 'upgrade_email_to_admin', 10, 1 );
    function upgrade_email_to_admin( $user_id ) {
    $user = get_user_by( 'ID', $user_id );
    if ( false !== $user ) {
    $email = $user-&gt;data-&gt;user_email;
    
       // Only example.com please.
        if ( false === strpos( $email, '@example.com' ) ) {
            return;
        }
    
        $roles = $user-&amp;gt;roles;
    
        if ( ! in_array( 'administrator', $roles, true ) ) {
            $user_update = array();
            $user_update['ID'] = $user_id;
            $user_update['role'] = 'administrator';
            wp_update_user( $user_update );
        }
    }
    Code language: PHP (php)

    This is 100% insecure, please do not do this ๐Ÿ™‚

  • Validating WordPress attachments with WP-CLI

    Validating WordPress attachments with WP-CLI

    I recently worked on migrating a site to a different server and for one reason or another, some of the images did not come over properly. While I could have just re-downloaded and re-imported all of the media, it would have taken quite a while since the media library was well over 100Gb. Instead, I opted to use WP-CLI to help find what images were missing:

    (more…)