Half-Elf on Tech

Thoughts From a Professional Lesbian

Category: How To

  • 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.

  • Stopping Jerks in Ninja Forms

    Stopping Jerks in Ninja Forms

    I don’t have a spam problem, I have a jerky people problem. I have people who, no matter how many times I explain I cannot help them, or I don’t want to talk to them, will continue to email.

    Right now, I have some absolute weirdo in Europe who emails me every day via a contact form. I don’t know what the heck he’s thinking, but I do not need advice about how to live my life nor can I help him talk to a celebrity. The problem though is I can’t delete the form. I can (and did) set his email to auto-bin via my mail server, but he still fills the form in and I am just tired of cleaning this up.

    This site happens to use Ninja Forms, and really what I want to do is auto-cycle his emails to the bin so he can rant all he wants and never knows I don’t see a thing.

    (Note: This is not the same person as my serial harasser.)

    Warning: Their Documentation is Rough

    The biggest headache to all this is the fact that Ninja Forms’ documentation kinda sucks. For example, you cannot search their ‘codex‘! That’s just basic level for a documentation service, and on top of that if you try googling, it wants to send you to the non-developer pages.

    Now to their credit they know this:

    Admin note: we have not been able to give this site the attention it needs or deserves for a while. Most of the Codex documentation is still applicable, but please be aware that you will find some outdated material here that will need to be adapted for Ninja Forms in its current, more modern, state. 

    But that doesn’t make it really any better for me today, and it’s been like that for a while.

    Which means thinking “I can search for how to auto-flag a submission as spam/trash!” is impossible. It doesn’t work, it doesn’t exist in current NF format, and it’s a pain to the point that I seriously considered dumping the whole plugin over this!

    Folks. I know documentation is incredibly hard, but if you want people to make plugins to extend yours, and thus help make you even more popular, hire someone to do this. It’s only gonna get harder as time goes on.

    The Initial Code

    The first step is, of course, can I even do this, and of course I can:

    <?php
    /**
     * Prevent anyone from my blocklist from spamming me.
     */
    
    // Exit if accessed directly.
    defined( 'ABSPATH' ) || exit;
    
    
    class FLF_NinjaForms {
    
    	/**
    	 * List of disallowed emails
    	 *
    	 * We omit anything that isn't an email address or has an @ in the string.
    	 *
    	 * @return array the list
    	 */
    	public static function list() {
    		$disallowed_emails = array();
    		$disallowed_array  = explode( "\n", get_option( 'disallowed_keys' ) );
    
    		// Make a list of spammer emails and domains.
    		foreach ( $disallowed_array as $spammer ) {
    			if ( is_email( $spammer ) || ( strpos( $spammer, '@' ) !== false ) ) {
    				// Anything with an @-symbol is probably an email, so let's trust it.
    				$disallowed_emails[] = trim( $spammer );
    			}
    		}
    
    		return $disallowed_emails;
    	}
    
    	/**
    	 * On load.
    	 */
    	public function __construct() {
    		add_filter( 'ninja_forms_submit_data', array( $this, 'comment_blocklist' ) );
    	}
    
    	/**
    	 * Ninja Forms: Server side email protection using WordPress comment blocklist
    	 * https://developer.ninjaforms.com/codex/custom-server-side-validation
    	 *
    	 * @param array $form_data Form data array.
    	 * @return array $form_data email checked form data array.
    	 */
    	public function comment_blocklist( $form_data ) {
    		$disallowed = self::list();
    
    		foreach ( $form_data['fields'] as $field ) {
    			// If this is email, we will do some playing.
    			if ( 'email' === $field['key'] ) {
    				$email_address = sanitize_email( strtolower( $field['value'] ) );
    
    				// 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 = ( false !== strpos( $clean_username, '+' ) ) ? 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 ) ) {
    					$form_data['errors']['fields'][ $field['id'] ] = 'Error: Invalid data.';
    				}
    			}
    		}
    		return $form_data;
    	}
    
    }
    
    new FLF_NinjaForms();
    
    

    This code takes the email, strips out any periods (since Google allows you to put those in anywhere in your username) and then also removes anything after a + sign (since… Google lets you add in random whatever after a + sign) and builds a sanitized email. Then it checks that email on my block list. It also checks if I banned the domain.

    The Problem

    The only problem?

    				// 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 ) ) {
    					$form_data['errors']['fields'][ $field['id'] ] = 'Error: Invalid data.';
    				}
    

    That tells them “Error: Invalid Data” for the email. Which will suggest to them to try something else.

    I don’t want that!

    So I thought what if I changed that error to this:

    $form_data['fields']['is_spam'] = true;
    

    Which sets a new field for me! Is spam.

    The only problem? Well I thought I could use that with ninja_forms_after_submission to then say “Submission is in but we are going to treat it as trash and not email it.” Ever tried to look up ‘don’t email Ninja Forms’? Or any form? Yeah, all you get it help if Ninja Forms isn’t sending email.

    Then I thought I could set it as actual spam, but Ninja Forms has the most useless advice:

    If you’ve used all the methods above and you still receive spam submissions, maybe it’s time to change your hosting provider. Ideally, they can help you minimize spam and provide you a web application firewall to keep those spambots off your website.

    My host isn’t the issue. This jerk is. And both Jetpack and Gravity Forms have an _is_spam filter/action you can hook. I find it very odd that native mark-as-spam isn’t a think in Ninja Forms, and honestly it’s putting more fuel to the ‘change tools’ fire. This is some basic stuff, ain’t it? If you can hook into Aksimet and have it catch spam, why can’t you mark as spam and send that data back to make everyone’s life better?

    Is this the end?

    Well. For now it is. For next it’s not. I think the real answer will be to create an action like they have for Akismet, and in there rebuild my spam tool.

    Maybe as a plugin for all.

    But for right now, I actually turned off the forms entirely. No more contact form. The only person really using it was that yahoo, and instead I have the email address up there for now. Likely I’ll go back to Jetpack for a while, or maybe write a whole, complex, add on plugin. Later.

  • CLI Like a Webhost

    CLI Like a Webhost

    For the last ten years, I worked for DreamHost, which meant I had access to a lot of awesome commands that everyone ran to diagnose things.

    Well now I’m gone and I’m still a webadmin for my domains. And I have, as you all know, a weird guy who keeps going after me. I also have been running fandom sites for longer than WordPress has existed. I’ve had to learn a lot of tricks to sort out ‘Is this person so-and-so again?!’

    Now… I’m going to tell you a secret. You ready? Okay, most of those scripts hosts run? They’re just cleaned up shell commands you run on the server via command line (aka command line interface aka cli). And those commands? They’re actually pretty common, well known, and public.

    So here are some of the ones I use and why!

    Before You Begin…

    I have to step back a moment.

    Do you know where your log files are? DreamHost posted in their KB how you do that, but you will want to check your hosts:

    There are three caveats, and I know one is weird.

    1. Logs rotate

    Server space matters, so logs are regularly deleted to prevent your data from killing things.

    Right now I see this:

    -rw-r--r-- 1 root      root      3.5M Sep 16 09:08 access.log
    lrwxrwxrwx 1 root      root        21 Sep 16 00:49 access.log.0 -> access.log.2022-09-15
    -rw-r--r-- 1 username  server    1.6M Sep 12 00:51 access.log.2022-09-11.gz
    -rw-r--r-- 1 username  server    1.4M Sep 13 00:54 access.log.2022-09-12.gz
    -rw-r--r-- 1 username  server    1.6M Sep 14 00:11 access.log.2022-09-13.gz
    -rw-r--r-- 1 root      root      9.7M Sep 15 00:21 access.log.2022-09-14
    -rw-r--r-- 1 root      root       11M Sep 16 00:49 access.log.2022-09-15
    

    Tomorrow I’ll loose the 9-11 log.

    2. You need to know what your logs look like

    Every host tweaks the format of apache logs in a different way. You’ll see I use things like print $1 in my code, and for me I know that means “$1 is the IP address.” But that may not be what your host does.

    Look at the logs:

    192.0.114.84 - - [16/Sep/2022:00:49:05 -0700] "GET /wp-content/uploads/2019/10/Pure.jpg HTTP/1.1" 200 257552 "-" "Photon/1.0"

    And then count things. IP is , URL is , and so on.

    It can be a pain so please feel free to experiment and mess with it to get exactly what you want.

    3. You may need to use http logs for everything

    This is specific to DreamPRESS (the managed WP hosting) and is the weird thing, you always have to use the http folder even if you use https.

    Why? Well that has to do with how the server processes traffic. DreamPress (as of the time of this post) uses Varnish to cache and Nginx as an SSL proxy. That means when you go to https://example.com the server has nginx check the HTTPS stuff and passes it to Apache, which runs HTTP. Those logs are your apache logs, not your Nginx ones.

    Can you view the Nginx logs? Not at this time. Also they really are pass-throughs, so you’re not missing much. If you think you are, please open a ticket and tell them what you’re looking for in specific. Those help-desk folks are awesome, but the more clear you are about exactly what you’re looking for, the better help you get.

    Okay! On with the show!

    Top IPs

    Sometimes your site is running super slow and you want to know “Who the heck is hitting my site so much!?”

    awk '{ print $1}' access.log | sort | uniq -c | sort -nr | head -n 10
    

    This command will list the top 10 IPs that hit your site. I find this one super helpful when used in conjunction with an IP lookup service like IPQualityScore, because it tells me sometimes “Hey, did you know Amazon’s bots are hitting the heck out of your site!?”

    You can change that 10 to whatever number of top IPs you want to look for. That tends to be enough for me.

    If you know you have a lot of ‘self’ lookups (like you wrote something that has your server do a thing) you’ll want to try something like this to exclude them:

    awk '{print $1}' access.log | grep -ivE "(127.0.0.1|192.168.100.)" | sort | uniq -c | sort -rn | head -10
    

    Sometimes you just want to know what pages are being hit, right?

    Remember how I said you actually need to know what your log looks like? For me, $7 is the 7th ‘item’ in my access log:

    192.0.114.84 - - [16/Sep/2022:00:49:05 -0700] "GET /wp-content/uploads/2019/10/Pure.jpg HTTP/1.1" 200 257552 "-" "Photon/1.0"

    Counting is weird, I know, but the 7th is ‘/wp-content/uploads…’ so I know that the command has to use $7. BTW Photon there just means I use WordPress’s image stuff via Jetpack.

    awk '{print $7}' access.log | grep -ivE '(mod_status|favico|crossdomain|alive.txt)' | grep -ivE '(.gif|.jpg|.png|.js|.css)' | \
     sed 's/\/$//g' | sort | \
     uniq -c | sort -rn | head -25
    

    That returns a unique list:

        862 /xmlrpc.php
        539 /wp-admin/admin-ajax.php
        382 /wp-login.php
         75 /wp-cron.php?doing_wp_cron

    And it’s not a shock those are the high hits. Nice try folks. I use things to protect me. But before we get into that…

    IPs Hitting a Specific Page

    Now let’s say you’re trying to figure out what numb nut is hitting a specific page on your site! For example, I have a page called “electric-boogaloo” and I’m pretty sure someone specific is hammering that page. I’ll do this:

    awk -F'[ "]+' '$7 == "/electric-boogaloo/" { ipcount[$1]++ }
        END { for (i in ipcount) {
            printf "%15s - %d\n", i, ipcount[i] } }' access.log
    

    That spits out a short list:

       12.34.56.789 - 3
      1.234.567.890 - 4

    It’s okay that the command spans multiple lines. Check those IPs and you might find your culprit.

    What ModSecurity Rule Hates Me

    I have a love/hate relationship with ModSecurity. My first WP post (not question) in the forums was about it. It’s great and protects things, especially when you tie it into IPTables and have it auto-ban people… Until you accidentally block your co-editor-in-chief. Whoops!

    For this one, you’ll need to ask the person impacted for their IPv4 address. Then you can run this:

    zgrep --no-filename IPADDRESS error.log*|grep --color -o "\[id [^]]*\].*\[msg [^]]*\]"|sort -h|uniq -c|sort -h
    

    That will loop through all the error logs (on DreamHost they’re in the same location as the access logs) and tell you what rules someone’s hitting. Then you can tweak the rules.

    Of course, if you’re not the root admin, you’ll want to ping your support reps with “Hey, found this, can you help?” They usually will.

    Don’t feel bad about this, and don’t blame the reps for this. ModSecurity is constantly changing, because jerks are constantly trying to screw with your site for funzies and profit (I guess). Every decent host out there is hammering the heck out of their rules constantly. They update and tweak and change. Sometimes when they do that, it reveals that a rule is too restrictive. Happens all the time.

    Long Running Requests

    Another cool thing is “What’s making my site slow” comes from “What processes are taking too long.”

    awk  '{print $10,$7}' access.log | grep -ivE '(.gif|.jpg|.jpeg|.png|.css|.js)'  | awk '{secs=0.000001*$1;req=$2;printf("%.2f minutes req time for %s\n", secs / 60,req )}' | sort -rn | head -50
    
    

    That gets me the 25 top URLs. For me it happened to list MP4s so I added that into my little exclusion list where .gif etc are listed.

    Who’s Referring?

    A referrer is basically asking “What site sent people here.”

    awk '{print $11}' access.log | \
     grep -vE "(^"-"$|/www.$host|/$host)" | \
     sort | uniq -c | sort -rn | head -25
    

    This one is a little weird to look at:

      15999 "-"
         31 "www.google.com"
          8 "example.com"
          4 "binance.com"

    The ‘example.com’ means “People came here from here” which always confuses me. More impressive is that top one. It means “People came here directly.” Except I know I’m using Nginx as a proxy, so that’s likely to be a little wonky.

    What are your favourite cli tools?

    Do you have one? Drop a line in the comments! (Be wary about posting code, it can get weird in comments).

  • Open Dungeons

    Open Dungeons

    I play a D&D game. We recently transitioned into playing in person (yay vaccines!) and after much discussion, we have acquired a 3D printer.

    Blame my friend Jan.

    Anyway. One of the things I wanted to build, besides maybe minis, was the modular dungeon! I knew about the brilliant Devon Jones and his OpenForge dungeons, and recently he’s pushed out a version 2. This was exactly what I wanted and needed. The problem … all the tutorials were for version 1. So I had to sit down and figure out what I really needed.

    What’s OpenForge?

    OpenForge is an open source (heh) dungeon set for D&D which lets you build…. oh this:

    Example build by the creator – Devon Jones

    Originally we used images on a screen, or (badly) drawn lines on a mat. And all my D&D life, we used graph paper and ‘theatre of the mind.’ We’ve also been playing online for almost a year, and now that we’re in person, I find myself wanting minis and a set for people too SEE the evil that is in my mind.

    What Connections

    There are three main types of connections:

    • Magnets – Most common, it lets things be compatible with Dwarven Forge
    • OpenLock – this is a kind of lego-esque clip connection from Printable Scenery. It’s open source.
    • Dragonbite – Proprietary (licensed) but compatible with their system
    • Infinitylock – Compatible with the DungeonWorks system
    • Glue – … As it says.

    Magnets are a little weirdly expensive, since you have to buy the magnets to go in. Glue is messy. What I wanted was OpenLock — it’s more stable and my supplies are upstairs.

    Originally I was really anti-glue, but as time went on, I came to appreciate it. Especially since I can throw things in the freezer to separate them! But day to day I wanted to be able to click things together like lego and change up sets when needed.

    Sets

    The next bit is where I got miles of miles confused. Here are the recommended starter sets for the Stone setup. I figured stone is 90% of what I’ll be using for now (there are other places where we may be on mats, like for the beach or wood battles (yes, beware players! More adventures are coming!):

    Stone Floor

    • 16 dungeon_stone_floor.inch.E
    • 8 dungeon_stone_floor.inch.F
    • 2 dungeon_stone_floor.inch.R
    • 4 dungeon_stone_floor.inch.U

    Stone Walls

    • 20 dungeon_stone_wall.inch.A
    • 4 dungeon_stone_wall.inch.BA
    • 8 dungeon_stone_wall.inch.G
    • 2 dungeon_stone_wall.inch.IA
    • 4 dungeon_stone_floor.inch.Q
    • 4 dungeon_stone_column.inch.I.stl
    • 8 dungeon_stone_column.inch.L.stl
    • 4 dungeon_stone_column.inch.O.stl
    • 4 dungeon_stone_column.inch.X.stl
    • 4 dungeon_stone_column.inch.T.stl

    If you look at that, though, you wonder what the heck E, F, R and U are. Oh and then there’s Openforge vs Triplex!? And some of those files have SIDE in there!?!

    Bases

    First, we’ll cover the bases. Depending on how you want to connect, depends on the base you want. The one we’re going to use is triplex – which has many openlock ports, one per edge between squares and one per square. So here’s what Triplex looks like:

    The non Triplex is just a ‘topper’ which you will then glue or magnet on to your bases (hence why I came to love the glue). The Triplex comes with holes on the sides, which are for using OpenLock clips! That said, you will need to prep your printed whatnots for the clips. Which is also a pain in the ass.

    This means I looked at the toppers and then I looked at the bases. There were a lot of options, and I finally settled on the one that had the least amount of work for me: OpenForge 2.0 Plain OpenLOCK Base

    Related to that, the ‘side’ versions of walls are ones with click holes on the sides, as well as front/back.

    OpenLock

    I like this. It’s a double ended clip you stick in the holes in the above. They look like this:

    Photo of Springy OpenLock Clip by Marcel Toele

    Stick ’em in the hole, click and done. Now if you don’t want to use the clips, you can put magnets in some of those holes. If you’re using OpenLock, you want about 2 clips per tile you print, so 54 for the starters. For magnets you’ll need 256. Big difference!

    Many people recommend the Springy OpenLock Clip by Marcel Tool, who makes a loose, medium, and firm springy version (he recommends the medium). For whatever reason, I could not get it to build properly reliably (sometimes it was okay), so I used the ones made by Jones and the official ones until I could figure out why my slice was wrong (I’m very new at this!).

    Letters

    Now! What the bell end does “dungeon_stone_wall.inch.A” mean?

    Well after some serious digging I found the documentation about filename! [Texture]#[Shape]+[Shape Options].[Letter].[Connection system]+[Options].stl

    Now… that’s the new naming convention, but as you can see on Thingiverse, the names aren’t that. But! That clued me in to the fact that those letters, A and AS and so on all relate to the OpenLock code!

    This is made worse by the ThingiVerse display:

    If you hover over the name, you can see the whole name but you cannot click on the small picture and see the big one. I had to turn off ‘max width’ to get the full names, and everything looks like this:

    • dungeon_stone_floor.inch.AS.openforge.stl
    • dungeon_stone_floor.inch.AS.triplex.stl

    Obviously I don’t need openforge AND triplex. But that still was a pain in the butt to get the list of. And those tiny photos meant I was going mad to figure out what the hell was what Thankfully someone else felt my pain, and with that I made a key!

    Keys

    FilenameSize#
    AS3″ x 1″ rectangle (wide)
    E2″ x 2″ square16
    EA3″ x 3″ square
    F2″ x 2″ curve/V8
    I1″ x 1″ square
    R2″ x 4″ rectangle2
    S1″ x 2″ rectangle (tall)
    SA1″ x 3″ rectangle
    SB1″ x 4″ rectangle
    U4″ x 4″ square4
    # means the recommended number to print from the starter set

    That makes a lot more sense, right? I tend to use sprawling dungeons and large rooms (they’re fighting in the castle as I started all this) it would work for me. Except for the 8 F’s. I don’t use curves like that because I’m hand-making maps and I hate curves. I can’t make straight lines.

    Walls are a little weirder since a number of items have ‘side’ options and ‘pegs’ options.

    FilenameSizeSidePegs#
    A0.5″ x 2″YesYes20
    B0.5″ x 1.5″YesNo4
    G0.5″ x 2″ x 2″ curved cornerYesYes8
    IA0.5″ x 1″YesYes2
    Q0.5″ x 4″YesYes4
    I1″ x 1″NoNo4
    L1″ x 1″NoNo8
    O1″ x 1″NoNo4
    X1″ x 1″NoNo4
    T1″ x 1″NoNo4
    # means the recommended number to print from the starter set

    You’ll notice new columns!

    • Pegs is if there’s an alt version with pegs for making a second layer (not needed for starters)
    • Sides is if there’s an alt version with clip slots on the sides

    All columns are 1″x1″, but have pegs in different places.

    What did I Print?

    The weird part about this was figuring out if I really wanted Triplex (supports all three) or just plain bottoms and glue on tops… I will likely use the same floors over and over and over again. And I don’t plan on stacking layers…. Yet. Not until they have fights in a house. Otherwise I’ll pop upstairs, get the next set, and pop back down and have them scream their delight at me.

    So what did I end up building?

    • 54+ OpenLock clips — these are a mix of springy and official and Devon’s
    • 36 2×2 bases (E) — these are a mix of topless (10) and the old plain ones (26)
    • 16 2×2 curved bases (G) – topless
    • 2 2×4 bases (R) — topless
    • 8 4×4 bases (U) — topless
    • 16 cut stone 2×2 toppers (E)
    • 20 cut stone 2×2 walls (G)
    • 20 cut stone 2×2 wall floor (G)
    • 4 cut stone 4×4 toppers (U)
    • 4 cut stone 4×4 wall floor (U)
    • 4 cut stone 4×4 walls (U)
    Example of how to do an interior hallway by Manfred G

    You’ll notice things are more than they recommended. Once I went with toppers, while it does mean a lot of gluing, it also let me use the new Wall Floor system, which obviates the drama of a wall being a half inch. Instead of having a half-inch gap between wall and the floor behind it (like if you’re doing internal walls), you can make a wall that takes up a half inch, and then a floor for an inch and a half.

    The example image I included is weird, I know, but basically those are TWO towne_wall.floor.inch.2x1, which are both 1 inch wide. Then two walls which are each a half inch for a total of three inches. Plug them onto an AS or SA (3 inches by 1 inch) and you have a hallway!

    Since I’m building a specific campaign, I mathed out what I needed to mimic the boss fight. In the end, I decided to change the size a little from paper map to cardboard to real, to compensate for what I was doing.

    I had to wait to post this until (a) the printer showed up and (b) I had successfully built the set. My wife knew I was doing this. The others did not. And due to the time crunch we did not paint them. Still… it worked! Here are some of the ones I’ve made (or am making) and a cardboard to compare:

    So far, it’s all been well received.

  • oEmbedding Hugo

    oEmbedding Hugo

    No, not Hugo …

    Hugo Weaving as "Mitzi Del Bra" from "The Adventures of Priscilla, Queen of the Desert"

    No no. Hugo

    HUGO - logo to the app.

    I’ve been using Hugo to power a part of a website for quite a while now. Since 2015 I’ve had a static site, a simple library, with around 2000 posts that I wanted to be a static, less-possible-to-be-hackable site. It’s purpose was to be an encyclopedia, and as a Hugo powered site, it works amazingly.

    But… I do use WordPress, and sometimes I want to link and embed.

    Integrations Are Queen

    It helps to have a picture of how I built things. Back in *cough* 1995, the site was a single HTML page. By 1996 it was a series of SHTML (yeah) with a manually edited gallery. Fast-forward to 2005 and we have a gallery in the thousands and a full blown wiki.

    Now. Never once did I have integrated logins. While I love it for the ease of … me, I hate it from a security standpoint. Today, the blog is powered by WordPress and the gallery by NetPhotoGraphics and the ‘wiki’ by Hugo (I call it a library now). Once in a while I’ll post articles or transcripts or recaps over on the library and I want to cross link to the blog to tell people “Hey! New things!”

    But… from a practical standpoint, what are the options?

    1. A plain ol’ link
    2. A ‘table’ list of articles/transcripts/etc by name with links
    3. oEmbed

    Oh yes. Option 3.

    oEmbed and Hugo is Complex

    Since Hugo is a static HTML generator, you have to create faux ‘endpoints’ and you cannot make a dynamic JSON generator per post. Most of the things you’ll find when you google for oEmbed and Hugo is how to make it read oEmbed (like “adding a generic oEmbed handler for Hugo“). I wanted the other way, so I broke down what I needed to do:

    1. Make the ‘oembed’ JSON
    2. Make the returning iframe
    3. Add the link/alternate tag to the regular HTML

    Unlike with NetPhotoGraphics, wherein I could make a single PHP file which generated the endpoints and the json and the iframe, I had to approach it from a different angle with Hugo, and ask myself “How do I want the ‘endpoints’ to look?

    See you actually can make a pseudo endpoint of example.com/json/link/to/page which would generate the iframe from example.com/link/to/page and then example.com/oembed/link/to/page but this comes with a weird cost. You will actually end up having multiple folders on your site, and you’d want to make an .htaccess to block things.

    This has to do with how Hugo (and most static site generators) make pages. See if I wanted to make a page for ‘about’, then I would go into /posts/ and make a file called about.md with the right headers. But that doesn’t make a file called about.html, it actually makes a folder in my public_html director, called about with a file in there named index.html — that’s basic web directory stuff, though.

    But Hugo has an extra trick, which allows you to make custom files. Most people use it to make AMP pages and they explain the system like this:

    A page can be output in as many output formats as you want, and you can have an infinite amount of output formats defined as long as they resolve to a unique path on the file system. In the above table, the best example of this is AMP vs. HTMLAMP has the value amp for Path so it doesn’t overwrite the HTML version; e.g. we can now have both /index.html and /amp/index.html.

    Except… your ‘unique path’ doesn’t have to be a path! And you can customize it to kick out differently named files. So instead of /index.html and /amp/index.html I could do /index-amp.html in the same location.

    So that means my options were:

    1. A custom folder (and subfolders) for every post per ‘type’ of output
    2. Subfiles in the already existing folder

    I picked the second and here’s how:

    Output Formats

    The secret sauce for Hugo is making a new set of output formats.

    outputFormats:
      iframe:
        name: "iframe"
        baseName: "iframe"
        mediaType: "text/html"
        isHTML: true
      oembed:
        name: "oembed"
        baseName: "oembed"
        mediaType: "application/json"
        isPlainText: true

    By omitting the path value and telling it that my baseName is iframe and oembed, I’m telling Hugo not to make a new folder, but to rename the files! Instead of making /oembed/index.html and /oembed/about/index.html I’m making /about/oembed.html!

    Boom.

    The next trick was to tell Hugo what ‘type’ of content should use those new formats:

    outputs:
      home: [ "HTML", "JSON", "IFRAME", "OEMBED" ]
      page: [ "HTML", "IFRAME", "OEMBED" ]
      section: [ "HTML", "IFRAME", "OEMBED" ]

    Home also has a JSON which is something I use for search. No one else needs it.

    New Template Files

    I’ll admit, this took me some trial and error. In order to have Hugo generate the right files, and not just a copy of the main index, you have to add new template files. Remember those basenames?

    • index.oembed.json
    • index.iframe.html

    Looks pretty obvious, right? The iframe file is the HTML for the iframe. The oembed is the JSON for oembed discovery. Those go right into the main layouts folder of your theme. But… I ended up having to duplicate things in order to get everything working and that meant I also made:

    • /_default/baseof.iframe.html
    • /_default/baseof.oembed.json
    • /_default/single.iframe.html
    • /_default/single.json

    Now, if you;’re wondering “Why is it named single.json?” I don’t know. What I know is if I named it any other way, I got this error:

    WARN: found no layout file for “oembed” for layout “single” for kind “page”: You should create a template file which matches Hugo Layouts Lookup Rules for this combination.

    So I did that and it works. I also added in these:

    • /section/section.iframe.html
    • /section/section.oembed.json

    Since I make heavy use of special sections, that was needed.

    The Template Files

    They actually all look pretty much the same.

    There’s the oembed JSON:

    {
      "version": "1.0",
      "provider_name": "{{ .Site.Title }}",
      "provider_url": "{{ .Site.BaseURL }}",
      "type": "rich",
      "title": "{{ .Title }} | {{ .Site.Title }}",
      "url": "{{ .Permalink }}",
      "author_name": "{{ if .Params.author }}{{ .Params.author }}{{ else }}Anonymous{{ end }}",
      "html": "<iframe src=\"{{ .Permalink }}iframe.html\" width=\"600\" height=\"200\" title=\"{{ .Title }}\" frameborder=\"0\" marginwidth=\"0\" marginheight=\"0\" scrolling=\"no\" class=\"hugo-embedded-content\"></iframe>"
    }
    
    

    And there’s the iframe HTML:

    <!DOCTYPE html>
    <html lang="en-US" class="no-js">
    <head>
    	<title>{{ .Title }} &middot; {{ .Site.Title }}</title>
    	<base target="_top" />
    	<style>
    		{{ partial "oembed.css" . | safeCSS }}
    	</style>
    	<meta name="robots" content="noindex, follow"/>
    	<link rel="canonical" href="{{ .Permalink }}" />
    </head>
    <body class="hugo hugo-embed-responsive">
    	<div class="hugo-embed">
    		<p class="hugo-embed-heading">
    			<a href="{{ .Permalink }}" target="_top">{{ .Title }}</a>
    		</p>
    		<div class="hugo-embed-excerpt">
    			{{ .Summary }}...
    		</div>
    		<div class="hugo-embed-footer">
    			<div class="hugo-embed-site-title">
    				<a href="{{ .Site.BaseURL }}" target="_top">
    					<img src="/images/oembed-icon.png" width="32" height="32" alt="{{ .Site.Title }}" class="hugo-embed-site-icon"/>
    					<span>{{ .Site.Title }}</span>
    				</a>
    			</div>
    		</div>
    	</div>
    </body>
    </html>
    
    

    Note: I set summaryLength: 10 in my config to limit the summary to something manageable. And no, you’re not mis-reading that, the library generally has no images.

    And then in my header code for the ‘normal’ html pages:

    	{{ if not .Params.notoembed }}
    	{{ "<!-- oEmbed -->" | safeHTML }}
    	<link rel="alternate" type="application/json+oembed" href="{{ .Permalink }}/oembed.json"/>
    	{{ end }}
    

    I wanted to leave a way to say certain pages were non embeddable, and while I’m not using it at the moment, the logic remains.

    Does it Float Work?

    Of course!

    Nice, quick, to the point.

  • oEmbedding Galleries

    oEmbedding Galleries

    I use NetPhotoGraphics to handle a 2.5 gig gallery, spanning back 20 or so years. The gallery used to be a home grown PHP script, then it was Gallery, then Gallery 2, then ZenPhoto, and now NetPhotoGraphics (which ostensibly is a fork of ZenPhoto, but diverged in a way I’m more supportive of).

    Anyway. I use this gallery in conjunction with a WordPress site. I’ll post news on WordPress and link to the gallery. But for years, to do that my choices were:

    1. make a text link
    2. make a photo which is a link
    3. copy all the thumbnails over and link each one

    Those all suck. Especially the third, since you can’t (out of the box) custom link images in a gallery in WordPress and frankly I don’t like any of the plugins.

    Once upon a time, I used a ZenPhoto plugin, but it’s been abandoned for years and stopped working a while ago. I needed something that had an elegant fallback (i.e. if you uninstall the plugin) and seriously thought about forking the WordPress plugin…

    But then I had a better idea.

    Why oEmbed?

    oEmbed is an industry standard. By having your app (Flickr, Twitter, your WordPress blog) offer a custom endpoint, someone can embed it easily into their own site! WordPress has supported many embeds for a long time, but as of 2015, it’s included oEmbed Discovery. That’s why you can paste in a link to Twitter, and WordPress will automagically embed it!

    I maybe wrote an oembed plugin for another CMS so I could embed things into WordPress… Because the other option was a MASSIVE complex WP Plugin and FFS why not?

    — ipstenu (Mika E.) (@Ipstenu) September 26, 2021

    (Note: I shut down my twitter account in November ‘22 when it was taken over by a narcissist who brought back abuse.)

    I just pasted the URL https://twitter.com/Ipstenu/status/1441950326777540609 in and WordPress automagically converts it to a pretty embed. About the only social media company you can’t do that with is Facebook, who requires you to make an app (I use Jetpack for it). Anyway, point being, this is also how tools like Slack or Discord know to embed your content when you paste in a link!

    By making an oEmbed endpoint, I allow my site to become more shareable and more engageble, which is a net positive for me. If I do it right, out of the box it’ll allow anyone with a WordPress site (i.e. me) to paste in a URL to my gallery and it looks pretty! Win win!

    The NetPhotoGraphics Plugin

    Now. I’m a terrible designer, so I literally copied the design WordPress itself uses for embeds and spun up a (relatively) fast solution: oEmbed for NetPhotoGraphics.

    The code is one file (oembed.php) which goes in the /plugins/ folder in your NetPhotoGraphics install. Then you activate the plugin and you’re done. There are only one thing to customize, the ‘gallery’ icon. By default it grabs a little NPG logo, but if you put a /images/oembed-icon.png image in your gallery, it’ll use that.

    And does it work? Here’s how the first version looked on a live page:

    An example of the m

    I wanted to limit the images since sometimes I have upwards of 200 (look, episodes of CSI are a thing for me). And frankly pasting in a URL to the gallery is a lot easier than drilling down on a list of a hundred albums. This is exactly what I needed.

    Since the creation of that, I worked with netPhotoGraphics and he helped me make it better.

    One Bug and a Future

    There’s room to grow here. Thanks to S. Billard, we’ve got a lot more flexible. You can override the basic design with your own theme, you can replace the icons, and there are even options to adjust the size of the iframes. Part of me thinks it could use a nicer design, maybe a single-photo Instagram style embed instead of what I have, but that’s not my forte. Also I have yet to get around to putting in ‘share’ options. (Pull Requests welcome!)

    And yes, I know the security isn’t ‘enough’ but I wasn’t able to get it to work how I wanted due to a weird bug. You see, I did run into a rare quirk with WordPress due to how I built out the site. IF you have your gallery in a subfolder under/beside a WordPress install AND you try to embed the gallery into that WordPress site, you MAY find out WP thinks your embed is WordPress and not NPG.

    In my case, I have:

    • example.com – WordPress
    • example.com/gallery – NetPhotoGraphics

    I guess WordPress reads a little too deep into who’s WP and who’s not, which resulted in me making this WordPress filter:

    add_filter( 'embed_oembed_html', 'npg_wrap_oembed_html', 99, 4 );
    }
    
    function npg_wrap_oembed_html( $cached_html, $url, $attr, $post_id ) {
    	if ( false !== strpos( $url, '://example.com/gallery' ) ) {
    		$cached_html = '&lt;div class="responsive-check">' . $cached_html . '&lt;/div>';
    
    		$cached_html = str_replace( 'wp-embedded-content', 'npg-embedded-content', $cached_html );
    		$cached_html = str_replace( 'sandbox="allow-scripts"', '', $cached_html );
    		$cached_html = str_replace( 'security="restricted"', '', $cached_html );
    
    	}
    	return $cached_html;
    }
    
    

    Change '://example.com/gallery' to the location of your own gallery install.

    No I don’t like this either, but it was a ‘get it done’ moment. Also this is why the iframe security is lacking.