Lamest excuse ever?

I know I rail about WHMCS and their horrible culture, and I know you think I am exaggerating, but this will put your mind at rest and once again I get to be right.

I misunderstood some instructions from a customer and included some extra things in an order I placed on their behalf. Let’s pretend the total of the order was $232.50 (which it was).

This is what it looks like in the orders list

What if I told you that the order was then edited via the “proper” way to do it, a bunch of items were removed so that the total was now $55, but it still shows $232.50

Any rational business owner…nay, human, would say “there is something wrong here”…or is it me?

It must be me, because despite the order only being for $55 worth of items, WHMCS insist that it is correct. What is great about this is the support ticket exchange and the ultimate excuse for why they do it:

I had an order to the value of $232.50

The order needed to be modified so some items were deleted and the invoice modified to now be $55.

However the order s still showing the original amount...why - see attached (with a screenshot like that above)

The order record provides a static record of the details at the time of ordering. You can certainly adjust the service First Payment Amount/Recurring Amount values and the invoice directly to change how much the client is actually charged. But the order total will always reflect the total of the order at the time of ordering.

Statement of fact…I want to know why and I am beyond expecting anything rational, so I get off to a good start

That is madness beyond what is typical for WHMCS

Please explain the logic behind an order being changed but the value of the order not being changed.

The value of the invoice or services can be changed, but that doesn’t change the value at the time the order was placed. WHMCS provides a mechanism to track all these values.

Still not answering the unanswerable…I push on


Please explain under what circumstances a person might want to keep track of the value of an order "when it was placed" when the ultimate value of the order was changed?

Because I can't, for the life of me, imagine such a situation.

It is valuable to know the price your customer saw in the shopping cart and checkout pace prior to placing their order, so that you can know their price expectations and how they might react to an increase or decrease vs that price.

WHAT THE F’ING F????

Apart from not really making sense, it doesn’t make any sense at all. Here is where this is at right now…


LOL...that is the most ridiculous thing ever John. THAT is why I would want to not see the true value of an order? You are joking, right?

I know you have to struggle to defend many of WHMCS quirks, but that one is worth sharing.

They are truly bizarre, and can produce a mostly workable product, but seem to have no idea how people run a business.

How to monetise a WordPress plugin

In about 2010 I adopted an abandoned WordPress plugin that was really useful, but needed some TLC. I tried really hard to contact the original author but I am not sure they used their real name. So I forked their plugin to create an improved version – I could never have written it from scratch. Since then I have spent countless hours improving the plugin, the original author would hardly recognise it.

My plugin had always been free to use and I asked for donations. Between 2010 and 2022 I received less than $500 in donations for the “countless hours” I had put into the plugin. I actually didn’t have a problem with that. But then on two separate occasions, people asked for a complex feature to be added, promising a donation, and then never following through. It pissed me off and I felt they had taken advantage of me.

Rather than not keep working on the plugin (my first reaction), I decided it was time to get a reasonable return for my work. But finding information on how to monetise a WordPress plugin was pretty much impossible. There are a couple of commercial licensing solutions, but they are quite expensive.

I started with WooCommerce (WC) and the License Manager for WooCommerce plugin. WC is a no brainer, it works, is easy to set up. The License Manager also works really well, but it didn’t do some things I wanted, especially being able to get the license key in plain text to add to a database.

So after spending too much time trying to solve that, I decided to create my own API, then I could do whatever I wanted with the data. I am a hack PHP developer, I can do stuff, but I am sure that any “expert” would have a bit to say about my code. But I got this done and I hope my solution helps you.

My plugin is really useful for community and activist groups, and having run a (successful) campaign I know that usually money comes out of an individual’s pocket. So for me personally, it was important that there be a very usable free version as well as an incentive to upgrade.

I wanted a solution that

  • Worked
  • Is easy for the user to manage
  • Is easy for me to manage i.e. automated
  • Is difficult enough to circumvent – but nothing is impossible to get around
  • Is fairly priced and didn’t use a subscription model (which I regard as gouging)

Before getting into the details, here is an overview of the process, step by step.

  1. Customer purchases a license via WooCommerce
  2. If PayPal payment succeeds WC order is automatically processed
  3. A database is updated with license details
  4. License key is sent to customer in a custom email, not from WC or a license plugin

Then…

  1. Customer inserts key into a form in the plugin
  2. Data are submitted via a custom API
  3. Database is checked and updated if the key is valid
  4. API responds with the status of key
  5. Assuming valid, a cURL request is sent to update a file so that all features are enabled

I am going to assume you have a reasonable level of competency so I will skip mundane stuff e.g. setting up WC.

A few things have to happen when a customer places an order and pays. The first hurdle was that WC will leave an order as “processing” until it is manually changed to “complete”. Since I wanted this to be automated and instant, I had to find a way to have the status change on payment. The Autocomplete WooCommerce Orders plugin does exactly what it says.

To make it work you add a function to your theme-child functions.php – assuming you know at least as much PHP as me, this should be enough to go on.

add_action('woocommerce_order_status_completed','payment_complete');

function payment_complete($order_id)
{

// get the order and the order data
$order = wc_get_order( $order_id );
$data = $order->get_data(); 
// this is the magic
$order->update_status( 'completed' );

// generate a license key
    $characters = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'; 
    // I don't include I/L/1 and 0/O to avoid typos in some fonts = saves work for me solving this for a customer.
    $licenseKey = ""; 
    $blockLength = 4; // how many characters in a block
    $blockCount = 6; // how many blocks
    $blockDelimiter = "-";

    for($x = 0; $x < $blockCount; $x++){
        for ($i = 0; $i < $blockLength; $i++) { 
        $index = rand(0, strlen($characters) - 1); 
        $licenseKey .= $characters[$index]; 
        } 
        if( $x < ( $blockCount - 1 ) ) $licenseKey .= $blockDelimiter;
    }
    // this produces a key something like CXK8-YHVX-G9Z6-JRWS-JF78-X2N3

// set up your PDO connection here

// save the data to the verification db
$sth = $conn->prepare("INSERT INTO licenses(licenseKey, licenseName, licenseEmail, licenseOrderID, licenseCreated) VALUES(?, ?, ?, ?, ?)");

$sth->execute(array( $licenseKey, $data['billing']['first_name'] . " " . $data['billing']['last_name'], $data['billing']['email'], $data['id'], date("Ymd-H:i:s")));

// email the key to buyer
$to = $data['billing']['email'];
$subject = 'Plugin License Key';
$from = 'you@example.com';

$headers  = 'MIME-Version: 1.0' . "\r\n";
$headers .= 'Content-type: text/html; charset=iso-8859-1' . "\r\n";
$headers .= 'From: '.$from."\r\n".
'Bcc: you@example.com' . "\r\n" .
    'Reply-To: '.$from."\r\n" .
    'X-Mailer: PHP/' . phpversion();
 
$message = '<html><body>';
$message .= "<p >Hi " .$data['billing']['first_name'] . '</p>';
$message .= '<p >Your license key is:</p>';
$message .= '<h2 >' . $licenseKey. '</h2>';
$message .= '<p >Add it at <em>dashboard</em> > <em>plugin</em> > <em>settings</em> </p>';
$message .= '</body></html>';
 
  // Sending email
  if(!mail($to, $subject, $message, $headers)){
    mail("you@example.com", "Plugin Email Fail - functions.php", $to . " :: " .  $message);
  }

}

OK, so now we have our order set to completed, we have save the data to our license database and we have sent the key to the customer. So far, so good. Now we need to handle what happens when they apply the key to upgrade to your Pro version.

On a page in the plugin I have a form to enter the key

The “click here” link goes to a page with info about the license and how to order.

When they click verify it calls some jQuery

jQuery('#licenseKeyButton').click( function () {
	var licence_key = jQuery("#license_key").val();
        jQuery.ajax({
           type: "POST",
           url: "<?php echo admin_url('admin-ajax.php'); ?>",
           data: {action:'plugin_verify_key',key:licence_key},   
           cache: false,
           success: function(response){
 
                response = jQuery.parseJSON(response);
                
                if(response.status == "valid"){
                    //do some things e.g. change button to green
                }
                else{
                    // do some other things e.g. change button to red      
                } 
           }
    	}) 
    });

The most important part of this is the line data: {action:'plugin_verify_key',key:licence_key}, we are using ajax to trigger a function and send the license key.

So far, so good. But before doing anything, we need to create an API. I had never done anything like this before and I was surprised that it wasn’t too complex.

I created a subdomain verify.example.com but you can do it any way you like, simply change URLs to suit.

The most important bit to note in the code below is "returnFile"=> "mycode" where “mycode” is part of the name of your file that is being upgraded. In this case it would be mycode.php.

To explain: When someone upgrades with their key, a cURL call is made (we’ll get to that) and the contents of a file (mycode.php) are replaced with code that doesn’t have any limitations on features. But I don’t want people to easily figure out which file that is. By passing the file name in the API response it isn’t in plain sight. But I am under no illusion that this fools everyone.

Also, there is a step for where people can revoke their key. Maybe they want to move the license to a new site. I’ll leave it to you to figure out how to grab the code from the WordPress plugin SVN to replace the Pro code with the original plugin code and do some other work. It is all based on other actions you’ll find in the code here.

// stop any casual visitors
if( !isset($_GET["key"]) ){
    echo "Thanks for visiting";
    exit;
}

// set up your PDO connection here

// check that the key is valid
$sth = $conn->prepare("SELECT * FROM licenses WHERE theLicenseKey= :key ");
$sth->execute([':key' => $_GET["key"]]);

//get our status
if ($sth->rowCount() == 1) $status = "valid";
if ($sth->rowCount() == 0) $status = "key fail";
if ($sth->rowCount() > 1) $status = "multiple results";
if ($_GET["action"] == "revoke") $status = "revoke";

// At this point you may want to log whatever happens - I do, but you need to do some work yourself

//based on our status, if failed
if($status == "key fail"){ 
    echo "Thanks for visiting";
    mail("you@example.com","API error - key fail", "Key fail for  " . $_GET["key"] . " from " . $_SERVER['REMOTE_ADDR']  );
    exit;
}
elseif($status == "multiple results") {
// if the key exists multiple times in the database - this is incomplete so far, but is to prevent the same key being used on multiple sites
    echo "Thanks for visiting";
    mail("you@example.com","API error - multiple for license", "Multiple records returned for " . $_GET["key"] . " from " . $_SERVER['REMOTE_ADDR'] );
    exit;
}

// Update our license in the database - you remember it was added when the customer paid.  Now they are activating, we need to add that data to the record.

// first we check if they are revoking the key, if so, revert the data record
if(isset($_GET["action"]) && $_GET["action"] == "revoke" ){

    $sth3 = $conn->prepare("UPDATE licenses SET licenseActivated=0, licenseActivatedDate='', licenseDomain = "" , licenseIP='" . $_SERVER['REMOTE_ADDR'] . "' WHERE licenseKey= :key ");
    $sth3 ->execute([ ':key'=>$_GET["key"]]);
    
    $object = [
        "status"=> "revoked",
         "returnFile"=> "mycode"
    ];
    //send response
    header("content-type: application/json");
    // send the status and file name back
    echo json_encode($object);
    exit;
}else{
// if we get this far, success.

// update the record then do more stuff
    $sth3 = $conn->prepare("UPDATE licenses SET licenseActivated=1, licenseActivatedDate='" . date("Ymd-H:i:s") . "', licenseDomain = :domain , licenseIP='" . $_SERVER['REMOTE_ADDR'] . "' WHERE licenseKey= :key");
    $sth3 ->execute([ ':domain'=>$_GET["domain"], ':key'=>$_GET["key"]]);
}

// create our response
while ($thekeys = $sth->fetch(PDO::FETCH_ASSOC)){
    $object = [
       "id" => $thekeys['licenseID'],
        "key"=> $thekeys['licenseKey'],
        "domain"=> $thekeys['licenseDomain'],
        "status"=> "valid",
        "returnFile"=> "mycode"
    ];
}

//send response
header("content-type: application/json");
echo json_encode($object);

Whew…so now we have some code waiting to receive data, test if it is valid or not and send back a response. Let’s do that.

The function to send the data is in a separate PHP script that you can name anything, so let’s call it processkey.php.

This is where our action plugin_verify_key is triggered

// you will substitute your plugin context for "plugin"
add_action('wp_ajax_plugin_verify_key', 'plugin_verify_key');
add_action('wp_ajax_nopriv_plugin_verify_key', 'plugin_verify_key');

// notice it is passing the value of "action"
function plugin_verify_key($action) {
     
    // check if the license key was sent, or if it already exists as an option
   if(isset($_POST['key'])){
        $license_key_value = $_POST['key'];
    }
    else{
        $license_key_value = get_option( 'plugin_license_key' );
    }

    // allow for plugin upgrade, we'll get to that later
    if($action == "upgrade"){        
        $license_key_value = get_option( "plugin_license_key" );       
    }

    //if there is no license key, stop the function
    if ($license_key_value == "" ) return; 
     
    $curl = curl_init();

    // set up our API call
    $url = "https://your.api.url.com?key=" . $license_key_value ."&domain=" . $the_domain . "&action=upgrade&version=" . get_option( 'plugin-version' );
    
    curl_setopt_array($curl, array(
        CURLOPT_URL => $url,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_ENCODING => "",
        CURLOPT_MAXREDIRS => 10,
        CURLOPT_TIMEOUT => 30,
        CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
        CURLOPT_CUSTOMREQUEST => "GET",
    ));

    // get our API response
    $response = curl_exec($curl);
    curl_close($curl);
  
        //The API returns data in JSON format, so first convert that to an array 
        $responseObj = json_decode($response, true);

    if ($responseObj["status"] == "valid"){  
            //get the name of the file from the response
            $filename = $responseObj["returnFile"] ;
            //build our URL to get the Pro version file
            $incomingURL = "https://your.api.url.com/" . $filename . ".txt";
            //get the contents of that file
            $getFile = file_get_contents($incomingURL);
            // where are we going to write those contents
            $thefile = dirname(__FILE__) . "/" .$filename . ".php";
            $doWrite = fopen( $thefile , "w");
            fwrite($doWrite, $getFile); 
            fclose($doWrite);
        
        // obfuscate what has been done by touching each file in the directory
        $files = scandir(dirname(__FILE__));
        foreach($files as $file) {
          touch(dirname(__FILE__) . "/" .$file);
        }
    }

// if it is a plugin upgrade, not a new key, quit the function here
if($action == "upgrade"){ return; }

// otherwise it is a new license activation so create an array 
$response = array();

$response["status"] = $responseObj["status"];
$response["license_key"] = $responseObj["key"];
$response["licence_key_verified"] = "1";

// add some values to the wp_options table
if($response["status"] == 'valid'){
    update_option('plugin_license_key', $response["license_key"]);
    update_option('plugin_license_key_verified','1');
}

// and then send our data back to the page
echo json_encode($response);
    
     exit;
 }

As I am creating this page, I see how complex this is. That it works is impressive…to me.

So…now they have entered their license key and clicked to verify. Assuming a valid key: the jQuery code makes an ajax call to processkey.php which sends data via cURL to your.api.url.com. Based on the result of a database lookup (Does the key exist? Only once?), the database is updated, some options are set and a file is over-written with “Pro version” code. Then a response is sent back to processkey.php which passes data back to the original page. Making sense so far? If I was reading this, I am sure at this point I would have to go back and read the code again.

Back in our form we give the user a nice response after the ajax success event. I change the colour of the button to green, the text to “verified”, disable it and hide the key with “*******”. Plus I add a message thanking people for upgrading.

The last thing to cover is if people have upgraded and I am releasing a new version of the plugin. When this happens, the entire plugin is replaced with the new version, over-writing my Pro code.

I handle this in the install function

     // whenever I do an upgrade I have to remember to change the version number here
     if ( version_compare( $installed_version, '1.0.0', '<' ) == 1 ) {
        
        // if it is already verified, run the function, passing the "upgrade" value
        if( get_option( 'license_key_verified' ) == 1 ){
            plugin_verify_licence_key("upgrade");
        }
    }

Jump back to processkey.php and at about line 15 see if($action == "upgrade"){. This is inside a call to the plugins_loaded action and is run every time wordpress is loaded. But it checks version numbers.

Assuming it is a new version, after the plugin is updated there is the same call to re-write the file with the Pro version code but then the function stops running if($action == "upgrade"){ return; } before updating options or returning a response. So the Pro users have an upgraded plugin and still have all the features.

I am aware of a potential security issue and am working on digitally signing the files that are downloaded to upgrade to Pro.

The result of all this has been a spectacular success. I had an upgrade target for the first 2 months. Maybe I was being modest, but I have hit double that number.

Thanks for reading this far, I hope it has helped you, just like code others have shared helped me.

I wish you success with your plugin.

Another crack at reducing spam

There are pathological people who are quite happy to spoil something good for everyone else if it is to their advantage. The internet in general is a great example of that and spam in particular.

On a good day, I receive about twice as many spam emails as legitimate emails. On a bad day it is worse.

Add to that there are constant attacks on the server and websites by people who are trying to hack a site so it will send spam, adding to the problem, and it is an ongoing pain in the arse.

RBL SPAM filtri | DNSBL | DNS blacklist | Hitrost.com
It makes a blog post more interesting to have some sort of image, but don’t be fooled, this is grossly inaccurate. The ratio of spam to legitimate email (for me) should be reversed.

In an effort to reduce the incoming spam count I have enabled one of the RBL’s (Realtime Black Lists).

The risk is always that it causes too many false positives – marking legit email as spam – and becoming a headache in itself.

If this works, it should be obvious within a day or so. Stand by for an update 😛

Awesome customer support continues

A lot of people say a lot of nice things about the exceptional customer service at 123Host.au (you can read them there).

It all comes down to the philosophy to give the level of customer service we wish we received from others, because we all know that in general, customer service sucks.

I am always really proud of the 123Host.au support ticket response times and whenever I think it probably can’t be improved…guess what.

March 2022 – over 68% of tickets replied within 1 hour and almost 97% replied within 4 hours!

“Within 1 hour” sounds good, but there were 68 support tickets opened in March and

I might give myself a pay rise.

123Host.au is live

It has been a long time coming but finally direct .au domains are available.

As well as having 123Host.com.au or 123Host.net.au, I now have the short, sweet and cool (I think) 123Host.au

Map of Australia with .au superimposed

The process to register a direct .au domain is a little confusing, here is the (hopefully) understandable version.

If you want to register example.au you can try as long as there is no other example.??.au domains registered.

If a domain does already exist such as example.com.au then there is a specific procedure to register example.au. The first and most important thing is for the owner of the .com.au to get a token from https://priority.auda.org.au/. These tokens prove that you are the owner of the .com.au.

If you are the only person who has example.??.au then you will get the direct .au immediately.

However if someone else has example.net.au then this page https://www.auda.org.au/tools/priority-status-tool explains the process to sort out who gets the direct .au.

Should you own .com.au and .net.au then the trick is to apply for .au with one of them and decline to apply with the other.

Let’s add one more thing…if you don’t own example.??.au and want example.au and no one else applies for it. you can register it after September 20th. I can help you get to the front of the queue.

Yeah it is complex and confusing. I am wrapping my brain around it and can help, contact me at 123host.au (see what I did there?)

Why “feature request” sites suck

I use some software that mostly does the job but has some really clunky flaws. In an effort to contribute something and also help others, I often make suggestions and invariably I am referred to a feature request site where you list your request and other users vote on it to determine the popularity of an idea. The company claims this is used to determine whether or not to implement the feature.

Any company that uses a features request site and regards popularity as a measure of whether to implement a feature has a flawed business model using a flawed process.

For a start, this site is likely used by a tiny number of users of the software, so any “popularity” is based on those who likely already want a feature (why else would you visit?), care enough to request it and even know that you can.

Further, since when has popularity had any bearing on whether or not a feature is worth implementing? This is a trap for developers where they are stuck in their thinking and won’t consider a novel idea that might be a game-changer. I am not suggesting that my ideas are.

Let’s look at an example from a company that I will call WHMCS, the worst offender, in my experience.

They have recently revamped their request site. It must have been a bit embarrassing to have 7 year old ideas not being acted on despite a large number of up-votes. Someone came up with a political style solution; they hid the date :o)

Also, if they decline an idea (despite it being popular) they then hide all the comments and shut down any further comments. It is the equivalent of sticking their fingers in their ears and saying “ummm ummm ummm”.

So, two of the top 3 highly requested features have been declined i.e. popularity doesn’t really have anything to do with it at all.

The entire process is disrespectful to the very people who have kept your business alive. If your tech support people aren’t switched on enough to say “Hey, thanks for the idea, I will forward it to our developers” instead of “Add this to the feature request site and let’s see how popular it is” your tech support sucks along with your company’s culture.

The process is inherently flawed and I won’t participate.

The purpose of this post is so I have a link instead of having to type out a rant every time I want to explain how I feel about their suggestion

cPanel phishing scam

No matter who you are hosted with, please don’t be taken in by a new phishing scam trying to get your cPanel login.

It is a pretty convincing copy of a genuine notification that you have filled your disk space and has the subject WARNING The domain “(example).com.au” has reached their disk quota.

At first I thought the 123host server was sending them, so I was confused as the accounts weren’t full and the date was wonky. I eventually discovered that one of the links in the email is to a site with a fake cPanel login (the pink highlight). 

A good thing to help spot a fake, though they may fix this, is that the dates are inconsistent (yellow highlight).

Screenshot of fake cpanel email

Four customers had contacted me asking why their disk is full, in each case it wasn’t.  So this is definitely a thing.  I have since had a bunch more reports of the same thing.

You can always check how much disk space you are using in cPanel.

If you receive one of these ignore it.  If you are a 123host.com.au customer you can send it to me to double check for you if you want.

If you have received it, clicked the link and entered your cPanel login details, you need to let me (or your hosting service) know URGENTLY so your cPanel password can be changed.

Bastards!

WooCommerce oops!

A critical vulnerability has been discovered in WooCommerce prior to version 5.5 (the current version). You can read about it here, but they don’t give much info on what might happen.  I dug into the code and I think that if someone exploited this on your store, they could have access to order, customer, and administrative information via a cleverly crafted search string.

CloudLinux - CloudLinux Blog - New vulnerability discovered - the fix for  CVE-2016-8655 for CloudLinux OS 7 is here with KernelCare



It is extremely important that if you have WooCommerce installed you upgrade to 5.5.1 as a matter of urgency.  Once these vulnerabilities become public, the baddies know about and start using them.Please don’t ignore this.  And while you are at it, check that WordPress is at version 5.7.2

If you subscribe to the 123Host WordPress Management service, I have already upgraded WooCommerce for you.

WHMCS knowledgebase icon mod

Maybe, one day WHMCS will be complete. In the meantime users have to do their own modifications to make it work to suit.

In this case, the 123host knowledgebase category icons looked boring and unintuitive

I understand that these are categories that contain articles and pedantically the folder icons are correct. But the labels already tell us there is more than one article, so we can get creative without compromising the UI.

Isn’t this prettier and more intuitive?

Modifying your template is easy-peasy. Of course the file to edit depends on which template you are using. I am going to assume twenty-one and that you have a child template twenty-one23host

First thing, make a backup if you are worried, but these are template files so it is easy to roll back to the parent version.

If it doesn’t already exist in your child theme, copy /templates/twenty-one/includes/knowledgebase.tpl to your child theme /templates/twenty-one23host/includes/knowledgebase.tpl and then open that file to edit.

At about line 18 you will find

                    <span class="h5 m-0">
                        <i class="fal fa-folder fa-fw"></i>

replace it with

                   <span class="h5 m-0">
                            {if $category.name eq 'Domains'}
                                {$caticon='fa fa-globe'}
                            {elseif $category.name eq 'Email'} 
                                {$caticon='fa fa-envelope'}
                            {elseif $category.name eq 'Hosting'} 
                                {$caticon='fa fa-server'}
                            {elseif $category.name eq 'Security'} 
                                {$caticon='fa fa-lock'}
                            {elseif $category.name eq 'Setup'}
                                {$caticon='fa fa-cog'}
                            {elseif $category.name eq 'WordPress'} 
                                {$caticon='fab fa-wordpress'}
                            {/if}
                              
                            <i class="{$caticon}" aria-hidden="true"></i>

We have used Smarty to do some string comparisons and set the fontawesome icon based on the result. Notice that the WordPress icon needs fab, the others don’t. Also notice that we have improved accessibility by adding aria-hidden="true" to hide the icon from machine readers – there’s no need to bog them down with decorative stuff – take note WHMCS (they won’t).

You may have more or less categories and will need to tweak it accordingly. This works and isn’t hard to maintain when a new category is added.

Grab the fontawesome icon code from https://fontawesome.com/v5.9/icons