My first job as a web developer was in 2000. Working with a company named Emedia Science, one of my jobs every morning was to rebuild a job board site with a sort of mail-merge affair running from a Microsoft Access database. This took two hours every morning, and so I pitched the boss on using PHP and MySQL as an alternative.
Over a quarter of a century later, I find myself again using the pattern of building out a static HTML site, in preference to the dynamic solution I had pitched in 2000.
One such website is the home of a podcast I produce. The podcast itself is hosted on Libsyn, but the website for the podcast is a static site hosted on Netlify and currently backed by Contentful.
I recently decided I should move the source of truth for the podcast episodes from Contentful to Libsyn, since episodes are defined there anyway for the RSS feed.
Contentful gave me webhooks; any time an episode transitioned from the scheduled to the published state, it sent a POST request to a URL of my choosing. I used that to trigger a rebuild, but in principle it could be used for anything. A cache purge, a social media post, a Discord bot, etc. I was pretty sure that Libsyn would have something similar.
I was wrong.
What does Libsyn offer?
Libsyn can post to a WordPress blog. If you provide Libsyn with a blog URL, and authentication details, then when a new episode publishes it can create a new post about that episode on a WordPress blog using the old XML-RPC implementation of the MetaWeblog API from 2002.
While I’m not running WordPress, that doesn’t mean I can’t hijack this mechanism to give me the publish hook that I’m looking for. All I need to do is mimic enough of the WordPress XML-RPC implementation to persuade Libsyn it is talking to a real WordPress.
Press enter or click to view image in full size
How much WordPress do I need?
With some empirical investigation (tl;dr logging) I found that I need to implement two methods to meet what Libsyn is expecting.
metaWeblog.newPost, called when a new episode is published; andmetaWeblog.getRecentPosts, called when you click ‘Test Connection’ in the Libsyn WordPress config screen.
We need to handle these events at /xmlrpc.php which is where Libsyn expects to find them. Each call is an XML document provided in the request body.
<?xml version="1.0"?>
<methodCall>
<methodName>metaWeblog.newPost</methodName>
<!-- Argument order matters -->
<params>
<!-- BlogID. We ignore this. -->
<param>
<value>
<string>1</string>
</value>
</param> <!-- Username -->
<param>
<value>
<string>libsyn</string>
</value>
</param>
<!-- Password -->
<param>
<value>
<string>correcthorsebatterystaple</string>
</value>
</param>
<param>
<value>
<struct>
<!-- Detail of the Episode. Ignore this too. -->
</struct>
</value>
</param>
<!-- Publish state. 1 = publish, 0 = draft -->
<param>
<value>
<boolean>1</boolean>
</value>
</param>
</params>
</methodCall>
The Fake Endpoint
I don’t care about the episode data, because my Netlify build is going to read the episode data from the RSS anyway. So there are just two things I need to check. Which method you’re calling, and if you’re authenticated.
The username and password are stored as secrets in my Netlify config, and they are included in plaintext in the XML payload. Checking the credentials is as simple as parsing out the relevant fields and doing a constant time comparison.
No match? You’re getting a 403 error.
<?xml version="1.0"?>
<methodResponse>
<fault>
<value>
<struct>
<member>
<name>faultCode</name>
<value>
<int>403</int>
</value>
</member> <member>
<name>faultString</name>
<value>
<string>Bad username or password.</string>
</value>
</member>
</struct>
</value>
</fault>
</methodResponse>
(This is not a real HTTP 403; it’s a MetaWeblog 403 error. The HTTP status code is still 200.)
Once we’re clear this is an authenticated request, we can inspect the methodName from the payload. I only care about getRecentPosts and newPost. Any other method and you’ll get the same 403 error. Perhaps it would be better to return something else here? But this is already a hack anyway, so it’ll keep.
Now we are sure this is a method we care about, we can handle the specifics. If you want recent posts, we’ll just return an empty list.
// Test Connection calls getRecentPosts
if (method === "metaWeblog.getRecentPosts") {
return reply("<array><data></data></array>");
}If you’re notifying me about a new post (that is, a new episode has been published), then I want to call my Netlify build hook.
// Libsyn sends newPost when an episode is published, so rebuild.
if (method === "metaWeblog.newPost") {
await fetch(BUILD_HOOK_URL, { method: "POST" });
}If the build hook fails, we’ll just catch that error and log, but return success back to Libsyn anyway. I don’t want to scare the old man.
// metaWeblog expects the Post ID back; but we'll fake it
return reply("<string>1</string>");And it works, mostly
It does successfully rebuild the site! Which is great. But there are two problems.
First, /xmlrpc.php gets a lot of spam. Bots scan the internet for WordPress and try to brute-force posting using XML-RPC. As soon as there was a working /xmlrpc.php, I started getting a steady stream of junk requests. The password check rejects them, but each one is a function invocation that has to be paid for with CPU time. Burning the planet into the sky to say ‘no’.
The obvious workaround for this is to change the URL. Libsyn always looks for /xmlrpc.php at the blog root; but since WordPress supports installation in a subdirectory, Libsyn does too. So I added a random string as a fake subdirectory to head off the blogspam. Lovely.
Second, Libsyn turns out to be really slow! Requests don’t arrive immediately on publish as they did with Contentful; sometimes they land several minutes later. It’s not the end of the world, but I’m paying for that latency exactly when my listeners are on my site looking for the next show.
What I actually want
This silly hack shouldn’t be needed.
If you run a podcast hosting platform, just add publish webhooks. And make them prompt. Especially if you work for Libsyn. It doesn’t need to do much. Even an empty POST when an episode goes live is better than nothing.
Until then, I’m pretending to be WordPress.