lewisdale.dev
Colour scheme

You can be friends with my blog

Like almost everyone else it seems, I've opened a Mastodon account. When reading about the standard that Mastodon is built on, ActivityPub, I had an idea: why not try and implement some of it with Netlify functions.

Resources and being discoverable

To get me started, I read a couple of posts on the Mastodon blog:

First of all, I needed to generate an JSON file for my account, and point to it with a .well-known/webfinger file. I generate these after Eleventy has finished building and add them to my _site directory:

eleventyConfig.on('eleventy.after', () => {
console.log("Writing actor file...");
const actorDef = {
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1"
],

id: `https://${domain}/${actor}`,
type: "Person",
preferredUsername: username,
inbox: `https://${domain}/inbox`,
summary: "Posts, streamed from @lewisdaleuk@dapchat.online 's blog",
"attachment": [
{
"type": "PropertyValue",
"name": "Website",
"value": "https://lewisdale.dev",
},
],

publicKey: {
id: `https://${domain}/${actor}#main-key`,
owner: `https://${domain}/${actor}`,
publicKeyPem: Buffer.from(process.env.PUBLIC_KEY, 'base64').toString("utf-8"),
}
}
fs.writeFileSync(`${outDir}/${actor}`, JSON.stringify(actorDef));
});

eleventyConfig.on('eleventy.after', () => {
if (!fs.existsSync(`${outDir}/.well-known`)) {
fs.mkdirSync(`${outDir}/.well-known`);
}

const wf = {
subject: `acct:${actor}@${domain}`,
links: [
{
rel: "self",
type: "application/activity+json",
href: `https://${domain}/${actor}`
},
{
rel: "http://webfinger.net/rel/profile-page",
type: "text/html",
href: `https://${domain}/blog`
}
]
};
fs.writeFileSync(`${outDir}/.well-known/webfinger`, JSON.stringify(wf));
});

You can see these in action at https://lewisdale.dev/lewisdale and https://lewisdale.dev/.well-known/webfinger.

Once I had these in place, I was able to "discover" my profile from my Mastodon instance, but couldn't follow the profile:

Mastodon search UI. The text '@lewisdale@lewisdale.dev' has been entered and there is one matching result

Enabling followers

Okay, this was the bit that had me stumped for several days. The flow basically looks like this:

A Follow message:

{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "a-request-id",
"type": "Follow",
"actor": "https://lewisdale.dev/lewisdale",
"object": "<the actor sending the request>"
}

And an Accept message, where object is the original Follow message:

{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://lewisdale.dev/lewisdale/12345",
"type": "Accept",
"actor": "https://lewisdale.dev/lewisdale",
"object": {
"@context": "https://www.w3.org/ns/activitystreams",
"id": "a-request-id",
"type": "Follow",
"actor": "https://lewisdale.dev/lewisdale",
"object": "<the actor sending the request>"
}
};

So, this is all fairly straightforward so far - there are some other bits I've not covered, like verifying the request signature, but for the most part the data is pretty easy to parse.

I wrote this as a Netlify Edge Function because it meant I could route requests to /inbox straight to it using my netlify.toml:

[[edge_functions]]
function = "inbox"
path = "/inbox"

The hard part was signing requests, not because signing with RSA keys is hard, but because Deno (which is the runtime for Netlify Edge Functions) only supports the SubtleCrypto API. The only major difference is that it operates on the raw data for the keyfile, so I couldn't just pass a Base64 encoded PEM file like I can with Node's crypto library.

But, once I'd overcome that hurdle (which, to be honest, is a whole other post), Following worked! I also implemented the Undo format, which is what a user will send when they want to unfollow you, but other than that I was ready to start accepting followers.

Publishing posts

The final step was to publish posts. I achieve this with a standard Netlify serverless function, which I placed in functions/deploy-succeeded.ts, which means Netlify will detect it and automatically run it every time I successfully deploy the blog.

So, for each new post, I send a Create message to each follow, which looks a bit like this:

 {
"@context": "https://www.w3.org/ns/activitystreams",

"id": "https://lewisdale.dev/lewisdale/an-id",
"type": "Create",
"actor": "https://lewisdale.dev/lewisdale",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"object": {
"id": "https://lewisdale.dev/post/my-post-url/an-id",
"type": "Note",
"published": "<the post date>",
"attributedTo": "https://lewisdale.dev/lewisdale",
"content": "<p>New post: <post title><br /><a href=\"https://lewisdale.dev/post/my-post-url\">https://lewisdale.dev/post/my-post-url</a></p>",
"to": ["https://www.w3.org/ns/activitystreams#Public"]
} ,
"cc": ["<follower url>"]
}

I also then log a reference to the message with the ID, so that I can send an Undo request if I need to delete the post, and so that I can avoid sending the same Post multiple times.

Data storage

So when developing this, I used Netlify Forms as my data store. This went great until I tried to insert all of my older posts into the forms, which took me to the limit of the free tier (100 submissions/month), and I think got me rate limited.

In the end, as with my comments system, Supabase was the lowest-barrier way to handle this. The free tier should be plenty of space and bandwidth to handle everything I need.

Final thoughts

This was a lot of fun to build. It works surprisingly well, and I learned a lot about cryptography along the way, so it's a big win.

I actually really like the idea of ActivityPub, it feels like the way the web was intended to work. I've got some ideas for other things I could build using it, so may end up pumping out some other projects.

I'm going to work on cleaning up the code that runs this, and I'll release it as a repository that can be deployed straight to Netlify. There are also a few other things I'd like to work on - it'd be cool to insert replies to posts as comments, for one. Right now there's no way I can see them unless they happen to turn up on my timeline.

In the meantime, you can now follow my blog through your Mastodon/other ActivityPub instance at @lewisdale@lewisdale.dev.

Add a comment