Half-Elf on Tech

Thoughts From a Professional Lesbian

Tag: coding

  • Looping LinksWith The WP HTML Processor

    Looping LinksWith The WP HTML Processor

    Here’s your backstory.

    You need to search all the links in a post and, if the link is to a specific site (wikipedia.com) you want to add it to an array you output at the bottom of your post, as citations. To do this, you will:

    1. Search for tags for every single link (<a href=.... )
    2. If the link contains our term (Wikipedia), put it in the array
    3. If the link also has a title, we’ll use that

    If we do all that, our output looks something like this:

    • Source: https://en.wikipedia.com/wiki/foobar
    • Source: Foobar2

    While you can do this with regex, you can also use the (new) HTML Processor class to do it for you.

    RegEx

    As I mentioned, you can do this with regex (I’ll spare you the drama of coming up with this in the first place):

    $citations = array();
    
    preg_match_all( '#<\s*a[^>]*href="([^"]+)"[^>]*>.*?<\s*/\s*a>#', get_the_content(), $matches );
    
    // Loop through the matches:
    foreach ( $matches[0] as $i => $match ) {
    
        // If the URL contains WikiPedia, we'll process:
        if ( str_contains( $match, 'wikipedia.com') ) {
    
            // Build initial data:
            $current_citation =[
                'url'   => $matches[1][$i],
                'title' => $matches[1][$i],
            ];
    
            // If there's a title, use it.
            if ( str_contains( $match, 'title=' ) ) {
                  $title_match = preg_match( '#<\s*a[^>]*title="([^"]+)"*[^>]*>.*?<\s*/\s*a>#', $match, $title_matches );
                  $current_citation['title'] = ( ! empty( $title_matches[1] ) ) ? $title_matches[1] : $current_citation['title'];
            }
        }
    
         $citations[] = $current_citation;
    }
    
    ?>
    <ol>
        <?php foreach ( $citations as $citation ): ?>
            <li itemprop="citation" itemscope itemtype="https://schema.org/CreativeWork">
                 Source: <a rel="noopener noreferrer external" itemprop="url" class="wikipedia-article-citations__url" target="_blank" href="<?php echo esc_url( $citation['url'] ) ?>"><?php echo esc_html( $citation['title'] ) ?></a>
             </li>
         <?php endforeach; ?>
    </ol>
    

    This is a very over simplified version, but the basis is sound. This will loop through the whole post, find everything with a URL, check if the URL includes wikipedia.com and output a link to it. If the editor added in a link title, it will use that, and if not, it falls back to the URL itself.

    But … a lot of people will tell you Regex is super powerful and a pain in the ass (it is). And WordPress now has a better way to do this, that’s both more readable and extendable.

    HTML Tag Processor

    Let’s try this again.

    What even is this processor? Well it’s basically building out something similar to DOM Nodes of all your HTMLin a WordPress post and letting us edit them. They’re not really DOM nodes, though, they’re a weird little subset, but if you think of each HTML tag as a ‘node’ it may help.

    To start using it, we’re going to ditch regex entirely, but we still want to process our tags from the whole content, so we’ll ask WordPress to use the new class to build our our tags:

    $content_tags = new WP_HTML_Tag_Processor( get_the_content() );

    This makes the object which also lets us use all the children functions. In this case, we know we want URLs so we can use next_tag() to get things:

    $content_tags->next_tag( 'a' );

    This finds the next tag matching our query of a which is for links. If we were only getting the first item, that would be enough. But we know we have multiple links in posts, so we’re going to need to loop. The good news here is that next_tag() in and of itself can keep running!

    while( $content_tags->next_tag( 'a' ) ) {
        // Do checks here
    }
    

    That code will actually run through every single link in the post content. Inside the loop, we can check if the URL matches using get_attribute():

    if ( str_contains( $content_tags->get_attribute( 'href' ), 'wikipedia.com' ) ) {
        // Do stuff here
    }
    

    Since the default of get_attribute() is null if it doesn’t exist, this is a safe check, and it means we can reuse it to get the title:

    if ( ! is_null( $content_tags->get_attribute( 'title' ) ) ) {
        // Change title here
    }
    

    And if we apply all this to our original code, it now looks very different:

    Example:

    		// Array of citations:
    		$citations = array();
    
    		// Process the content:
    		$content_tags = new WP_HTML_Tag_Processor( get_the_content() );
    
    		// Search all tags for links (a)
    		while( $content_tags->next_tag( 'a' ) ) {
    			// If the href contains wikipedia, build our array:
    			if ( str_contains( $content_tags->get_attribute( 'href' ), 'wikipedia.com' ) ) {
    				$current_citation = [
    					'url'   => $content_tags->get_attribute( 'href' ),
    					'title' => $content_tags->get_attribute( 'href' ),
    				];
    
    				// If title is defined, replace that in our array:
    				if ( ! is_null( $content_tags->get_attribute( 'title' ) ) ) {
    					$current_citation['title'] = $content_tags->get_attribute( 'title' );
    				}
    
    				// Add this citation to the main array:
    				$citations[] = $current_citation;
    			}
    		}
    
    		// If there are citations, output:
    		if ( ! empty( $citations ) ) :
    			// Output goes here.
    		endif;
    

    Caveats

    Since we’re only searching for links, this is pretty easy. There’s a decent example on looking for multiple items (say, by class and span) but if you read it, you realize pretty quickly that you have to be doing the exact same thing.

    If you wanted to do multiple loops though, looking for all the links but also all span classes with the class ‘wikipedia’ you’d probably start like this:

    while ( $content_tags->next_tag( 'a' ) ) {
        // Process here
    }
    
    while ( $content_tags->next_tag( 'span' ) ) {
        // Process here
    }
    

    The problem is that you would only end up looking for any spans that happened after the last link! You could go a more complex search and if check, but they’re all risky as you might miss something. To work around this, you’ll use set_bookmark() to set a bookmark to loop back to:

    $content_tags = new WP_HTML_Tag_Processor( get_the_content() );
    $content_tags->next_tag();
    
    // Set a bookmark:
    $content_tags->set_bookmark( 'start' ); 
    
    while ( $content_tags-> next_tag( 'a' ) ) {
        // Process links here.
    }
    
    // Go back to the beginning:
    $content_tags->seek( 'start' ); 
    
    while ( $content_tags->next_tag( 'span' ) ) {
        // Process span here.
    }
    
    

    I admit, I’m not a super fan of that solution, but by gum, it sure works!

  • Docked Libraries and DNSMasque

    Docked Libraries and DNSMasque

    I use Docker at work because it’s what we have to use to build sites on specific servers (like WordPress VIP). Honestly, I like it because everything is nicely isolated, but it has been known to have some … let’s call them ‘quirks’ with the newer M1 and M2 chip Macs.

    You know what I have.

    And I had some drama on a Friday afternoon, because why the hell not.

    Drama 1: libc-bin

    After working just fine all day, I quit out of Docker to run something else that likes to use a lot of processing power. When I popped back in and started my container, it blew a gasket on me:

    21.39 Setting up npm (9.2.0~ds1-1) ...
    21.40 Processing triggers for libc-bin (2.36-9+deb12u1) ...
    22.74 npm ERR! code EBADENGINE
    22.74 npm ERR! engine Unsupported engine
    22.74 npm ERR! engine Not compatible with your version of node/npm: npm@10.1.0
    22.74 npm ERR! notsup Not compatible with your version of node/npm: npm@10.1.0
    22.74 npm ERR! notsup Required: {"node":"^18.17.0 || >=20.5.0"}
    22.74 npm ERR! notsup Actual:   {"npm":"9.2.0"}
    

    I was using Node 16 as a holdover from some work I was doing back at DreamHost. Of course the first thing I did was update Node to 18, but no matter what I tried, Docker would not run the right version!

    I looked at the Dockerfile and saw this section:

    # Development tooling dependencies
    RUN apt-get update \
    	&& apt-get install -y --no-install-recommends \
    		bash less default-mysql-client git zip unzip \
    		nodejs npm curl pv \
    		msmtp libz-dev libmemcached-dev \
    	&& npm install --global npm@latest \
    	&& rm -rf /var/lib/apt/lists/*
    

    When I broke it apart, it was clear than apt-get install was installing the wrong version of Node!

    Maddening. I wrestled around, and finally I added FROM node:18 to the top of my Dockerfile to see if that declare would work (after all, Docker supports multiple FROM calls since 2017).

    To my surprise, it did! Almost…

    Drama 2: PHP

    It broke PHP.

    While you can have multiple FROM calls in modern Docker, you have to make sure that you place them properly. Since node was the new thing, I put it as the second FROM call. In doing so, it overrode the PHP call a few lines down, causing the build to fail on PHP.

    Our Dockerfile is using something like the older version of the default file (I know I know, but I can only update a few things at a time, I have 4 tickets out there to modernize things, including PHPCS), I had to move the call for FROM wordpress:php8.1-fpm to right above the line where we call PHP.

    You may not have that. But if you add in that from node and it breaks PHP telling you it can’t run? That’s probably why.

    Huzzah, the build works! PHP and Node are happy … but then …

    Drama 3: UDP Ports

    Guess what happened next?

    Error response from daemon: Ports are not available: exposing port 
    UDP 0.0.0.0:53 -> 0.0.0.0:0: listen udp 0.0.0.0:53: bind: address 
    already in use
    

    I shouted “FOR FUCKS SAKE!”

    I did not want to edit the compose.yml file. Not one bit. If it works for everyone else, it should be that way.

    Thankfully, Docker has a way to override with compose.override.yml (we have a docker-compose.override.yml file, old school name, old school project). I was already using that because, for some dumb reason, the only way to get the database working was to add in this:

    services:
      db:
        platform: linux/amd64
    

    It’s not a super dumb reason, it’s a Docker vs M1 chipset reason. Still, it was annoying as all get out.

    Naturally, I assumed override meant anything I put in there would override the default. So I tossed this in:

      dnsmasq:
        ports:
          - "54:54/udp"
    

    Turns out, override doesn’t mean override when it comes to ports. I went to the documentation, and there is no mention of how to override ports. On to the Googles! A lot of searching finally landed me on an old, closed ticket that implied I could do exactly what I wanted.

    After reading that whole long ass ticket I determined the syntax is this:

      dnsmasq:
        ports: !reset
          - "54:54/udp"
    

    Finally I could get my site up and running! No idea why that isn’t documented, since the dnsmasq issue is a known compat issue with MacOS.

    Drama 4: Update All The Things

    Then I did the dumbest thing on the planet.

    I updated the following:

    • Docker
    • Everything I use from HomeBrew
    • Mac OS (only the minor – I’m not jumping to the next major release on a Friday afternoon!)

    But I have good news this time.

    Everything worked.

    Final Drama…

    The thing was, I didn’t really want to have to edit the Dockerfile. It’s a tetchy beast, and we all use different OSes (I’m M1, one person is older Mac, one is Windows). Cross compatibility is a bit issue. I did test a number of alternatives (like defining engine in package.json and even mimicking our other setups).

    At the end of the day, nothing worked except the above. No matter what, libc-bin was certain I was using NPM 10, which I wasn’t. I wish I’d found a better solution, but so far, this is the only way I could convince my Dockerfile that when you install via apt, you really want to use the latest.

  • Stopping Jerks in Gravity Forms

    Stopping Jerks in Gravity Forms

    “Hang on,” I hear you say. “Didn’t you already do this?”

    Well, kind of. I did with Ninja Forms (and actually that resulted in me removing Ninja Forms and going back to Jetpack, and isn’t that a pill?). And I’ve mentioned Gravity Forms before with disallowed keys.

    This is really an extension of the disallowed keys and some very specific complex things I’ve changed since those posts.

    Yeah, tech changes.

    What’s a “Disallowed Key”?

    Disallowed keys are basically all those terms you put in your Disallowed Comment list (formerly the Blacklist):

    A screenshot of the disallowed comment keys field from Wordpress, with a string of examples like 'foo.bar@gmail' and so on

    Now if you look at my example above, I have a bunch of terms in there. Each line is a ‘key,’ meaning @example.net is a key.

    WordPress built this in for handling comments, specifically, but I’ve long advocated for things to be global, and since this is saved in my site, I can easily re-use it by telling Jetpack or anything else “If this email is on the list of disallowed keys, I don’t want to see it.”

    The reality of doing that is, of course, harder than it looks.

    For example, if you use Gmail, then foobar@gmail.com and foo.bar@gmail.com and foobar+ihatemika@gmail.com are all the same bloody thing! And if I block generaljerkyperson do I then want to block generaljerkyperson@nonjerky.com ?

    You have to make some decisions. Now solving the email one with periods and plus signs was easy. By comparison.

    Jerky People Aren’t Spammers

    This is the other thing to get into our heads. Jerky people ain’t spammers. Spammers are, weirdly, easier to spot and handle. Someone posts about viagra? Spam. Someone posts about SEO? Probably spam (though not always on a tech site). But those are all pretty self evident once you look at them!

    What is not self-evident is something like the world I have.

    Over on a site I run, we have a group instance (Slack, Discord kind of thing) and we let people sign up via Google Sheets for a while. But we ended up with one person in the group who was an antagonist. Let’s call her Bertie.

    Bertie isn’t a terrible human, but she has “Opinions” and they are the right ones. She likes a very specific thing, and does not like variations or changes. And if you don’t like what she does, you’re wrong, she’s right, and damn you. She picks fights, constantly, she disrespects anything anyone else likes… To put it how my friend Tracy might say “She yucks on other peoples’ yums.”

    We attempted to negotiate with her. Point out “Hey you’re in a GROUP and if you cannot respect people enough to tolerate their opinions when they differ, then this ain’t the group for you.”

    Bertie didn’t. And she didn’t change. So we banned her. Fine.

    She tried to sign up via Google Sheets again. We said no. And again. No. Then she tried new emails, new bios, new fake names. One actually got in. But as soon as we learned it was her, we kicked her.

    And at this point, we were fucking tired of playing whack-a-mole. So I decided to make something better.

    Catch and Release

    My overall thought process boils down to this: Every single jerky person has tells.

    They just do. And a human can spot them and go ‘wait a second…’ because we’re actually really good at recognizing patterns that are similar. A computer has to be taught that “If the submission is from this region or has this kind of email, it’s probably them.”

    So I started to build out some logic that would check all my flagged emails and IPs. Then I took advantage of Gravity Forms’ API to make a note in the entry so I could have a record of why someone was flagged.

    Here’s what it looks like for someone who’s email and IP was on the naughty list:

    Seeing that come in overnight, by the way, delighted me. It shunted the annoying Bertie to spam for two reasons, and none of us had to deal with her.

    How it Works

    This is the code part. The down and dirty here is I have two classes, one for finding spammers, and then the Gravity Forms that calls it. I did this because at the start, I had both Jetpack and Gravity Forms AND Google Forms. Obviously I can’t block someone hitting the Google Form directly, but I used to have this prevent that from loading the form. It was easy to get around. I know I know.

    Even though I now exclusively use Gravity Forms on the site, I left these separate to be future friendly. Also it means you can steal that to put it into whatever you’re doing. It’s GPLv2.

    By the way, everything goes to spam for a simple reason: It lets me clear up false positives.

    Find Spammers

    This class is what hooks into disallowed keys and checks if the email is banned, the domain is banned, or the IP used it banned. It also has an option to check for people who are moderated! That means if you wanted to flag people who might be jerks, you can do it.

    class Find_Spammers {
    
    	/**
    	 * List of disallowed Keys
    	 *
    	 * We check for emails, domains, and IPs.
    	 *
    	 * @return array the list
    	 */
    	public static function list( $keys = 'disallowed_keys' ) {
    
    		// Preflight check:
    		$valid_keys = array( 'disallowed_keys', 'moderation_keys' );
    		$keys       = ( in_array( $keys, $valid_keys, true ) ) ? $keys : 'disallowed_keys';
    
    		// Time for the show!
    		$disallowed_keys  = array();
    		$disallowed_array = explode( "\n", get_option( $keys ) );
    
    		// Make a list of spammer emails and domains.
    		foreach ( $disallowed_array as $spammer ) {
    			if ( is_email( $spammer ) ) {
    				// This is an email address, so it's valid.
    				$disallowed_keys[] = $spammer;
    			} elseif ( strpos( $spammer, '@' ) !== false ) {
    				// This contains an @ so it's probably a whole domain.
    				$disallowed_keys[] = $spammer;
    			} elseif ( rest_is_ip_address( $spammer ) ) {
    				// IP adresses are also spammery people.
    				$disallowed_keys[] = $spammer;
    			}
    		}
    
    		return $disallowed_keys;
    	}
    
    	/**
    	 * Is someone a spammer...
    	 * @param  string  $email_address The email address
    	 * @param  string  $plugin        The plugin we're checking (default FALSE)
    	 * @return boolean                True/False spammer
    	 */
    	public static function is_spammer( $to_check, $type = 'email', $keys = 'disallowed_keys' ) {
    
    		// Default assume good people.
    		$return = false;
    
    		// Get disallowed keys & convert to array
    		$disallowed = self::list( $keys );
    
    		if ( 'email' === $type ) {
    			$email_address = $to_check;
    
    			// Break apart email into parts
    			$emailparts = explode( '@', $email_address );
    			$username   = $emailparts[0];       // i.e. foobar
    			$domain     = '@' . $emailparts[1]; // i.e. @example.com
    
    			// Remove all periods (i.e. foo.bar > foobar )
    			$clean_username = str_replace( '.', '', $username );
    
    			// Remove everything AFTER a + sign (i.e. foobar+spamavoid > foobar )
    			$clean_username = strstr( $clean_username, '+', true ) ? strstr( $clean_username, '+', true ) : $clean_username;
    
    			// rebuild email now that it's clean.
    			$email = $clean_username . '@' . $emailparts[1];
    
    			// If the email OR the domain is an exact match in the array, then it's a spammer
    			if ( in_array( $email, $disallowed, true ) || in_array( $domain, $disallowed, true ) ) {
    				$return = true;
    			}
    		}
    
    		if ( 'ip' === $type ) {
    			$ip      = $to_check;
    			$bad_ips = false;
    			foreach ( $disallowed as $nope ) {
    				if ( rest_is_ip_address( $nope ) ) {
    					if ( ( strpos( $ip, $nope ) !== false ) || $ip === $nope ) {
    						$bad_ips = true;
    					}
    				}
    			}
    
    			// If they're a bad IP, then they're a bad IP and we flag.
    			if ( false !== $bad_ips ) {
    				$return = true;
    			}
    		}
    
    		return $return;
    	}
    
    }
    
    new Find_Spammers();
    

    Gravity Forms Check

    You may have noticed that the spammer checker is really just that, a checker. You have to call it. How I call it is via a Gravity Forms function. This does a couple kind of redundant things, and I know it can be optimized.

    The IP checker has some extra stuff to help me record where an IP is from when people submit, in order to try and catch other ‘common traits.’ It’s using ip-info, and amusingly I’ve found it’s mostly right. For some reason, it got the IP location of the same IP as being from 3 separate locations. I suspect it’s Bertie trying to be smarter and use a VPN. The fact that the Location is not the only measuring stick I use though means she can change her IP and email a bunch of times, but I have other checks.

    One improvement on my list is that if someone has a certain number of red-flags, it treats it like a jerk and sends to spam.

    class My_Gravity_Forms {
    
    	public function __construct() {
    		// Check all Gravity Forms ... forms for spammers.
    		add_action( 'gform_entry_is_spam', array( $this, 'gform_entry_is_spam' ), 10, 3 );
    	}
    
    	/**
    	 * Mark as spam
    	 *
    	 * If someone on our block-list emails, auto-mark as spam becuase we do
    	 * not want to hear from them, but we don't want them to know they were rejected
    	 * and thus encourage them to try other methods. Aren't assholes fun?
    	 *
    	 * @param  boolean  $is_spam  -- Is this already spam or not?
    	 * @param  array    $form     -- All the form info
    	 * @param  array    $entry    -- All info from the entry
    	 * @return boolean            true/false if it's "spam"
    	 */
    	public function gform_entry_is_spam( $is_spam, $form, $entry ) {
    
    		// If this is already spam, we're gonna return and be done.
    		if ( $is_spam ) {
    			return $is_spam;
    		}
    
    		$spam_message = 'Failed internal spam checks';
    		$warn_message = '';
    		$is_spammer   = false;
    		$is_moderated = false;
    		$is_bot       = false;
    		$is_vpn       = false;
    
    		// Loop and find the email:
    		foreach ( $entry as $value => $key ) {
    			if ( is_email( $key ) && ! $is_spammer ) {
    				$email        = $key;
    				$is_spammer   = Find_Spammers::is_spammer( $email, 'email', 'disallowed_keys' );
    				$is_moderated = Find_Spammers::is_spammer( $email, 'email', 'moderated_keys' );
    			}
    
    			if ( rest_is_ip_address( $key ) && ! $is_spammer ) {
    				$ip           = $key;
    				$is_spammer   = Find_Spammers::is_spammer( $ip, 'ip', 'disallowed_keys' );
    				$is_moderated = Find_Spammers::is_spammer( $ip, 'ip', 'moderated_keys' );
    				$is_bot       = self::check_ip_location( $ip, 'hosting' );
    				$is_vpn       = self::check_ip_location( $ip, 'proxy' );
    			}
    		}
    
    		// If this was a bot...
    		if ( true === $is_bot ) {
    			$warn_message .= 'Likely submitted by a bot or someone scripting. ';
    		}
    
    		// If a VPN...
    		if ( true === $is_vpn ) {
    			$warn_message .= 'Using a VPN. This may be harmless, but it\'s also how people evade bans. ';
    		}
    
    		// And if it's a spammer...
    		if ( $is_spammer ) {
    			$message = $spam_message;
    
    			if ( isset( $email ) ) {
    				$message .= ' - Email ( ' . $email . ' )';
    			}
    			if ( isset( $ip ) ) {
    				$message .= ' - IP Address ( ' . $ip . ' )';
    			}
    
    			$result = GFAPI::add_note( $entry['id'], 0, 'My Robot', $message, 'error', 'spam' );
    			return true;
    		} else {
    			if ( ! empty( $warn_message ) ) {
    				$add_note = GFAPI::add_note( $entry['id'], 0, 'My Robot', $warn_message, 'warning', 'spam' );
    			}
    		}
    
    		// If we got all the way down here, we're not spam!
    		return false;
    	}
    
    	/**
    	 * IP Checker
    	 */
    	public function check_ip_location( $ip, $format = 'full' ) {
    		$return    = $ip;
    		$localhost = array( '127.0.0.1', '::1', 'localhost' );
    
    		if ( in_array( $ip, $localhost, true ) ) {
    			$return = 'localhost';
    		} else {
    			$api     = 'http://ip-api.com/json/' . $ip;
    			$request = wp_remote_get( $api );
    
    			if ( is_wp_error( $request ) ) {
    				return $ip; // Bail early
    			}
    
    			$body = wp_remote_retrieve_body( $request );
    			$data = json_decode( $body );
    
    			switch ( $format ) {
    				case 'full':
    					// Return: US - Chicago
    					$return .= ( isset( $data->countryCode ) ) ? ' ' . $data->countryCode : ''; // phpcs:ignore
    					$return .= ( isset( $data->countryCode ) ) ? ' - ' . $data->city : ''; // phpcs:ignore
    					$return .= ( isset( $data->proxy ) && true === $data->proxy ) ? ' (VPN)' : '';
    					break;
    				case 'hosting':
    					$return = ( isset( $data->hosting ) && true === $data->hosting ) ? true : false;
    					break;
    				case 'proxy':
    					$return .= ( isset( $data->proxy ) && true === $data->proxy ) ? true : false;
    					break;
    			}
    		}
    
    		return $return;
    	}
    }
    
    new My_Gravity_Forms();
    

    Any Issues?

    Two.

    1. I use an ‘approval’ feature (forked from “Gravity Forms Approvals” to allow for multiple approvers optional but only one has to approve to be a go – the original requires all approvers to approve) – for some reason this is not properly moving anything in spam or trash to a ‘rejected’ status
    2. IP-Info got a ‘different’ IP location than I see from the IP in two cases. I believe that’s due to the individual trying to juke the system and being caught anyway, but it needs some debugging.

    Oh and clearly I have some optimization I could stand to work on, but that’s for another day.

    This code is live, in production, and has been merrily blocking Bertie for some time.

  • Shlinky Dinks

    Shlinky Dinks

    For a number of reasons it was time to move on to new things. I was looking for a better, more modern solution to running my own short URLs.

    There are a lot of reasons people want these. When I started with them, it was because Twitter had limits and I wanted to control my tweets and short URLs. But then time moved on, Twitter decided to meh, not care about URL length, which meant I didn’t really need the extra weight.

    But I had a reason to keep mine around, and that’s WordCamps. 99.999% of the use of have for short URLs is to link people to things for WordCamps, like my slides but also related links that otherwise would be too long for anyone to write down in a reasonable time frame.

    And while I’d been using the same old, functional, system, it had quirks that had long since frustrated me, including not being a modern design. I felt like I was stepping back into the early 2000s, and yes, that UX matters to me.

    Enter Shlink.io

    After experimenting around, I found Shlink.io, a GDPR (yes!) friendly self-hosted URL shortener that is a little more tech, but a lot more smooth. It has a full blown API, a deep command line, and an (optional) admin that is, well, nifty.

    Features include:

    • Custom short URLs
    • Multiple Domains
    • QR Codes
    • Tags
    • Robust stats
    • Validates URLs before linking

    It’s not a set-and-forget install, to be sure, and each server is going to have some quirks, but overall I’m happy with it already.

    What’s Missing

    There’s no WordPress plugin. Yet. I suspect this will happen once people realize the API is so freaking crazy.

    There’s no way to import everything from another service, but I did a fast export of my DB and then grep’d and search/replaced so I could run commands like this:

    php bin/cli short-url:generate -c SHORT https://example.com/
    

    Done and done. Imported a few thousand URLs. I will note that most of those links don’t matter, since nearly no one hit them, but I’m just a stickler for old URLs continuing to work. Most of the time. I went back through all the failed import and found I had old links to things like test sites.

    Also the admin backend is an add-on (or non-hosted but I’m neurotic). I installed the web client at a subdomain and then used the configurator to allow passwordless logins. No, I didn’t leave it unprotected! I went old school:

    #Protect Directory
    AuthName "Dialog prompt"
    AuthType Basic
    AuthUserFile /home/ipstenu/example.com/admin/.htpasswd
    Require valid-user
    
    SSLOptions +StrictRequire
    SSLRequireSSL
    SSLRequire %{HTTP_HOST} eq "sub.example.com"
    
    ErrorDocument 403 https://example.com
    
    <Files "servers.json">
      Order Allow,Deny
      Deny from all
    </Files>
    

    What Was Messy

    The GeoLiteDB stuff was weird. It took me a while to realize I was running out of space in tmp and that was blocking me from doing anything. Since I host this VPS on DreamHost at the moment, and I work there, I went and set tmp to disk instead of memory and that magically worked.

    Now. Would I like the admin stuff to be built in and easier to manage? Of course. And would I like ‘better’ security when I use the server.json file (like maybe telling people to protect it and hide their API keys, hey) but I’ve properly opened up a ticket for them on that one.

    End Result?

    I like it. So I’m using Shlinks now and there you go.

  • Gutenberg Categories

    Gutenberg Categories

    No, this isn’t something snazzy about doing cool things with categories within Gutenberg (although you could). This is about making a section for your own blocks. A category.

    What’s a Category?

    I’m incredibly lazy, so when I use blocks, I type /head and make sure I get the right block, hit enter, and keep on typing.

    The slash command at work!

    But. Sometimes I think I need a specific block and I don’t know the name, so I click on the circle + sign and I get a list of categories!

    Block categories

    You won’t see reusable if you didn’t make some, but the point is that you could make a custom category for your custom blocks.

    Show me the code!

    Glad you asked! You’re gonna love this one.

    add_filter( 'block_categories', function( $categories, $post ) {
    	return array_merge(
    		$categories,
    		array(
    			array(
    				'slug'  => 'halfelf',
    				'title' => 'Half-Elf',
    			),
    		)
    	);
    }, 10, 2 );

    Yep. That’s it! It’s an array, so you can add more and more and more as you want. Don’t go too wild, though, or you’ll make it too long.

  • Hashtag Your Jetpack with Custom Post Types

    Hashtag Your Jetpack with Custom Post Types

    The brunt of this code comes from Jeremy Herve, who was explaining to someone how to add Category hashtags prefixed to Jetpack Publicize tweets.

    That is, someone wanted to take a category for a post (say ‘How To’) and convert that into a hashtag (say #howto).

    I too wanted to do this, but like my weirdly related posts, I needed to do the following:

    1) Get the tags
    2) Check if the tag slug was the same as a post slug for a specific custom post type
    3) Output the hashtag as all one-word, no spaces, no hyphens

    Here’s The Code

    No more explaining, here’s the code.

    class Hashtag_Jetpack {
    
    	public function __construct() {
    		add_action( 'publish_post', array( $this, 'custom_message_save' ) );
    	}
    
    	public function publicize_hashtags() {
    		$post = get_post();
    
    		// If the post isn't empty AND it's a post (not a page etc), let's go!
    		if ( ! empty( $post ) && 'post' === get_post_type( $post->ID ) ) {
    			$post_tags = get_the_tags( $post->ID );
    			if ( ! empty( $post_tags ) ) {
    				// Create list of tags with hashtags in front of them
    				$hash_tags = '';
    				foreach ( $post_tags as $tag ) {
    					// Limit this to shows only.
    					$maybeshow = get_page_by_path( $tag->name, OBJECT, 'post_type_shows' );
    					if ( $maybeshow->post_name === $tag->slug ) {
    						// Change tag from this-name to thisname and slap a hashtag on it.
    						$tag_name   = str_replace( '-', '', $tag->slug );
    						$hash_tags .= ' #' . $tag_name;
    					}
    				}
    
    				// Create our custom message
    				$custom_message = 'New post! ' . get_the_title() . $hash_tags;
    				update_post_meta( $post->ID, '_wpas_mess', $custom_message );
    			}
    		}
    	}
    
    	// Save that message
    	public function custom_message_save() {
    		add_action( 'save_post', array( $this, 'publicize_hashtags' ) );
    	}
    
    }
    
    new Hashtag_Jetpack();
    

    The only ‘catch’ you may stumble on is that I’m checking against the post type of post_type_shows – just change that as you need to.

    Voila! Instant hashtags.

    Oh and if you’re wondering why I didn’t put in a check for “Is Jetpack active…” the reason is that this is adding a post meta, and doesn’t actually depend on Jetpack being active at all. Will it ‘clutter up’ your database if Jetpack isn’t active? Yes. But it won’t break your site so it’s safe enough for me.