Previously, if you wanted to subscribe to changes from this blog, you’d have to subscribe to the RSS feed, but as of today you can also subscribe to it in your preferred Fediverse client, like Mastodon. Note this is considered Beta quality. If you have any issues, let me know.
What is the Fediverse? It’s a protocol for federated (meaning many independently operated) social networks, kind of like email. Under the hood, it uses a protocol called ActivityPub to define the interactions between different servers.
There’s a number of big implementations of this, like Mastodon, that I could have used. However, I wanted to see if it was possible to integrate directly into my static website generator, Hugo and generate all the content directly out of the posts I already write without having to maintain another program and expose another domain name for people to remember (e.g. blog@mastodon.technowizardry.net.)
This post walks through the work I did to make this work.
What is ActivityPub?
ActivityPub is an open standard protocol designed to enable decentralized social networking across different platforms and services. Published by the W3C organization, the same org that publishes the HTML standard, it allows users to maintain their online identities and connections while moving between different websites, apps, and services without losing access to their content or network.
By defining an interoperable protocol, different software, like Mastodon, Lemmy, Pleroma can all subscribe and publish notes to different instances and software. The federation part means that you can subscribe to notes on a different server. My goal is to have readers be able to subscribe to posts in Mastodon and eventually be able to like and even comment on them.
The WebFinger
The first thing that happens when you follow somebody else is your software issues a “webfinger” request (spec). The client will GET https://www.technowizardry.net/.well-known/webfinger?resource=acct:blog@technowizardry.net. The response tells clients that I do support ActivityPub and the response looks like:
| |
To generate this with Hugo, create a file layouts/index.webfinger.ajson:
| |
The subject needs to match the value passed by the client in the /webfinger?resource=acct:blog@technowizardry.net. Since I only support a single account, I can hard-code the value. The links array will tell clients where to find my user details. This is critical for ActivityPub.
Next we need to tell Hugo to generate this file (set in config.yaml). Note that I’m going to generate the files with an .ajson extension to distinguish between this and the HTML outputs. More on that later.
| |
Generating the outbox
The ActivityPub outbox contains a historical listing of ActivityPub events (my blog posts) that other servers can download to catch-up on old events. Unfortunately, it doesn’t seem to be well used. For example, Mastodon does not pull old posts.
I’m going to generate it anyway just to be safe in-case there is a server that does support it. The template iterates through every published post and emits a single JSON object for every post, much like the RSS feed is generated.
Note the way that I serialize the content and summary fields. While researching this post and implementing it, I found several other implementations that incorrectly serialized the objects where HTML entities would be included in the post or they would even generate invalid JSON objects. For more information, see my other post on fixing common Hugo encoding problems.
layouts/index.activitypub_outbox.json:
| |
Time to update the config.yaml again:
| |
Generate per-post files
Even though Mastodon doesn’t download old posts automatically, it can still open any post. By pasting the URL, and clicking “Open URL in Mastodon”, Mastodon will issue a GET request to that URL with the header Accept: application/activity+json expecting to download the post in ActivityPub JSON format.

Right now, it’s passing back as HTML. Let’s generate something per post. Again, update the config.yaml to generate this new file.
| |
Let’s refactor this and the outbox and create a new partial that is shared between the outbox and post. The partial has to have the extension .html because that’s how Hugo works.
layouts/partials/post_main_blob.html:
| |
layouts/posts/single.post_json.ajson:
| |
Making the Accept header work
This is the part where your environment may look different than mine and may differ. For example, if you’re running on Vercel, then this approach would be better. If you were using Azure Websites, then this also works. Both of these generate static files that include references to small functions as a service to handle the inbox and followers lists. These can be hosted on any cloud provider.
Prior to this project, I used pure Hugo to generate static content and Mastodon was able to work with this to load posts. I built everything using a Hugo Docker image and packaged the content into an NGINX Docker container that was run on my Kubernetes cluster. However, this is not enough to implement ActivityPub.
First, we need to check the Accept header to see if the client is requesting HTML or if it’s requesting an ActivityPub JSON blob of the item.
Attempt 1 - Using NGINX
My first attempt was to implement this using NGINX’s configuration and it looked like the below. The following implemented a conditional based on the Accept header and returned the index.ajson file if the client passed in the special MIME type or returns index.html in any other case.
| |
This worked okay, but as I continued to implement this, I started to have issues. For example, when developing I had circular dependencies, and logic to implement request handling was getting split up into different Docker containers.
Next part of this series, I’ll show how I approached this and implemented a server-side follower store.
Conclusion
As you can see, this is one of the lengthier posts, for a seemingly “easy” function. This goes to show you that the simplist of tasks can take the most amount of time. But don’t worry, all you have to do as a user is to “like” and “subscribe” and donate to my coffee fund for more information like this.
To implement ActivityPub, we have to generate the webfinger file to allow clients to know where to go, an ActivityPub user page, an outbox to download all posts, and individual post files.
References
- https://maho.dev/2024/02/a-guide-to-implement-activitypub-in-a-static-site-or-any-website/
- https://paul.kinlan.me/adding-activity-pub-to-your-static-site/
- https://github.com/mastodon/mastodon/issues/34
- https://github.com/mastodon/mastodon/issues/21770
- https://www.w3.org/TR/activitypub/#collections
- https://webfinger.net/spec/
