Categories
WordPress

Prettier multisite upload directories

One of my frustrations with WordPress multisite is how ugly the upload directories get. I know, most people don’t see these upload URLs… but I do see them and they bother me. In our domain mapping post we described one approach to handling this. In that case we used a PHP function to get WordPress to look at a different directory. But another approach is to allow WordPress to use the directories it knows, but rewrite the URLs with Nginx. We had to do this recently for an older multisite network still using ms-files.php internally.

In this case we have multisite set up as a network using subdirectories for the subsites. Since the subdirectories are already being used for regular content, we thought that using /subsite/files would make sense for the upload directories. So an image’s URL might be https://hostname.org/subsite/files/2021/05/image.jpg.

We put the nginx rewrite rule into our /server/tenseg.conf file:

rewrite ^/([_0-9a-zA-Z-]+/)?files/(.+)$ /wp-includes/ms-files.php?file=$2 last;

This transforms a URL like https://site.org/subsite/files/image.jpg into https://site.org/wp-includes/ms-files.php?file=image.jpg.

Really, we might want to consider dumping ms-files altogether.

Categories
Linode SpinupWP

Be your own host

This month I offered to demo SpinupWP and Linode for our local MSPWP WordPress meetup. The slides from that presentation are available here, as are the other links I shared. My apologies for the awkward minutes when the first part of the demo failed!

As emphasized in the presentation, using SpinupWP to manage a cloud server like those at Linode is a great solution if you are looking for control of your WordPress infrastructure. But it does demand more attention from you than shared hosting or managed WordPress hosting.

Categories
AWS

S3 Redirects

When moving our Tenseg site to S3 we overlooked some redirects that were in an htaccess file. Most of these we could fix with Jekyll front matter and plugins, but there were two that really needed conditional redirection of a more sophisticated sort. For these we ended up creating some redirection rules in S3 itself.

In the “Static website hosting” property of the S3 bucket we added some XML redirection rules.

<RoutingRules>
	<RoutingRule>
		<Condition>
			<KeyPrefixEquals>archives/date/</KeyPrefixEquals>
		</Condition>
		<Redirect>
			<ReplaceKeyPrefixWith>blog/</ReplaceKeyPrefixWith>
			<HostName>www.tenseg.net</HostName>
		</Redirect>
	</RoutingRule>
	<RoutingRule>
		<Condition>
			<KeyPrefixEquals>archives/category/</KeyPrefixEquals>
		</Condition>
		<Redirect>
			<ReplaceKeyPrefixWith>blog/category/</ReplaceKeyPrefixWith>
			<HostName>www.tenseg.net</HostName>
		</Redirect>
	</RoutingRule>
</RoutingRules>

These rules mean that any reference to the /archives/date directory was turned into a reference to the /blog directory, and any reference to the /archives/category directory was turned into a reference to the /blog/category directory. The odd thing was that without the explicit HostName parameter used above, the redirections were made directly into the raw bucket URL, which was very ugly.

Categories
AWS

S3 SSL

Amazon S3 itself does not do SSL for a static website, but you can get SSL from Amazon CloudFront. In theory this should not cost much more than regular S3 since Amazon does not charge for moving S3 data to CloudFront, so your S3 request charges are replaced by CloudFront request charges, but AWS charges are really hard to unravel, so I can’t be sure of that.

You can connect an S3 bucket to CloudFront in two different ways, one is pretty simple to implement, the other is a bit more complex but allows for greater security. For these examples I am assuming the S3 bucket already exists and behaves as you would want the site to behave.

In both cases you must log in to a sufficiently authorized IAM user or root user and you will need an SSL certificate configured to work with your website on S3. First, make sure you are in the us-east-1 region (“N. Virginia” in the navigation bar at the top), which is the only region with free certificates. Go to the AWS Certificate Manager and get started with provisioning a certificate. “Request a public certificate” and give it your domain name. Follow the instructions for validating your ownership of the domain. Wait for the status to change from “Pending Validation” to “Issued”, which can take a while.

Then run this “stack” to create the CloudFront instance. Once the creation of the stack is done, make note of the URL in the output tab, this is the URL you will need to use as the value of the appropriate CNAME in your DNS records for your custom domain.

Once your CloudFront distribution has been created, click on it to go to the “Distribution Settings”, then edit the “General” settings. Here you will add your domain name in the “Alternate Domain Names” and choose a “Custom SSL Certificate” which should be the same certificate you created above. Click the “Yes, Edit” button to save these changes. Note it will take a few minutes for these changes to be deployed.

One thing you may want to change is the default TTL (time to live) for your cached objects. To do so, go to your distribution settings, choose the “Behaviors” tab, choose the default behaviors, and click the “Edit” button. I usually set the minimum TTL to 0, the maximum TTL to 86400 (24 hours), and the default TTL to 180 (3 minutes). Don’t forget to save with the “Yes, Edit” button. You can increase that default once you are done debugging, but remember that any changes to your site may have to wait that long before they are seen by others.

Simple: Let S3 handle the webish stuff

If your static site really does not need security, but only needs SSL to fit into the new always SSL web environment, then you can connect S3 to CloudFront in a way that forces S3 to keep handling and resolving web requests. It is not clear to me how this impacts pricing; it may be that this method undermines the free S3 to CloudFront retrievals and results in each request generating both S3 and CloudFront charges, so keep an eye on the costs.

You will need to know your S3 endpoint URL for this method. Click on your bucket in the buckets list, then click on the “Properties” tab, click on the “Static website hosting” item, and then copy the “Endpoint” URL.

To use this method you simply have to edit the “Origins and Origin Groups” of the CloudFront distribution create by the stack above. Click the checkbox next to the only origin, then click the “Edit” button. Change the “Origin Domain Name” to the S3 endpoint URL you copied. Save this change by clicking the “Yes, Edit” button.

That’s it! Your site should now work as it did in S3, but now with SSL enabled.

Complex: Using Lambda

In order to secure your site fully, you may want to use an OAI to protect the requests between CloudFront and S3. While I won’t get into setting up OAI here, just know that if you do want to use OAI you will have to leave the S3 origin alone. This has a couple disadvantages: (1) CloudFront will not resolve naked directory requests to “index.html” and (2) CloudFront’s default error pages are very ugly.

To resolve to “index.html” automatically you will need to use an AWS Lambda function. Go to AWS Lambda and create a new function. Choose to “Author from scratch”, give it a name like defaultIndexHtml, and click the “Create function” button.

Replace the script in index.js with the following script…

'use strict';
var path = require('path');

exports.handler = (event, context, callback) => {
  var request = event.Records[0].cf.request;
  
  console.log('Request URI: ', request.uri);

  const parsedPath = path.parse(request.uri);
  var newUri = request.uri;

  console.log('Parsed Path: ', parsedPath);
  
  if (parsedPath.ext === '') {
    newUri = path.join(parsedPath.dir, parsedPath.base, 'index.html');
  }

  console.log('New URI: ', newUri);

  // Replace the received URI with the URI that includes the index page
  request.uri = newUri;
  
  // Return to CloudFront
  return callback(null, request);
};

This script will look for any requests that do not have extensions and add /index.html to them. You can use whatever default file name you like. Make sure to click the “Save” button before trying the next step.

Now you add a trigger by clicking the “+ Add trigger” button. Select “CloudFront” as the trigger configuration and then click the “Replay to Lambda@Edge” button. If you’ve moved very quickly you might get to this step before the necessary AWS role has been deployed. If you get a complaint about the role, just wait a few minutes and try again. Once the trigger is added, it can also take a few minutes for CloudFront to update and include the lambda function. Once it does, your naked directory requests should resolve properly.

To improve the error pages, you can return to your CloudFront distribution settings, click on the “Error Pages” tab, and create a custom error response. You can point to any page of your static S3 site as the landing page for errors.

A note about the cache

CloudFront does cache pages, and while I do advise that you reduce the period of the cache above, you might forget to do this or otherwise find the cache gets in the way.

You can invalidate portions of the cache in the CloudFront distribution settings from the “Invalidations” tab. However, one thing can be very hard to invalidate: an empty directory. If you happen to test a naked directory with a URL like http://aws.tenseg.net/test before you have the default index pages set up, then your browser will download a zero length file instead of showing the index page. This zero length file gets cached. I found that I could not get rid of it by invalidating the directory with test and I had to instead invalidate the whole cache with *.

This should not be a problem once the index pages work.

Categories
SpinupWP WordPress

Bad Link

One of our clients accidentally used a preview link when publicizing a post in a mass email. Of course, the readers of this email could not resolve a link to a preview of a post on the client’s WordPress site. Since this link went out by email, there was no opportunity to correct the mistake on that end. We wanted to create a redirect from the preview link to the actual page using Nginx.

We created a file called custom-redirects.conf in their site’s server directory. In this case that was at /etc/nginx/sites-available/ediblecleveland.com/server/custom-redirects.conf. In that file we added:

if ( $query_string = "preview_id=15781&preview_nonce=4538cdad70&_thumbnail_id=15792&preview=true" ) {
	return 301 /archives/15781;
}

This simply looks for the very specific query string that was accidentally used in the email and, if found, returns the plain post instead.

After making this change we used sudo nginx -s reload to reload the configuration with syntax checking, so we didn’t accidentally bring Nginx down with a syntax error.

Categories
Care and Feeding SpinupWP

Upgrades

SpinupWP will let you know that non-security updates are waiting on the underlying server, but it won’t install them. We use “longview” at Linode to look at recent performance, and longview includes a list of waiting upgrades so you can see what is in store.

To install them we ssh to our sudo-capable account and issue the following commands:

$ sudo apt update
$ sudo apt upgrade

The update makes sure all Linux packages needing an update are accounted for. The upgrade actually installed the updated packages.

We see “cryptsetup” warnings regularly, but have not been worrying about them.

If we are asked about keeping the php.ini changes, we choose to “keep the local version currently installed”.

We try to check for these updates weekly.

Categories
SpinupWP WordPress

Domain mapping with a WordPress network

A few years ago when I wanted to use domain mapping for domains in a WordPress network, it was a bit of a chore. At that time I had to use a special domain mapping plugin and another tool called “sunrise.” This week, when setting up domain mapping with a network generated by SpinupWP we discovered things had gotten a lot simpler.

Since WordPress 4.5 domain mapping has become a native feature. All we had to do was use SpinupWP to define additional domains for our network site, create a subsite for that domain on WordPress, and then go to Sites and edit that site to put the domain name into the Site Address (URL).

That worked! But then we decided we also wanted upload URLs that were a bit more friendly than the site numbers that WordPress automatically uses. A typical piece of media on a site mapped subsite would get a URL like https://subsite.com/wp-content/uploads/site/3/2020/2/media.jpg. That works, but I find the site/3 a bit too mysterious and revealing at the same time. It is mysterious in that it really means nothing to a person reading the URL. It is revealing in that it is trivial to guess that there might be a site/4 or a site/5.

We wondered if we could not use something like sub instead, so that the URL would become https://subsite.com/wp-content/uploads/sub/2020/2/media.jpg. This would take two things, (1) a way to define the string to use for a given subsite, and (2) a way to tell WordPress to use that string instead of the blog ID number in the upload URLs.

We decided to keep defining the string as an experts-only affair. Basically, if this string was present then we wanted to assume an expert had decided the substitution should take place. Since only experts were going to do this, we left it to a WPCLI command:

$ wp --url=https://subsite.com option add tg_upload_dir sub

In other words, we created an option to hold the string. If that option exists, we would make the substitution.

To carry out the substitution we create a one-function upload directory modifier plugin to look for that option and use if when found. The heart of this plugin is the following function:

add_filter( 'upload_dir', 'tg_upload_dir_filter' );

function tg_upload_dir_filter( $dirs ) {
	global $wpdb;
	
	$directory = get_option('tg_upload_dir');
	
	if (! $directory) {
		return $dirs;
	}
		
	$dirs['baseurl'] = site_url() . '/wp-content/uploads/' . $directory;
	$dirs['basedir'] = ABSPATH . 'wp-content/uploads/' . $directory;	
	$dirs['path'] = $dirs['basedir'] . $dirs['subdir'];
	$dirs['url']  = $dirs['baseurl'] . $dirs['subdir'];
	
	return $dirs;
}

While this gave us the URLs we wanted, we still had to take one more step to get these URLs to work: we had to create a symlink from the directory WordPress had created to the friendlier name we wanted to use. To accomplish this we used SSH to connect to the server behind our SpinupWP site and then did the following:

$ cd files/wp-content/uploads
$ ln -s sites/3 sub

The beauty of this approach is that both the sites/3 URL and the sub URL will work, so we don’t have to search and replace existing media URLs unless we want to.

Categories
Hardening SpinupWP

Battling the bots

We found our clients getting attacked by bots trying to login. This was tedious, so we looked for ways to discourage these bots.

Our first attempt was to limit their access to our usernames. To accomplish this we added the following to our /server/tenseg.conf file in each available site (we should figure out how to not use the explicit URL):

if ($arg_author) {
  return 301 $scheme://ggp.tenseg.net;
}

This didn’t stop them from trying, though. So we also added the following to this main site’s /before/tenseg-limits.conf file. Note, we can only add this once, so we do not add it to any of the other available servers.

limit_req_zone $binary_remote_addr zone=WPLIMIT:10m rate=15r/m;
limit_req_status 429;

This creates an Nginx rate limit named WPLIMIT that allows sites to only access something 15 times per minute and uses 10MB of space holding the addresses of those making attempts. Once this limit is created we can add the following to each available server’s /server/tenseg.conf file:

location = /wp-login.php {
    limit_req zone=WPLIMIT burst=5 nodelay;
    include fastcgi.conf;
    fastcgi_pass unix:/run/php/php7.3-ggp.sock;
}

location = /xmlrpc.php {
    limit_req zone=WPLIMIT burst=5 nodelay;
    include fastcgi.conf;
    fastcgi_pass unix:/run/php/php7.3-ggp.sock;
}

Note the inclusion of the FastCGI stuff in these entries. Without this, the php file does not function as a PHP file at all. We learn the values to use in each site by checking the values used in that site’s sites-available configuration file.

The burst=5 nodelay on these limit_req settings ensures that the normal human login experience is still satisfactory. We have found that if a person is using a password manager they could well exceed the one-request-per-four-seconds (15r/m) rate just by having the password manager enter their credentials and one-time token. Allowing an undelayed burst makes regular logins flow smoothly.

After making these changes, the best way to restart Nginx is to use sudo nginx -s reload because this will report syntax errors and not actually restart the server if the configuration files are flawed.

In addition to this, we also learned to use ufw (the “uncomplicated firewall” that SpinupWP has included) to block certain bad actors.

sudo ufw insert 1 deny from 23.100.85.179 to any

This should block a site at a level below Nginx. We include insert 1 to make this the first rule, it has to come before we allow in web traffic. You can view all the settings with sudo ufw status numbered.

We’ll see how this goes.