Half-Elf on Tech

Thoughts From a Professional Lesbian

Tag: wordpress

  • Secure Mindsets in Plugins

    Secure Mindsets in Plugins

    At WordCamp Europe last week, I talked about the basics of plugin development. Since I had a mixed bag of experiences, I decided not to actually write a plugin in the class, but instead I took Hello Dolly and edited it. I discussed how the plugin worked, that an action called a function, which returned a value, and showed the interconnectivity. In this way, the attendees could understand the big picture of how code comes together.

    But at the end, with five minutes, I touched on an important aspect of plugins that Hello Dolly doesn’t do much with, because it doesn’t have to.

    I talked about security.

    Past You

    In the past, you probably done insecure things. Have you ever left your car unlocked in the driveway while you ran the groceries inside? We all do things that are insecure or unsafe. This is normal. Similarly, we have done insecure code. In the past, all of us, when we begin, we write code to perform actions without thinking about how it will be used globally. We don’t worry about safe, we worry about functions.

    There’s nothing wrong with this. We are often focus driven designers, fueled by passion and desire, so we want to do and not worry about the details.

    This Morning’s You

    That said, when we do work in that way, we get ourselves into trouble when we ignore security. We assume people will only use the code in the right way, because it’s obvious what is right and what is wrong. I try not to say ‘obvious’ or ‘simple’ when talking about code or interfaces, because they are absolutely never, not once, obvious or simple. When I got my Apple Watch, the UX of Force Touch wasn’t obvious to me. It’s not simple now, since it can be a bit touchy, but it’s not difficult.

    In the same vein, we all know that users do weird shit. Really weird shit. They put text in fields that should only gen numbers. They put numbers in for email. They copy paste without thinking. And you know that. You’ve seen it.

    The Right Now You

    Having read that, you’re hopefully thinking “how can I make my code secure?”

    When we talk about basic security, we mean four things:

    1. Validate your data
    2. Sanitize what you save
    3. Escape what you output
    4. Verify a human meant to do it

    That’s it. Make sure a date is a date and an email is an email. Make sure you save the data in a way that doesn’t put other data at risk. Remove any possibly dangerous characters from what you show to users. Always make sure someone meant to do the action. WordPress has over a dozen Sanitize and Escape functions to help make sure you save the right data and it has nonces to help make sure you save when you should.

    They’re very complex, but at their heart, they do those four things.

    Future You

    The you of tomorrow will appreciate the you of today, if you remember to never trust your data. People typo. People make mistakes. People do bad things on purpose. All of that just happens. And if today, you learn how stop those bad things, tomorrow’s you will look back on you with love and thanks. Your users will thank you. Your next future will love you even more.

    Security isn’t just https and good passwords. It’s a mindset to remember that anything passed to your code might be attacked. It’s a mindset that good users do bad and dumb things. It’s a mindset that mistakes happen. And it’s a mindset that being aware of the whole of your code, how it all comes together, must always include validation, sanitization, escaping, and nonces.

  • A Fully Functional Alexa Skill

    A Fully Functional Alexa Skill

    I’ve been talking a lot (a lot) about my Amazon Alexa skill.

    First of all, it’s done. It’s published and working. You can check it out on Amazon.com.

    Secondly, a lot of people are like me and need a real, concrete, working example. So I’ve decided to post the entirety of my Amazon Alexa Skill.

    This file, which is actually found on a site in /lwtv-plugin/rest-api/alexa-skills.php consists of two endpoints: the flash briefing and the skill. The skill is called “Bury Your Queers” and while I suspect that part of the code is the least ‘useful’ to people, it’s also good to see the real code.

    Here, then, is the original approved and certified WordPress code for an Alexa Skill:

    The Code

    <?php
    /*
    Description: REST-API - Alexa Skills
    
    For Amazon Alexa Skills
    
    Version: 1.0
    Author: Mika Epstein
    */
    
    if ( ! defined('WPINC' ) ) die;
    
    /**
     * class LWTV_Alexa_Skills
     *
     * The basic constructor class that will set up our JSON API.
     */
    class LWTV_Alexa_Skills {
    
    	/**
    	 * Constructor
    	 */
    	public function __construct() {
    		add_action( 'rest_api_init', array( $this, 'rest_api_init') );
    	}
    
    	/**
    	 * Rest API init
    	 *
    	 * Creates callbacks
    	 *   - /lwtv/v1/flash-briefing
    	 */
    	public function rest_api_init() {
    
    		// Skills
    		register_rest_route( 'lwtv/v1', '/alexa-skills/briefing/', array(
    			'methods' => 'GET',
    			'callback' => array( $this, 'flash_briefing_rest_api_callback' ),
    		) );
    
    		// Skills
    		register_rest_route( 'lwtv/v1', '/alexa-skills/byq/', array(
    			'methods' => [ 'GET', 'POST' ],
    			'callback' => array( $this, 'bury_your_queers_rest_api_callback' ),
    		) );
    
    
    	}
    
    	/**
    	 * Rest API Callback for Flash Briefing
    	 */
    	public function flash_briefing_rest_api_callback( $data ) {
    		$response = $this->flash_briefing();
    		return $response;
    	}
    
    	/**
    	 * Rest API Callback for Bury Your Queers
    	 * This accepts POST data
    	 */
    	public function bury_your_queers_rest_api_callback( WP_REST_Request $request ) {
    
    		$type   = ( isset( $request['request']['type'] ) )? $request['request']['type'] : false;
    		$intent = ( isset( $request['request']['intent']['name'] ) )? $request['request']['intent']['name'] : false;
    		$date   = ( isset( $request['request']['intent']['slots']['Date']['value'] ) )? $request['request']['intent']['slots']['Date']['value'] : false;
    
    		$validate_alexa = $this->alexa_validate_request( $request );
    
    		if ( $validate_alexa['success'] != 1 ) {
    			$error = new WP_REST_Response( array( 'message' => $validate_alexa['message'], 'data' => array( 'status' => 400 ) ) );
    			$error->set_status( 400 );
    			return $error;
    		}
    
    		$response = $this->bury_your_queers( $type, $intent, $date );
    		return $response;
    	}
    
    
    	function alexa_validate_request( $request ) {
    
    		$chain_url = $request->get_header( 'signaturecertchainurl' );
    		$timestamp = $request['request']['timestamp'];
    		$signature = $request->get_header( 'signature' );
    
    	    // Validate that it even came from Amazon ...
    	    if ( !isset( $chain_url ) )
    	    	return array( 'success' => 0, 'message' => 'This request did not come from Amazon.' );
    
    	    // Validate proper format of Amazon provided certificate chain url
    	    $valid_uri = $this->alexa_valid_key_chain_uri( $chain_url );
    	    if ( $valid_uri != 1 )
    	    	return array( 'success' => 0, 'message' => $valid_uri );
    
    	    // Validate certificate signature
    	    $valid_cert = $this->alexa_valid_cert( $request, $chain_url, $signature );
    	    if ( $valid_cert != 1 )
    	    	return array ( 'success' => 0, 'message' => $valid_cert );
    
    	    // Validate time stamp
    		if (time() - strtotime( $timestamp ) > 60)
    			return array ( 'success' => 0, 'message' => 'Timestamp validation failure. Current time: ' . time() . ' vs. Timestamp: ' . $timestamp );
    
    	    return array( 'success' => 1, 'message' => 'Success' );
    	}
    
    	/*
    		Validate certificate chain URL
    	*/
    	function alexa_valid_key_chain_uri( $keychainUri ){
    
    	    $uriParts = parse_url( $keychainUri );
    
    	    if (strcasecmp( $uriParts['host'], 's3.amazonaws.com' ) != 0 )
    	        return ( 'The host for the Certificate provided in the header is invalid' );
    
    	    if (strpos( $uriParts['path'], '/echo.api/' ) !== 0 )
    	        return ( 'The URL path for the Certificate provided in the header is invalid' );
    
    	    if (strcasecmp( $uriParts['scheme'], 'https' ) != 0 )
    	        return ( 'The URL is using an unsupported scheme. Should be https' );
    
    	    if (array_key_exists( 'port', $uriParts ) && $uriParts['port'] != '443' )
    	        return ( 'The URL is using an unsupported https port' );
    
    	    return 1;
    	}
    
    	/*
    	    Validate that the certificate and signature are valid
    	*/
    	function alexa_valid_cert( $request, $chain_url, $signature ) {
    
    		$md5pem     = get_temp_dir() . md5( $chain_url ) . '.pem';
    	    $echoDomain = 'echo-api.amazon.com';
    
    	    // If we haven't received a certificate with this URL before,
    	    // store it as a cached copy
    	    if ( !file_exists( $md5pem ) ) {
    		    file_put_contents( $md5pem, file_get_contents( $chain_url ) );
    		}
    
    	    $pem = file_get_contents( $md5pem );
    
    	    // Validate certificate chain and signature
    	    $ssl_check = openssl_verify( $request->get_body() , base64_decode( $signature ), $pem, 'sha1' );
    
    	    if ($ssl_check != 1 ) {
    		    return( openssl_error_string() );
    		}
    
    	    // Parse certificate for validations below
    	    $parsedCertificate = openssl_x509_parse( $pem );
    	    if ( !$parsedCertificate ) return( 'x509 parsing failed' );
    
    	    // Check that the domain echo-api.amazon.com is present in
    	    // the Subject Alternative Names (SANs) section of the signing certificate
    	    if(strpos( $parsedCertificate['extensions']['subjectAltName'], $echoDomain) === false) {
    	        return( 'subjectAltName Check Failed' );
    	    }
    
    	    // Check that the signing certificate has not expired
    	    // (examine both the Not Before and Not After dates)
    	    $validFrom = $parsedCertificate['validFrom_time_t'];
    	    $validTo   = $parsedCertificate['validTo_time_t'];
    	    $time      = time();
    
    	    if ( !( $validFrom <= $time && $time <= $validTo ) ) {
    	        return( 'certificate expiration check failed' );
    	    }
    
    	    return 1;
    	}
    
    	/**
    	 * Generate the Flash Briefing output
    	 *
    	 * @access public
    	 * @return void
    	 */
    	public function flash_briefing() {
    
    		$query = new WP_Query( array( 'numberposts' => '10' ) );
    		if ( $query->have_posts() ) {
    			while ( $query->have_posts() ) {
    				$query->the_post();
    
    				$response = array(
    					'uid'            => get_the_permalink(),
    					'updateDate'     => get_post_modified_time( 'Y-m-d\TH:i:s.\0\Z' ),
    					'titleText'      => get_the_title(),
    					'mainText'       => get_the_title() . '. ' . get_the_excerpt(),
    					'redirectionUrl' => home_url(),
    				);
    
    				$responses[] = $response;
    			}
    			wp_reset_postdata();
    		}
    
    		if ( count( $responses ) === 1 ) {
    			$responses = $responses[0];
    		}
    
    		return $responses;
    
    	}
    
    	/**
    	 * Generate Bury Your Queers
    	 *
    	 * @access public
    	 * @return void
    	 */
    	public function bury_your_queers( $type = false, $intent = false, $date = false ) {
    
    		$whodied    = '';
    		$endsession = true;
    		$timestamp  = ( strtotime( $date ) == false )? false : strtotime( $date ) ;
    		$helptext   = 'You can find out who died on specific dates by asking me questions like "who died" or "who died today" or "who died on March 3rd" or even "How many died in 2017." If no one died then, I\'ll let you know.';
    
    		if ( $type == 'LaunchRequest' ) {
    			$whodied = 'Welcome to the LezWatch TV Bury Your Queers skill. ' . $helptext;
    			$endsession = false;
    		} else {
    			if ( $intent == 'AMAZON.HelpIntent' ) {
    				$whodied = 'This is the Bury Your Queers skill by LezWatch TV, home of the world\'s greatest database of queer female on TV. ' . $helptext;
    				$endsession = false;
    			} elseif ( $intent == 'AMAZON.StopIntent' || $intent == 'AMAZON.CancelIntent' ) {
    				// Do nothing
    			} elseif ( $intent == 'HowMany' ) {
    				if ( $date == false || $timestamp == false ) {
    					$data     = LWTV_Stats_JSON::statistics( 'death', 'simple' );
    					$whodied  = 'A total of '. $data['characters']['dead'] .' queer female characters have died on TV.';
    				} elseif ( !preg_match( '/^[0-9]{4}$/' , $date ) ) {
    					$whodied    = 'I\'m sorry. I don\'t know how to calculate deaths in anything but years right now. ' . $helptext;
    					$endsession = false;
    				} else {
    					$data     = LWTV_Stats_JSON::statistics( 'death', 'years' );
    					$count    = $data[$date]['count'];
    					$how_many = 'No queer female characters died on TV in ' . $date . '.';
    					if ( $count > 0 ) {
    						$how_many = $count .' queer female ' . _n( 'character', 'characters', $count ) . ' died on TV in ' . $date . '.';
    					}
    					$whodied  = $how_many;
    				}
    			} elseif ( $intent == 'WhoDied' ) {
    				if ( $date == false || $timestamp == false ) {
    					$data    = LWTV_BYQ_JSON::last_death();
    					$name    = $data['name'];
    					$whodied = 'The last queer female to die was '. $name .' on '. date( 'F j, Y', $data['died'] ) .'.';
    				} elseif ( preg_match( '/^[0-9]{4}-(0[1-9]|1[0-2])$/' , $date ) ) {
    					$whodied    = 'I\'m sorry. I don\'t know how to calculate deaths in anything but days right now. ' . $helptext;
    					$endsession = false;
    				} else {
    					$this_day = date('m-d', $timestamp );
    					$data     = LWTV_BYQ_JSON::on_this_day( $this_day );
    					$count    = ( key( $data ) == 'none' )? 0 : count( $data ) ;
    					$how_many = 'No queer females died';
    					$the_dead = '';
    					if ( $count > 0 ) {
    						$how_many  = $count . ' queer female ' . _n( 'character', 'characters', $count ) . ' died';
    						$deadcount = 1;
    						foreach ( $data as $dead_character ) {
    							if ( $deadcount == $count && $count !== 1 ) $the_dead .= 'And ';
    							$the_dead .= $dead_character['name'] . ' in ' . $dead_character['died'] . '. ';
    							$deadcount++;
    						}
    					}
    					$whodied = $how_many . ' on '. date('F jS', $timestamp ) . '. ' . $the_dead;
    				}
    			} else {
    				// We have a weird request...
    				$whodied = 'I\'m sorry, I don\'t understand that request. Please ask me something else.';
    				$endsession = false;
    			}
    		}
    		$response = array(
    			'version'  => '1.0',
    			'response' => array (
    				'outputSpeech' => array (
    					'type' => 'PlainText',
    					'text' => $whodied,
    				),
    				'shouldEndSession' => $endsession,
    			)
    		);
    
    		return $response;
    
    	}
    
    }
    new LWTV_Alexa_Skills();
    

    Some Explanations

    The functions bury_your_queers and bury_your_queers_rest_api_callback are the important ones. The flash briefing is there because I was tired of Amazon being picky about embedded media in RSS feeds.

    The way bury_your_queers_rest_api_callback works is it takes the request data to generate the type of request, the intent, and the date information. Then it passes the full request data to alexa_validate_request which is the part you’ll really want.

    That function, alexa_validate_request, is what’s validating that the request came from Amazon, that it’s got a legit certificate from Amazon, and that the request was made in the last 60 seconds. While all those checks kick back an error, the development tools from Amazon will not show you them. Yet. I’m hoping they will in the future so we can more easily debug, but it was a lot of blind debugging. Not my favorite.

    Some Custom Code

    In the bury_your_queers function, I make some calls to other code not included:

    • LWTV_BYQ_JSON::last_death()
    • LWTV_BYQ_JSON::on_this_day( $this_day )

    Those both reference another rest API class in a different file. What’s important here is not what the data is, but that I’m calling those functions and getting an array back, and using that to fill in my reply. For example, here we have the call for ‘last death’:

    $data    = LWTV_BYQ_JSON::last_death();
    $name    = $data['name'];
    $whodied = 'The last queer female to die was '. $name .' on '. date( 'F j, Y', $data['died'] ) .'.';
    

    From this you can infer that the array kicked back has key for name and died. And in fact, if you look at the JSON output, you’ll see if has that and a bit more. I’m just extracting what is required. The same is true of the other function, LWTV_BYQ_JSON::on_this_day, to which I’m passing a parameter of a date.

  • POST Isn’t Just For Posting

    POST Isn’t Just For Posting

    I’m taking a moment here to explain something that confused the hell out of me when I was getting into the JSON API. In short, I was confused about what POST meant in the JSON API.

    POST vs GET

    The official documentation mentions this:

    Want to get your site’s posts? Simply send a GET request to /wp-json/wp/v2/posts. Update user with ID 4? Send a POST request to /wp-json/wp/v2/users/4. Get all posts with the search term “awesome”? GET /wp-json/wp/v2/posts?search=awesome. It’s that easy.

    From that I inferred that if I was updated content on my site, I would use a POST call. Otherwise, I’m always going to be using GET to get data. Makes sense. If you want to get the data on a specific post, you do a get for /wp-json/wp/v2/posts/1234 and get that post’s data. Store the JSON output as a parameter in your plugin, let’s say, and Bob’s your father’s brother’s husband.

    I was wrong.

    Alexa Posts

    When I started working with Alexa I was confounded. It told me it sent a POST request to my site. I stared at that for a while. I’d been assuming that when I asked Alexa something, I’d be able to tell it to do a GET request from /wp-json/MYAPP/v1/MYSKILL/parameter. After all, I’m not posting data.

    But then I thought about it a little bit more. A straightforward GET request gets data from a URL without interaction. A POST posts data to a site, and you decide what to do with it. Most of the time when we think of a POST action happening, we think of updating data.

    POST doesn’t have to mean update

    A POST is just sending data to your JSON API. It posts to your site.

    That’s why passing the WP_REST_Request $request data to your function gives you magical access to the request data. And from that we can grab all the data Alexa requests send to your site, which lets us parse the data and make decisions on our replies.

    Now like I said before, what you do with the POST is up to you. But that explains a lot about why Amazon is so picky about making sure a request came legit from them. Especially since you can order stuff from Amazon…

    “Hey Alexa, can you tell TV shows to stop killing off queer characters?”

  • FacetWP, Genesis, and Archives

    FacetWP, Genesis, and Archives

    In my ongoing use of FacetWP and Genesis, I ran into a case where I wanted to change the archive description content based on what sorts of options had been selected in the search. In part I wanted to remind visitors of what they’d picked, but also I wanted to easy to remove a search facet.

    Before

    In the beginning, the archive was a static thing:

    Before any work was done - it says 'TV Shows' and lists how many.

    This is intentionally boring. It lists the archive title, how many posts, and a description.

    Filtering the Content

    Since this is Genesis, the first step is to know how to filter at all. Since I’m only doing this on custom post types, I went with the very precise action and that is genesis_do_cpt_archive_title_description (aptly named).

    I remove it and then add in my own:

    remove_action( 'genesis_before_loop', 'genesis_do_cpt_archive_title_description' );
    add_action( 'genesis_before_loop', 'DOMAIN_do_facet_archive_title_description' );
    

    From here out, all the work will happen in the function DOMAIN_do_facet_archive_title_description which lives in my functions.php because it’s all theme specific.

    What Gets Added

    Now it’s time to decide what you want to add. I picked three things:

    1. Change the post count based on the results
    2. List the selections chosen
    3. Change the title based on the sort order

    Those are two simple asks and one weird one.

    Facet comes with the ability to display counts and selections:

    • facetwp_display( 'counts' );
    • facetwp_display( 'selections' );

    The problem I had was that the counts were formatted in a way I didn’t like, so I quickly cleaned it up by filtering the result count:

    add_filter( 'facetwp_result_count', function( $output, $params ) {
        $output = $params['total'];
        return $output;
    }, 10, 2 );
    

    That means the count and the selections can simply be tacked on to the description.

    Adding the Sort Data

    The hardest part was figuring out how to add the sort data. Since FacetWP uses a lot of javascript, I spent half an afternoon ranting to myself and trying to figure out how to do this in javascript. And then I did what I usually do when confused. I read the code.

    As I read, I realized some of FacetWP’s magic is that they pass the GET parameters of the search over to javascript… And if they were doing that, then I could just use PHP to grab those parameters.

    All I had to do was pass $_GET['fwp_sort'] into a variable.

    The Code

    Enough talk. Here’s the code:

    function lwtvg_do_facet_archive_title_description() {
    
    	$headline = genesis_get_cpt_option( 'headline' );
    
    	if ( empty( $headline ) && genesis_a11y( 'headings' ) ) $headline = post_type_archive_title( '', false );
    
    	$intro_text  = genesis_get_cpt_option( 'intro_text' );
    	$count_posts = facetwp_display( 'counts' );
    	$selections  = facetwp_display( 'selections' );
    	$fwp_sort    = ( isset( $_GET['fwp_sort'] ) )? $_GET['fwp_sort'] : '';
    
    	switch ( $fwp_sort ) {
    		case 'most_chars':
    			$sort = 'Number of Characters (Descending)';
    			break;
    		case 'least_chars':
    			$sort = 'Number of Characters (Ascending)';
    			break;
    		case 'most_dead':
    			$sort = 'Number of Dead Characters (Descending)';
    			break;
    		case 'least_dead':
    			$sort = 'Number of Dead Characters (Ascending)';
    			break;
    		case 'date_desc':
    			$sort = 'Date (Newest)';
    			break;
    		case 'date_asc':
    			$sort = 'Date (Oldest)';
    			break;
    		case 'title_desc':
    			$sort = 'Name (Z-A)';
    			break;
    		case 'title_asc':
    		default:
    			$sort = 'Name (A-Z)';
    	}
    
    	$headline    = $headline ? sprintf( '<h1 %s>%s Sorted By %s (%s)</h1>', genesis_attr( 'archive-title' ), strip_tags( $headline ), $sort, $count_posts ) : '';
    
    	$intro_text  = $intro_text ? apply_filters( 'genesis_cpt_archive_intro_text_output', $intro_text ) : '';
    	$intro_text .= $selections;
    
    	if ( $headline || $intro_text ) printf( '<div %s>%s</div>', genesis_attr( 'cpt-archive-description' ), $headline . $intro_text );
    }
    

    You’ll notice that I’ve kept in all the regular Genesis filters. This was so that my theme can take advantage of whatever magic Genesis invents down the line.

    How It Looks

    Now the default looks like this:

    Default view, before sorting

    And after you’ve picked a few options, it changes to this:

    After Sorting

    If you click the little x’s on the side of the selections, they’re removed.

    There’s still room for design improvement, but remember folks. Release and iterate.

  • Adding Sort Options to Facet

    Adding Sort Options to Facet

    As I implement more and more aspects of FacetWP, I find more and more ways to manipulate the searches. At first I only added in the features that let people easily search for multiple aspects at once. But I hadn’t yet added in any features to sorting.

    Sorting and Ordering

    The way Facets generally work is that you can easily organize all ‘types’ together, so if you wanted to search for everything that crossed four separate categories, it was very easy. In addition, you can extend it to search meta data as well.

    Sorting, on the other hand, is changing the order of the results. For example, if you wanted to search for everyone with terms A, B, and D, and post meta foo, but order them based on post meta bar, you can!

    A Practical Example

    I always do better with examples I can wrap my hands around.

    Take television shows. Take a list of 500 TV shows, and have them include the following taxonomies:

    • Genres (drama, sitcom, etc)
    • Airdates (Year to Year)
    • Tropes (common tropes)
    • Number of characters
    • Number of dead characters

    That’s enough for now.

    With that list, and a couple facets, you can concoct a smaller list of all sitcoms that aired in between 2014 and 2016 (inclusive), with a trope of ‘sex workers.’ The answer is 4 by the way. By default, the list displays alphabetically.

    But. What if you wanted to order them by the ones with the most characters first?

    That’s sorting.

    The Code

    Okay so how do we add this in? Functions!

    Facet comes with quite a few defaults, but it lets you add your own sort options. The two things I’m going to show below are how to rename the display labels for some of the defaults, and how to add in one new option for the most number of characters:

    add_filter( 'facetwp_sort_options', 'DOMAIN_facetwp_sort_options', 10, 2 );
    
    function facetwp_sort_options( $options, $params ) {
    
    	$options['default']['label']    = 'Default (Alphabetical)';
    	$options['title_asc']['label']  = 'Name (A-Z)';
    	$options['title_desc']['label'] = 'Name (Z-A)';
    
    	if ( is_post_type_archive( 'DOMAIN_shows' ) ) {
    
    		$options['most_characters'] = array(
    			'label' => 'Number of Characters (Descending)',
    			'query_args' => array(
    				'orderby'  => 'meta_value_num', // sort by numerical
    				'meta_key' => 'DOMAIN_char_count',
    				'order'    => 'DESC', // descending order
    			)
    		);
    	}
    

    I have this wrapped in a check for is_post_type_archive because I don’t want the options to show on other pages. The meta key is the name of the meta key you’re going to use to sort by (I have key that updates every time a post is saved with a count of characters attached) and the orderby value is one of the ones WP Query can use.

    End result?

    A dropdown with the options

    Looks nice!

  • Taxonomy Icons

    Taxonomy Icons

    Last year I talked about how I made icons for my taxonomy terms. When you have a limited number of terms, that makes sense. When you have a taxonomy with the potential for a high volume of terms, like nations (192), or worse an unlimited number of terms, this approach looses its value.

    Instead, I realized what I needed for a particular project was a custom icon for each taxonomy. Not the term.

    I split this up into two files because I used a slightly different setup for my settings API, but the tl;dr of all this is I made a settings page under themes called “Taxonomy Icons” which loads all the public, non-default taxonomies and associates them with an icon.

    For this to work for you, you will need to have your images in a folder and define that as your IMAGE_PATH in the code below. Also mine is using .svg files, so change that if you’re not.

    File 1: taxonomy-icons.php

    The one gotcha with this is I usually set my default values with $this->plugin_vars = array(); in the __construct function. You can’t do that with custom taxonomies, as they don’t exist yet.

    class TaxonomyIcons {
    
    	private $settings;
    	const SETTINGS_KEY = 'taxicons_settings';
    	const IMAGE_PATH   = '/path/to/your/images/';
    
    	/*
    	 * Construct
    	 *
    	 * Actions to happen immediately
    	 */
        public function __construct() {
    
    		add_action( 'admin_menu', array( $this, 'admin_menu' ) );
    		add_action( 'admin_init', array( $this, 'admin_init' ) );
    		add_action( 'init', array( $this, 'init' ) );
    
    		// Normally this array is all the default values
    		// Since we can't set it here, it's a placeholder
    		$this->plugin_vars = array();
    
    		// Create the list of imagess
    		$this->images_array = array();
    		foreach ( glob( static::IMAGE_PATH . '*.svg' ) as $file) {
    			$this->images_array[ basename($file, '.svg') ] = basename($file);
    		}
    
    		// Permissions needed to use this plugin
    		$this->plugin_permission = 'edit_posts';
    
    		// Menus and their titles
    		$this->plugin_menus = array(
    			'taxicons'    => array(
    				'slug'         => 'taxicons',
    				'submenu'      => 'themes.php',
    				'display_name' => __ ( 'Taxonomy Icons', 'taxonomy-icons' ),
    			),
    		);
        }
    
    	/**
    	 * admin_init function.
    	 *
    	 * @access public
    	 * @return void
    	 * @since 0.1.0
    	 */
    	function admin_init() {
    		// Since we couldn't set it in _construct, we do it here
    		// Create a default (false) for all current taxonomies
    		$taxonomies = get_taxonomies( array( 'public' => true, '_builtin' => false ), 'names', 'and' );
    		if ( $taxonomies && empty( $this->plugin_vars ) ) {
    			foreach ( $taxonomies as $taxonomy ) {
    				$this->plugin_vars[$taxonomy] = false;
    			}
    		}
    	}
    
    	/**
    	 * init function.
    	 *
    	 * @access public
    	 * @return void
    	 * @since 0.1.0
    	 */
    	function init() {
    		add_shortcode( 'taxonomy-icon', array( $this, 'shortcode' ) );
    	}
    
    	/**
    	 * Get Settings
    	 *
    	 * @access public
    	 * @param bool $force (default: false)
    	 * @return settings array
    	 * @since 0.1.0
    	 */
    	function get_settings( $force = false) {
    		if ( is_null( $this->settings ) || $force ) {
    			$this->settings = get_option( static::SETTINGS_KEY, $this->plugin_vars );
    		}
    		return $this->settings;
    	}
    
    	/**
    	 * Get individual setting
    	 *
    	 * @access public
    	 * @param mixed $key
    	 * @return key value (if available)
    	 * @since 0.1.0
    	 */
    	function get_setting( $key ) {
    		$this->get_settings();
    		if ( isset( $this->settings[$key] ) ) {
    			return $this->settings[$key];
    		} else {
    			return false;
    		}
    	}
    
    	/**
    	 * Set setting from array
    	 *
    	 * @access public
    	 * @param mixed $key
    	 * @param mixed $value
    	 * @return void
    	 * @since 0.1.0
    	 */
    	function set_setting( $key, $value ) {
    		$this->settings[$key] = $value;
    	}
    
    	/**
    	 * Save individual setting
    	 *
    	 * @access public
    	 * @return void
    	 * @since 0.1.0
    	 */
    	function save_settings() {
    		update_option( static::SETTINGS_KEY, $this->settings );
    	}
    
    	/**
    	 * admin_menu function.
    	 *
    	 * @access public
    	 * @return void
    	 * @since 0.1.0
    	 */
    	function admin_menu() {
    
    		foreach ( $this->plugin_menus as $menu ) {
    			$hook_suffixes[ $menu['slug'] ] = add_submenu_page(
    				$menu['submenu'],
    				$menu['display_name'],
    				$menu['display_name'],
    				$this->plugin_permission,
    				$menu['slug'],
    				array( $this, 'render_page' )
    			);
    		}
    
    		foreach ( $hook_suffixes as $hook_suffix ) {
    			add_action( 'load-' . $hook_suffix , array( $this, 'plugin_load' ) );
    		}
    	}
    
    	/**
    	 * Plugin Load
    	 * Tells plugin to handle post requests
    	 *
    	 * @access public
    	 * @return void
    	 * @since 0.1.0
    	 */
    	function plugin_load() {
    		$this->handle_post_request();
    	}
    
    	/**
    	 * Handle Post Requests
    	 *
    	 * This saves our settings
    	 *
    	 * @access public
    	 * @return void
    	 * @since 0.1.0
    	 */
    	function handle_post_request() {
    		if ( empty( $_POST['action'] ) || 'save' != $_POST['action'] || !current_user_can( 'edit_posts' ) ) return;
    
    		if ( !wp_verify_nonce( $_POST['_wpnonce'], 'taxicons-save-settings' ) ) die( 'Cheating, eh?' );
    
    		$this->get_settings();
    
    		$post_vars = $this->plugin_vars;
    		foreach ( $post_vars as $var => $default ) {
    			if ( !isset( $_POST[$var] ) ) continue;
    			$this->set_setting( $var, sanitize_text_field( $_POST[$var] ) );
    		}
    
    		$this->save_settings();
    	}
    
    	/**
    	 * Render admin settings page
    	 * If setup is not complete, display setup instead.
    	 *
    	 * @access public
    	 * @return void
    	 * @since 0.1.0
    	 */
    	function render_page() {
    		// Not sure why we'd ever end up here, but just in case
    		if ( empty( $_GET['page'] ) ) wp_die( 'Error, page cannot render.' );
    
    		$screen = get_current_screen();
    		$view  = $screen->id;
    
    		$this->render_view( $view );
    	}
    
    	/**
    	 * Render page view
    	 *
    	 * @access public
    	 * @param mixed $view
    	 * @param array $args ( default: array() )
    	 * @return content based on the $view param
    	 * @since 0.1.0
    	 */
    	function render_view( $view, $args = array() ) {
    		extract( $args );
    		include 'view-' . $view . '.php';
    	}
    
    	/**
    	 * Render Taxicon
    	 *
    	 * This outputs the taxonomy icon associated with a specific taxonomy
    	 *
    	 * @access public
    	 * @param mixed $taxonomy
    	 * @return void
    	 */
    	public function render_taxicon ( $taxonomy ) {
    
    		// BAIL: If it's empty, or the taxonomy doesn't exist
    		if ( !$taxonomy || taxonomy_exists( $taxonomy ) == false ) return;
    
    		$filename = $this->get_setting( $taxonomy );
    
    		// BAIL: If the setting is false or otherwise empty
    		if ( $filename == false || !$filename || empty( $filename ) ) return;
    
    		$icon     = file_get_contents( static::IMAGE_PATH . $filename  . '.svg' );
    		$taxicon  = '<span role="img" class="taxonomy-icon ' . $filename . '">' . $icon . '</span>';
    
    		echo $taxicon;
    	}
    
    	/*
    	 * Shortcode
    	 *
    	 * Generate the Taxicon via shortcode
    	 *
    	 * @param array $atts Attributes for the shortcode
    	 *        - tax: The taxonomy
    	 * @return SVG icon of awesomeness
    	 */
    	function shortcode( $atts ) {
    		return $this->render_taxicon( $atts[ 'tax' ] );
    	}
    
    }
    
    new TaxonomyIcons();
    

    File 2: view-appearance_page_taxicons.php

    Why that name? If you look at my render_view function, I pass the ID from get_current_screen() to it, and that means the ID is appearance_page_taxicons and that’s the page name.

    <div class="wrap">
    
    	<h1><?php _e( 'Taxonomy Icons', 'taxonomy-icons' ); ?></h1>
    
    	<div>
    
    		<p><?php __( 'Taxonomy Icons allows you to assign an icon to a non-default taxonomy in order to make it look damn awesome.', 'taxonomy-icons' ); ?></p>
    
    		<p><?php __( 'By default, Taxonomy Icons don\'t display in your theme. In order to use them, you can use a shortcode or a function:' , 'taxonomy-icons' ); ?></p>
    
    		<ul>
    			<li>Shortcode: <code>[taxonomy-icon tax=TAXONOMY]</code></li>
    		</ul>
    
    		<form method="post">
    
    		<?php
    		if ( isset( $_GET['updated'] ) ) {
    			?>
    			<div class="notice notice-success is-dismissible"><p><?php _e( 'Settings saved.', 'taxonomy-icons' ); ?></p></div>
    			<?php
    		}
    		?>
    
    		<input type="hidden" name="action" value="save" />
    		<?php wp_nonce_field( 'taxicons-save-settings' ) ?>
    
    		<table class="form-table">
    
    			<tr>
    				<th scope="row"><?php _e( 'Category', 'taxonomy-icons' ); ?></th>
    				<th scope="row"><?php _e( 'Current Icon', 'taxonomy-icons' ); ?></th>
    				<th scope="row"><?php _e( 'Select Icon', 'taxonomy-icons' ); ?></th>
    			</tr>
    
    			<?php
    
    			foreach ( $this->plugin_vars as $taxonomy => $value ) {
    				?>
    				<tr>
    					<td>
    						<strong><?php echo get_taxonomy( $taxonomy )->label; ?></strong>
    						<br /><em><?php echo get_taxonomy( $taxonomy )->name; ?></em>
    					</td>
    
    					<td>
    						<?php
    						if ( $this->get_setting( $taxonomy ) && $this->get_setting( $taxonomy ) !== false ) {
    							echo $this->render_taxicon( $taxonomy );
    						}
    						?>
    
    					</td>
    
    					<td>
    						<select name="<?php echo $taxonomy; ?>" class="taxonomy-icon">
    							<option value="">-- <?php _e( 'Select an Icon', 'taxonomy-icons' ); ?> --</option>
    							<?php
    							foreach ( $this->images_array as $file => $name ) {
    								?><option value="<?php echo esc_attr( $file ); ?>" <?php echo $file == $this->get_setting( $taxonomy ) ? 'selected="selected"' : ''; ?>><?php echo esc_html( $name ); ?></option><?php
    								};
    							?>
    						</select>
    					</td>
    
    				</tr><?php
    			}
    
    			?>
    
    			<tr valign="top">
    				<td colspan="3">
    					<button type="submit" class="button button-primary"><?php _e( 'Save', 'taxonomy-icons' ); ?></button>
    				</td>
    			</tr>
    		</table>
    		</form>
    	</div>
    </div>
    

    End Result

    And in the end?

    An example of Taxonomy Icons

    By the way, the image has some wrong text, but that is what it looks like.