You can find the index and other parts of this series here.
Let’s dig in into the most controversial part of my guide: the inbox. As far as I know, there is no way to make this inbox static. However, thanks to the fact that ActivityPub does not require the inbox to share the same domain as the other elements, we can host it anywhere.
I believe it could be controversial for two reasons. First, it is not truly static; you require a backend somewhere running on some kind of server. The second reason is that I am using Azure, and I know from the get-go that will put some of you off. But I believe this guide is still valid, as you can easily deploy to AWS, GCP, or even Vercel. I am writing this in .NET, but as you may already know, translating the logic should be trivial for most modern languages. I will actually encourage people to contribute these implementations to my repo and make it our repo.
Overview
The Inbox
receives POST
HTTP requests for different actions. These requests may or may not be signed, and you can decide to take action or not on those. There is no definition of what minimal implementation should be, and one example is how Threads implemented their federation in phases. You could implement just the follow feature, leaving your followers without any ability to unfollow you, but that is not cool. For a static site, I would suggest that the core should be follow/unfollow and replies.
Follow
This is how the process looks like; we will go into the details of each step:
sequenceDiagram participant Follower-instance participant Inbox participant Storage Follower-instance->>Inbox: Follow request Inbox->>Follower-instance: 200 OK Inbox->>Follower-instance: Request actor info Inbox->>Storage: Save follower record Inbox->>Follower-instance: AcceptRequest
When a user requests to follow your site, they will send a request like this:
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://mastodon.social/64527582-3605-4d19-ac99-6715df3b0707",
"type": "Follow",
"actor": "https://mastodon.social/users/mictlan",
"object": "https://maho.dev/@blog"
}
- Context is the namespace for the JSON request.
- Id is a unique ID for the follow request.
- Type is the type of the message.
- Actor is who is trying to follow you.
- Object is what is trying to follow. In this case, your actor URI.
The request itself should be answered by a 200 OK
, but that is not the end of the process, and will just mark the follow request as pending
in the Mastodon or any other client side.
What I do with this is to create a record in my database (an Azure Table, but it can be anything, even SQLite). I only store the actor URI and nothing else.
The actor by itself is not useful, so you need to request more information about it. This is a GET
request to the actor URI. The result will give you a lot of things (it actually returns what we saw in part 3 of implementing an actor), but the most important one is the Public Key.
Once you store the actor and fetch extra information, you need to send a request to their inbox (this inbox URL comes from the actor object). This is an AcceptRequest
, and this is how mine looks:
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://maho.dev/@blog#accepts/follows/mictlan@mastodon.social",
"type": "Accept",
"actor": "https://maho.dev/@blog",
"object": {
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://mastodon.social/64527582-3605-4d19-ac99-6715df3b0707",
"type": "Follow",
"actor": "https://mastodon.social/users/mictlan",
"object": "https://maho.dev/@blog"
}
}
- id: An unique ID of the accept request; it can be anything, just make sure it is unique.
- type: Accept string.
- actor: Who is making the request; in this case, your static site actor.
- object: The original follow request. Tip: Make sure it is the original and not recreated (unserialized/serialized).
This request should be signed; otherwise, it won’t work. Mastodon is very fast to process any request. If you see the follow request not being reflected in the instance, it may be an issue with the signature. Mastodon will also return just a 200 OK
if the signature is valid, but may not process the message if any of the elements do not match wherever Mastodon is expecting.
You may find better documentation about the signature that ActivityPub instances are expecting, but this is the summary:
- Four request headers are necessary:
- Host: with the host part of the follower actor inbox URI.
- Date: UTC date string in the ISO format.
- Digest: the SHA256 checksum of the whole payload in this format:
SHA-256={payloadHash}
- Signature: A base64 encoded signed string using your private key. The signature string looks like this:
(request-target): post {inboxUrl.AbsolutePath}\nhost: {inboxUrl.Host}\ndate: {date}\ndigest: {digest}
Note: Remember to add the Content-Type “application/activity+json” element to your request.
You can find my .NET implementation here.
Unfollow
This is how the process looks like; we will go into the details of each step:
sequenceDiagram participant Follower-instance participant Inbox participant Storage Follower-instance->>Inbox: Unfollow request Inbox->>Follower-instance: 200 OK Inbox->>Storage: Remove follower record Inbox->>Follower-instance: Request actor info Inbox->>Follower-instance: AcceptRequest
The unfollow request is very similar to the follow request. For example:
{
"@context":"https://www.w3.org/ns/activitystreams",
"id":"https://mastodon.social/users/mictlan#follows/51971777/undo",
"type":"Undo",
"actor":"https://mastodon.social/users/mictlan",
"object":{
"id":"https://mastodon.social/f722c435-5a8a-4e94-b7c8-529cdcdf12a6",
"type":"Follow",
"actor":"https://mastodon.social/users/mictlan",
"object":"https://maho.dev/@blog"
}
}
You can see that the object element contains the whole Follow object. As in the follow part, you may want to check the signature of this request, since a bad actor could “unfollow” other people without their consent.
As in the follow request, you need to return a 2XX
HTTP code and send an AcceptRequest to the follower inbox to confirm the unfollow request. The AcceptRequest is exactly the same, and remember the object element just contains the whole original request, in this case, the undo request (which also includes an object inside).
Again, you can find the .NET implementation here.
Replies
This is how the process looks like; we will go into the details of each step:
sequenceDiagram participant Follower-instance participant Inbox participant Storage Follower-instance->>Inbox: Reply to post Inbox->>Follower-instance: 200 OK Inbox->>Storage: Create the replies JSON file
If someone is replying to your site in the fediverse, you will receive a request like this:
{
"@context": [
"https://www.w3.org/ns/activitystreams",
{
"ostatus": "http://ostatus.org#",
"atomUri": "ostatus:atomUri",
"inReplyToAtomUri": "ostatus:inReplyToAtomUri",
"conversation": "ostatus:conversation",
"sensitive": "as:sensitive",
"toot": "http://joinmastodon.org/ns#",
"votersCount": "toot:votersCount"
}
],
"id": "https://hachyderm.io/users/mapache/statuses/112281648967418926/activity",
"type": "Create",
"actor": "https://hachyderm.io/users/mapache",
"published": "2024-04-16T15:39:57Z",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [
"https://hachyderm.io/users/mapache/followers",
"https://maho.dev/@blog"
],
"object": {
"id": "https://hachyderm.io/users/mapache/statuses/112281648967418926",
"type": "Note",
"summary": null,
"inReplyTo": "https://hachyderm.io/users/mapache/statuses/111910324565541779",
"published": "2024-04-16T15:39:57Z",
"url": "https://hachyderm.io/@mapache/112281648967418926",
"attributedTo": "https://hachyderm.io/users/mapache",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [
"https://hachyderm.io/users/mapache/followers",
"https://maho.dev/@blog"
],
"sensitive": false,
"atomUri": "https://hachyderm.io/users/mapache/statuses/112281648967418926",
"inReplyToAtomUri": "https://hachyderm.io/users/mapache/statuses/111910324565541779",
"conversation": "tag:hachyderm.io,2024-02-11:objectId=125092496:objectType=Conversation",
"content": "<p><span class=\"h-card\" translate=\"no\"><a href=\"https://maho.dev/\" class=\"u-url mention\">@<span>blog</span></a></span> I will reply to this post for the part 6.</p>",
"contentMap": {
"en": "<p><span class=\"h-card\" translate=\"no\"><a href=\"https://maho.dev/\" class=\"u-url mention\">@<span>blog</span></a></span> I will reply to this post for the part 6.</p>"
},
"attachment": [],
"tag": [
{
"type": "Mention",
"href": "https://maho.dev/@blog",
"name": "@blog@maho.dev"
}
],
"replies": {
"id": "https://hachyderm.io/users/mapache/statuses/112281648967418926/replies",
"type": "Collection",
"first": {
"type": "CollectionPage",
"next": "https://hachyderm.io/users/mapache/statuses/112281648967418926/replies?only_other_accounts=true&page=true",
"partOf": "https://hachyderm.io/users/mapache/statuses/112281648967418926/replies",
"items": []
}
}
},
"signature": {
"type": "RsaSignature2017",
"creator": "https://hachyderm.io/users/mapache#main-key",
"created": "2024-04-16T15:39:57Z",
"signatureValue": "am2S196mTFCudcFl5GwghZu93aE2WwNTcmFVIzR18YgHso9qVAd4mE2dkJH9WygJnGikS+yPvHAHXK2bP/6qq6NkTCf3p3F0JR8UlbunT06qbZTa4Vi6KKGsXjL4PA7AzjMd0Wk8jUxYm2yJtDm8xzEr7SH8rwBxg8u+ozkIY0cPBAPoBZwb8+j7DQYxN67YJp0MQCRj6TmODgw/ywZySijs1sbCO1Q6U53icHiW19P27vwZzD41i9tMZajosBJHCNX2Bh6uop/LCRiXbAwvbqVPoS3T8k0x22kwdo4oo3tdtfrUAL9giBjQ/nTHGSPDbCvygQT2U43FbN1u47Feww=="
}
}
As you can see, this is a full note creation, and it is very similar to when we are generating notes for our posts. The only difference is the inReplyTo
field (e.g., "inReplyTo": "https://hachyderm.io/users/mapache/statuses/111910324565541779"
), which is the post URL where we are replying.
What I do is to store only the note URL (the reply URL) and the reply-to URL, not the content. You may want to store the content if you want, but just be aware the author may edit the content, so it won’t be up-to-date unless you catch the edit/delete requests. With the note URL, I render the comments on-demand on the static site and always get the latest of them.
You could also skip implementing replies and just use a Mastodon instance as a “gateway,” searching your post and retrieving the replies. This has the downside that you will only see the replies that that specific instance was aware of.
The last step is to generate the replies collection. Each post/note has a “replies” URI, which contains a collection of the replies (a list of URLs as well). This looks similar to this:
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://maho.dev/socialweb/replies/4f2756ff205d2e4b15e5c65c17f961e5?page=true",
"partOf": "https://maho.dev/socialweb/replies/4f2756ff205d2e4b15e5c65c17f961e5",
"type": "CollectionPage",
"items": [
"https://dotnet.social/users/SmartmanApps/statuses/111910545030985527"
]
}
This is a static resource, generated by my Azure Function in my site storage.
Putting all together
A dotnet implementation of these features can be found here. I will try to record a video of how to deploy it and add some IaC/CI-CD in the next few weeks.
One important aspect is that the Inbox backend should have access to your private key, which I am storing as a secret.
As I said before, I hope/wish/expect that others will contribute/fork their own non-Azure and/or non-dotnet implementations. I will buy you a coffee if you do.
Final notes
Once your site “federates” with other instances, it is very likely the instance will start broadcasting other requests other than follow/unfollow/replies. For example, each time an account is deleted, it will send a “Delete” request. Since I am using Azure Functions, and one of the elements of pricing is runtime, the first thing I do in my inbox is to check the type of message and immediately discard or end execution of things I don’t recognize (returning an 500 error
for the messages I don’t want to process).
And final note, there is some similar implementation named Social Inbox from Distributed.Press but it adds more features like moderation and stuff like that.