---
title: "You can be friends with my blog"
date: 2022-11-08T00:00:00
slug: you-can-be-friends-with-my-blog

---
Like almost everyone else it seems, I've [opened a Mastodon account](https://dapchat.online/@lewisdaleuk). When reading about the standard that Mastodon is built on, [ActivityPub](https://www.w3.org/TR/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:

* [How to implement a basic ActivityPub server](https://blog.joinmastodon.org/2018/06/how-to-implement-a-basic-activitypub-server/)
* [How to make friends and verify requests](https://blog.joinmastodon.org/2018/07/how-to-make-friends-and-verify-requests/)


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:

```javascript
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:

{#% image "./img/lewisdale-mastodon-find.png", "Mastodon search UI. The text '@lewisdale@lewisdale.dev' has been entered and there is one matching result", "mx-auto h-auto" %#}

## Enabling followers

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

* Receive a "Follow" message from another user (an Actor)
* Send an "Accept" message to that user confirming you've received the request
* Persist the Actor information so that you can notify them of new posts
  
A Follow message:

```json
{
    "@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:

```json
{
	"@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](https://docs.netlify.com/edge-functions/overview/) because it meant I could route requests to `/inbox` straight to it using my `netlify.toml`:

```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:

```json
 {
	"@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](https://lewisdale.dev/post/adding-comments-to-my-blog/), 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`.
