Half-Elf on Tech

Thoughts From a Professional Lesbian

Author: Ipstenu (Mika Epstein)

  • Stacked Charts Part 1: Understanding Your Data

    Stacked Charts Part 1: Understanding Your Data

    There are a few different type of charts. Actually there are a lot. I find a nice bar chart fairly easy to read and understand. So when Tracy said we should generate some nice stats about nations, like how many shows there were per nation, I was able to do that pretty easily:

    An excerpt of shows by nation - USA has the most. Yaaaay.
    An excerpt of shows by nation

    And as far as that goes, it’s pretty cool. It’s really just the same code I use to generate category statistics already. This is, by the way, why using WordPress to generate your data is useful. It’s easy to replicate code you’ve already got.

    But then Tracy, who I think derives some perverse joy out of doing this to me, says “Can we find out how many trans characters there are per nation?”

    Use WordPress First

    If you heard my talks about Sara Lance, you’ve heard me tout that data based sites should always use WordPress functions first. By which I mean they should use taxonomies and custom post types when possible, because accessing the data will be consistent, regular, and repeatable.

    Ironically, it’s because I chose to use WordPress than I was in a bit of a bind.

    You see, we have three post types on the site right now: shows, characters, and actors. The shows have the taxonomy of ‘nation’ so getting that simple data was straightforward. The characters store the taxonomies of gender identity and sexual preference. That sounds pretty logical, right?

    So how, you may wonder, do we get a list of characters on a show? A query. Basically we search wp_post_meta for all characters with the array of lezchars_show_group and, within that multidimensional, have a show of the post ID of the show saved. Which means the characters are dynamically generated every single time a page is loaded. And yes, that is why I use The L Word as my benchmark for page speed.

    However by doing all this dynamically, generating the stats for characters per nation would look like this:

    1. Use get_terms to get a list of all shows in a nation to …
    2. Loop through all those shows and …
    3. Loop through all the characters on each show to extract the data to …
    4. Store the data per nation

    Ouch. Talk about slow.

    Solution? Use WordPress!

    Thankfully there was a workaround. One of the other odd things we do with shows is generate a show ‘score’ – a value calculated by the shows relative awesomeness, our subjective enjoyment of it, and the number of characters, alive or dead, it has.

    In order to make that generation run faster, every time a show or character is saved, I trigger the following post_meta values to be saved:

    • lezshows_characters – An array of character counts alive and dead
    • lezshows_the_score – The insane math of the score

    So I added three more:

    • lezshows_sexuality
    • lezshows_gender
    • lezshows_romantic

    All of those are generated when the post is saved, as it loops through all the characters and extracts data.

    Generate The Base

    In order to get the basics, we start by generating an array of everything we’re going to care about. I do this by listing all the taxonomies I want to use and then loop through them, adding each slug to a new array with a value of 0:

    $valid_taxes = array( 
    	'gender'    => 'lez_gender',
    	'sexuality' => 'lez_sexuality',
    	'romantic'  => 'lez_romantic',
    );
    $tax_data = array();
    
    foreach ( $valid_taxes as $title => $taxonomy ) {
    	$terms = get_terms( $taxonomy );
    	if ( ! empty( $terms ) && ! is_wp_error( $terms ) ) {
    		$tax_data[ $title ] = array();
    		foreach ( $terms as $term ) {
    			$tax_data[ $title ][ $term->slug ] = 0;
    		}
    	}
    }
    

    That gives me a multidimensional array which, I admit, is pretty epic and huge. But it lets move on to step two, of getting all the characters:

    $count          = wp_count_posts( 'post_type_characters' )->publish;
    $charactersloop = new WP_Query( array(
    	'post_type'              => 'post_type_characters',
    	'post_status'            => array( 'publish' ),
    	'orderby'                => 'title',
    	'order'                  => 'ASC',
    	'posts_per_page'         => $count,
    	'no_found_rows'          => true,
    	'meta_query'             => array( array(
    		'key'     => 'lezchars_show_group',
    		'value'   => $post_id,
    		'compare' => 'LIKE',
    	),),
    ) );
    

    Next I stop everything as a new array. Which is where we get into some serious fun. See, I have to actually double check the character is in the show, since the ‘like’ search has a few quirks when you’re searching arrays. The tl;dr explanation here is that if I look for shows with a post ID of “23” then I get “23” and “123” and “223” and so on.

    Yeah. It’s about as fun as you’d think. If I wasn’t doing arrays, this would be easier, but I have Sara Lance to worry about.

    if ($charactersloop->have_posts() ) {
    	while ( $charactersloop->have_posts() ) {
    		$charactersloop->the_post();
    		$char_id     = get_the_ID();
    		$shows_array = get_post_meta( $char_id, 'lezchars_show_group', true );
    
    		if ( $shows_array !== '' && get_post_status ( $char_id ) == 'publish' ) {
    			foreach( $shows_array as $char_show ) {
    				if ( $char_show['show'] == $post_id ) {
    					foreach ( $valid_taxes as $title => $taxonomy ) {
    						$this_term = get_the_terms( $char_id, $taxonomy, true );
    						if ( $this_term && ! is_wp_error( $this_term ) ) {
    							foreach( $this_term as $term ) {
    								$tax_data[ $title ][ $term->slug ]++;
    							}
    						}
    					}
    				}
    			}
    		}
    	}
    	wp_reset_query();
    }
    

    You’ll notice there’s a quick $tax_data[ $title ][ $term->slug ]++; in there to increment the count. That’s the magic that gets processed all over. It tells me things like “this show has 7 cisgender characters” which is the first half of everything I wanted.

    Because in the end I save this as an array for the show:

    foreach ( $valid_taxes as $title => $taxonomy ) { 
    	update_post_meta( $post_id, 'lezshows_char_' . $title , $tax_data[ $title ] );
    }
    

    How Well Does This Run?

    It’s okay. It’s not super awesome, since it has to loop so many times, this can get pretty chunky. See The L Word and it’s 60+ characters. However. It only updates when the show is saved, or a character is added to the show, which means the expensive process is limited. And by saving this data in an easily retrievable format, I’m able to do the next phase. Generate the stats.

  • Structured Data

    Structured Data

    Every month around the 15th and 30th, I check Google Webmaster to see if there are any 'errors' I need to address. Most of the time this is 404s on pages that were wrong for 30 minutes, and Google decided to crawl me right then. In January, I got a bunch of new alerts about 'structured data.'

    What is it?

    Google uses structured data that it finds on the web to understand the content of the page, as well as to gather information about the web and the world in general. Which is a fancy way of saying Google looks for specific classes and attributes in your page to determine what's what. 

    Most of the time, your theme handles this for you. It's why you see hentry and authorcard in your page source. This is part of telling Google what the content is, contextually.

    Bad Errors

    Sadly, Google's report just said I had "missing fn" errors and gave me this:

    itemtype: http://microformats.org/profile/hcard
    photo: https://secure.gravatar.com/avatar/CODE?s=118&d=retro&r=g

    That's not very helpful unless you already know what you're looking for. And even then… There was a link to test live data and I clicked on it, only to get a report back that I had no errors. Frustrating, right?

    Then I checked a different error:

    Missing: author
    Missing: entry-title
    Missing: updated

    That was a little better, I thought, and I found out that since we'd removed author and updated from those posts, for a valid reason mind you, that was showing an error. The 'easy' fix was to make this function:

    function microformats_fix( $post_id ) {
    	$valid_types = array( 'post_type_authors', 'post_type_characters', 'post_type_shows' );
    	if ( in_array( get_post_type( $post_id ), $valid_types ) ) {
    		echo '<div class="hatom-extra" style="display:none;visibility:hidden;">
    			<span class="entry-title">' . get_the_title( $post_id ) . '</span>
    			<span class="updated">' . get_the_modified_time( 'F jS, Y', $post_id ) . '</span>
    			<span class="author vcard"><span class="fn">' . get_option( 'blogname' ) . '</span></span>
    		</div>';
    	}
    }

    And then I just tossed it into my post content.

    Does This Matter?

    Kind of. It doesn't appear to actively hurt your SEO but it can help a little. In my case, I'm not actually using 'Author' so it's an inconvenience. And I don't want to make public when a page was last updated, since it's meant to be static content, but also it gets 'saved' a lot more than it gets updated, due to how I process some data. Basically it would lie to users.

    But. Apparently I get to lie to Google.

    Yaaay.

  • Customizing Jetpack Feedback

    Customizing Jetpack Feedback

    Fair bit of warning, this is a big code heavy post. I use Jetpack to handle a variety of things on my sites. Contact forms, stats, embeds, monitoring, backups, a bit of security (brute force prevention), and in some cases Photon and galleries. Oh and I especially use it for the tweeting and facebookieing of posts. Love it or hate it, it has it's uses and as a user, I find it easier to work with than its alternatives. However! Jetpack is not perfect. And my current drama llama was that I wanted to do two things:
    1. Show on my dashboard how many messages I had
    2. Mark a feedback message as 'answered' without deleting
    The second item was very important as on a shared-management type site, it's hard to know who did what and did everyone handle an email or what have you? Really a better tool would be something that you could reply to from within the admin dashboard, versus emailing all over, but that's another day. Instead, I decided to tackle a custom post status.

    This is NOT Fully Supported

    This is my caveat. My big warning. WordPress doesn't yet fully support Custom Post Status. That is, yes you can totally register them, but there's no easy way to put in the interface, as the trac ticket has been around since 2010 (you read that right) and it's still not done. All that said, if you're willing to wrangle a bit of Javascript and sanitize your outputs properly, you can do this.

    Big Block of Code

    <?php
    /*
    Description: Jetpack Customizations
    Version: 1.0
    */
    
    if ( ! defined('WPINC' ) ) die;
    
    /**
     * Customize_Jetpack_Feedback class.
     * Functions used by Jetpack to cutomize Feedback
     */
    class Customize_Jetpack_Feedback {
    
    	/**
    	 * Constructor
    	 * @since 1.0
    	 */
    	public function __construct() {
    		add_action( 'dashboard_glance_items', array( $this, 'dashboard_glance' ) );
    		add_action( 'admin_head', array( $this, 'dashboard_glance_css' ) );
    		
    		add_action( 'init', array( $this, 'custom_post_statuses' ), 0 );
    		add_filter( 'post_row_actions', array( $this, 'add_posts_rows' ), 10, 2);
    		add_action( 'plugins_loaded', array( $this, 'mark_as_answered' ) );
    		add_filter( 'display_post_states', array( $this, 'display_post_states' ) );
    		add_action( 'admin_footer-post.php', array( $this, 'add_archived_to_post_status_list' ) );
    		add_action( 'admin_footer-edit.php', array( $this, 'add_archived_to_bulk_edit' ) );
    	}
    
    	/**
    	 * Add custom post status for Answered
    	 * 
    	 * @access public
    	 * @return void
    	 * @since 1.0
    	 */
    	public function custom_post_statuses() {
    		register_post_status( 'answered', array(
    			'label'                     => 'Answered',
    			'public'                    => false,
    			'exclude_from_search'       => true,
    			'show_in_admin_all_list'    => true,
    			'show_in_admin_status_list' => true,
    			'label_count'               => _n_noop( 'Answered <span class="count">(%s)</span>', 'Answered <span class="count">(%s)</span>' ),
    		) );
    	}
    
    	/**
    	 * Add URL for replying to feedback.
    	 * 
    	 * @access public
    	 * @param mixed $actions
    	 * @param mixed $post
    	 * @return void
    	 * @since 1.0
    	 */
    	public function add_posts_rows( $actions, $post ) {
    		// Only for Feedback
    		if ( $post->post_type == 'feedback' ) {
    			$url = add_query_arg( 'answered_post_status-post_id', $post->ID );
    			$url = add_query_arg( 'answered_post_status-nonce', wp_create_nonce( 'answered_post_status-post_id' . $post->ID ), $url );
    	
    			// Edit URLs based on status
    			if ( $post->post_status !== 'answered' ) {
    				$url = add_query_arg( 'answered_post_status-status', 'answered', $url );
    				$actions['answered_link']  = '<a href="' . $url . '" title="Mark This Post as Answered">Answered</a>';
    			} elseif ( $post->post_status == 'answered' ){
    				$url = add_query_arg( 'answered_post_status-status', 'publish', $url );
    				$actions['answered']  = '<a class="untrash" href="' . $url . '" title="Mark This Post as Unanswered">Unanswered</a>';
    				unset( $actions['edit'] );
    				unset( $actions['trash'] );
    			}
    		}
    		return $actions;
    	}
    
    	/**
    	 * Add Answered to post statues
    	 * 
    	 * @access public
    	 * @param mixed $states
    	 * @return void
    	 * @since 1.0
    	 */
    	function display_post_states( $states ) {
    		global $post;
    
    		if ( $post->post_type == 'feedback' ) {
    			$arg = get_query_var( 'post_status' );
    			if( $arg != 'answered' ){
    				if( $post->post_status == 'answered' ){
    					return array( 'Answered' );
    				}
    			}
    		}
    
    		return $states;
    	}
    
    	/**
    	 * Process marking as answered
    	 * 
    	 * @access public
    	 * @return void
    	 * @since 1.0
    	 */
    	public function mark_as_answered() {
    
    		// If contact forms aren't active, we'll just pass
    		if ( class_exists( 'Jetpack' ) && Jetpack::is_module_active( 'contact-form' ) ) {
    
    			// Check Nonce
    			if ( isset( $_GET['answered_post_status-nonce'] ) && wp_verify_nonce( $_GET['answered_post_status-nonce'], 'answered_post_status-post_id' . $_GET['answered_post_status-post_id'] ) ) { 
    				// Check Current user Can and then process
    				if( current_user_can('publish_posts') && isset( $_GET['answered_post_status-status'] ) ) {
    					$GLOBALS[ 'wp_rewrite' ] = new wp_rewrite;
    		
    					$status  = sanitized_text_field( $_GET['answered_post_status-status'] );
    					$post_id = (int) $_GET['answered_post_status-post_id'];
    		
    					// If it's not a valid status, we have a problem
    					if ( !in_array( $status, array( 'answered', 'publish' ) ) ) die( 'ERROR!!!' );
    		
    					$answered = array( 'ID' => $post_id, 'post_status' => $status );
    					wp_update_post( $answered );
    				}
    			}
    
    		}
    	}
    
    
    	/**
    	 * add_archived_to_post_status_list function.
    	 * 
    	 * @access public
    	 * @return void
    	 * @since 1.0
    	 */
    	function add_archived_to_post_status_list(){
    		global $post;
    		$complete = $label = '';
    
    		// Bail if not feedback
    		if ( $post->post_type !== 'feedback' ) return;
    
    		if( $post->post_status == 'answered' ) {
    			echo '
    				<script>
    					jQuery(document).ready(function($){
    						$("#post-status-display" ).text("Answered");
    						$("select#post_status").append("<option value=\"answered\" selected=\"selected\">Answered</option>");
    						$(".misc-pub-post-status label").append("<span id=\"post-status-display\">Answered</span>");
    					});
    				</script>
    			';
    		} elseif ( $post->post_status == 'publish' ){
    			echo '
    				<script>
    					jQuery(document).ready(function($){
    						$("select#post_status").append("<option value=\"answered\" >Answered</option>");
    					});
    				</script>
    			';
    		}
    	} 
    
    	public function add_archived_to_bulk_edit() {
    		global $post;
    		if ( $post->post_type !== 'feedback' ) return;	
    		?>
    			<script>
    			jQuery(document).ready(function($){
    				$(".inline-edit-status select ").append("<option value=\"answered\">Answered</option>");
    				$(".bulkactions select ").append("<option value=\"answered\">Mark As Answered</option>");
    			});
    			</script>
    		<?php
    	}
    
    	/*
    	 * Show Feedback in "Right Now"
    	 *
    	 * @since 1.0
    	 */
    	public function dashboard_glance() {
    		if ( class_exists( 'Jetpack' ) && Jetpack::is_module_active( 'contact-form' ) ) {
    			foreach ( array( 'feedback' ) as $post_type ) {
    				$num_posts = wp_count_posts( $post_type );
    				$count_posts = ( isset( $num_posts->publish ) )? $num_posts->publish : '0';
    				if ( $count_posts !== '0' ) {
    					if ( 'feedback' == $post_type ) {
    						$text = _n( '%s Message', '%s Messages', $count_posts );
    					}
    					$text = sprintf( $text, number_format_i18n( $count_posts ) );
    					printf( '<li class="%1$s-count"><a href="edit.php?post_type=%1$s">%2$s</a></li>', $post_type, $text );
    				}
    			}
    		}
    	}
    
    	/*
    	 * Custom Icon for Feedback in "Right Now"
    	 *
    	 * @since 1.0
    	 */
    	public function dashboard_glance_css() {
    	if ( class_exists( 'Jetpack' ) && Jetpack::is_module_active( 'contact-form' ) ) {
    		?>
    		<style type='text/css'>
    			#adminmenu #menu-posts-feedback div.wp-menu-image:before, #dashboard_right_now li.feedback-count a:before {
    				content: '\f466';
    				margin-left: -1px;
    			}
    		</style>
    		<?php
    		}
    	}
    
    }
    
    new Customize_Jetpack_Feedback();
    
    What this does is create a new status for your feedback of “Answered”. Ta Dah!
  • Restrict Site Access Filters

    Restrict Site Access Filters

    I have a demo site I use to for development. One of the things I want is to be able to lock the site to logged in users only and that I can do via Restricted Site Access by 10up.

    One of the things the plugin also allows is to open up access to an IP, so someone who doesn't have an account can check the site before you go live. The problem with this feature is caching.

    Caching Restricted Pages

    It doesn't really matter what kind of caching system you use, the point is all the same. People who aren't logged in should get a cached version of the content. People who are logged in, or whom you've determined need a unique experience, don't get cached content. That's the barebones of caching.

    The problem I ran into with restricted site access is that if I whitelisted an IP range, and someone from that range visited the site, they generated a page which my cache system … cached. That meant the next person got to see the cached content.

    Worf from Star Trek face-palming

    Now this may not actually be a problem in all cache systems, but I happened to be using Varnish, which is fairly straightforward about how it works. And, sadly, the plugin I'm using doesn't have a way around this. Yet.

    Filters and Hooks

    Like any enterprising plugin hoyden, I popped open the code and determined I needed to address the issue here:

    // check if the masked versions match
    if ( ( inet_pton( $ip ) & $mask ) == ( $remote_ip & $mask ) ) {
    	return;
    }

    This section of code is checking "If the IP matches the IP we have on our list, stop processing the block. It's okay to show them the content." What I needed was to add something just above the return to tell it "And if it's Varnish, don't cache!"

    At first my idea was to just toss a session_start() in there, which does work. For me. Adam Silverstein was leery of that having unintended consequences for others, and wouldn't it be better to make it hookable? After all, then any caching plugin could hook in! He was right, so I changed my pull request to this:

    do_action( 'restrict_site_access_ip_match', $remote_ip, $ip, $mask ); // allow users to hook ip match

    The next version of the release will have that code.

    In The Field

    Now, assuming you've slipped that code into your plugin, how do you actually use it?

    Since I need to have this only on my 'dev' site, and I'm incredibly lazy efficient, I decided to put this code into the MU plugins I use for the site:

    if ( DB_HOST == 'mysql.mydevsite.dream.press' ) {
    	add_action( 'restrict_site_access_ip_match', 'mydevsite_restrict_site_access_ip_match' );
    }
    
    function mydevsite_restrict_site_access_ip_match() {
    	session_start();
    }

    This is not the only way to do it. I also happen to have a define of define( 'MYSITE_DEV', true ); in my wp-config.php file, so I could have checked if that was true:

    if ( defined( 'MYSITE_DEV' ) && MYSITE_DEV ) { ... }

    Now, you'll notice I'm using sessions, even after Adam and I determined this could be bad for some people. It can. And in my case, in this specific situation, it's not dangerous. It's a quick and dirty way to tell Varnish not to cache (because PHP sessions indicate a unique experience is needed).

    The downside is that not caching means there's more load on my server for the non-logged in user who is legit supposed to be visiting the site. Since this is a development site, I'm okay with that. I would never run this in production on a live site. 

  • Accidental Example

    Accidental Example

    My father was having email woes, so I undertook the monumental task of sorting out his hellish setup. Among other hurdles, he still uses (and in fact prefers) POP email.

    Don't judge him.

    However it was in reviewing the POP mail that I found a problem. He had over 145 emails, and of them only 33 or so were legitimate emails. Of the other 112, about 20 were 'mailing lists' (like Safeway and Egencia and crap we do actually use), 5 or so were porn, and then 87 were from a deployment service.

    Not His Monkey House

    I double checked that my father didn't use the service and then I looked at the email. They were all emails for an account payable system that he absolutely didn't use.

    Sample image of the emails, saying that someone was moved to "paid" in accounts payable.

    That's not at all Dad's job, so I agreed they were likely junk but how did they get there?

    A Real Company

    The first thing I did was check that this was a legit company. Interesting. I then did the logical step and requested a password reset for his email. It emailed me a link, which I clicked and yes, it let me reset the password… Except it didn't.

    I got an error saying that the 'username' was already in use.

    Which made no sense. I was on the password reset form. Not a create user form. So I tried a few different ways, and then tried to file a bug report or ask for help with is email and it all error'd out. It did not like his email.

    To Twitters!

    I then complained on Twitter, which netted me the very helpful Isabelle who DM'd me and knew right away what was happening.

    The hundreds of emails were actually just a mix-up because one of our product specialists had a demonstration company with a database with tons of 'demonstration users' with personalities and characters names and your dad's email got in by accident (due to its homonym toy story character).

    Isabelle

    Dad's domain is woody.com you see.

    Suddenly it all made sense.

    Why We Use Example.com

    They went ahead and removed his email from all their pipelines and deleted the fake account they'd made for the domain (which explains why I couldn't do a reset). And I haven't seen an email come in after that.

    It was a rude awaking for this poor company. We don't use real domains in our examples for a damn good reason: people copy/pasta.

    No one thought to check if the domain existed, and it's pure coincidence that they picked his email for the demos and examples. And yet it's a good reminder for you too. Those example domains you pick will probably be used by someone in production. Don't spam them.

    But a bigger concern is this. How much private data got sent to my father over the course of the weeks this was the case? How much information did he have access to that he shouldn't? You're all very lucky he's not malicious.

  • Still Not Using Plugins for Security

    Still Not Using Plugins for Security

    Seven years, and my answer to 'do I need a security plugin' is the same.

    Nope.

    What is a Security Plugin?

    A security plugin is not a plugin like 'brute force protect' or 'limit login attempts.'

    A security plugin is like Better WP Security or WordFence or a hundred other plugins that promise to scan your site and let you know what's changed.

    This is not to say that first set of plugins aren't there to make you 'safer,' it's that those are single use, targeted plugins that address a single issue. Limiting login attempts prevents someone from trying the same attack over and over and over until they get in.

    By contrast, all-in-one security plugins try to do everything. They scan your code, your data, and your site. They look for all the possible attack vectors and they try to plugin them.

    What Makes That Secure?

    That's the question I ask people. If a plugin adds in 2-factor authentication, I ask them what it does for them? Password expirations, captchas, file compares etc. Those are all good things, individually, but are they applicable for all people? What, specifically, about those things makes you more secure or not?

    Now. Before you get all shirty with me, I am well aware of what all of those things are good for. With the exception of captchas (which are not accessibility friendly, please stop using them), all of those things make a lot of sense. You expire passwords and, one hopes, require strong passwords to make it harder to break in. But if you have a 2FA setup, do you need to require rotating passwords?

    It’s All About Thinking

    Security plugins stop people from thinking about what's going on.

    I've seen it time and again, people install a plugin that 'makes them safe,' follow the bare minimum of requirements, and then install whatever they want without thinking about it, leave registrations open, and oops, get hacked.

    This is not to say that security plugins don't prevent some of that from happening, but they're often an 'after the fact' solution. That is, usually a security plugin doesn't know to block X until X has been exploited. That's kind of the nature of the beast, though, and why WordPress and many other CMS developers don't release full details on security fixes until they've been out there for a while. They want to give people a chance to upgrade before saying "Hey, y'all who didn't are super vulnerable."

    It’s Also About Speed

    Security plugins also have a tendency to make your site slower. This usually comes up when people have turned on everything that comes with a security plugin. Which goes right back to my point about thinking. The user doesn't think, because they're not yet educated, about the impact of the code on their site.

    To put it simply, the more things you ask WordPress to do before it can load a page, the slower it will be to load a page.

    Pretty cut and dried, right?

    What’s My Answer?

    I don't call this the 'right' answer or even the best one. Not everyone has access to my resources after all, so it's not fair to say "Hire Mika to think for you!" But to me, the best answer is to use the resources you have intelligently.

    Firewalls, from a server side, are all but a requirement to me. If your web host doesn't have one, and most at least have ModSecurity, get a new web host. If you disabled it on your site because a random plugin doesn't work with it, delete the plugin and turn it back on. If you can't move to a new host, look into firewalls like Incapsula or Sucuri. Put something between users and data.

    Site Scanning is a great tool, but don't run it on WordPress. A great example of smart security scanning is VaultPress. It's a remote service that has a copy of all your files and it scans the copy, not your site, for issues. There are other services you can use that scan your site without affecting traffic. Again, web hosts often have tools for this.

    Be Aware of what's going on. Don't just let security be a black box. Make sure you know what kinds of attacks are common on your site. If you're hit by a DDoS, for example, where they're just hammering your site to take it down, a 2FA plugin will not help. If they're trying to log in all the time, a scanner is probably not what you need.

    Lock It Down. If you don't need it, don't use it. If you don't need it on, turn it off. Update regularly. Don't install everything under the sun. 

    Don’t Buy Into FUD

    This is a tricky balance. On the one hand, I want to say 'don't panic if your favourite security plugin of choice tells you everything doomed!' Remember, they're trying to sell you things. But on the other … don't think everything's fine and dandy.

    It's not a simple solution. You have to simultaneously be aware of problems and not overwhelmed by them. You have to learn how to care about which ones are important to you and which are not.

    In a word, you have to think.

    And there is no plugin on the planet that can think for you.