You can find the index and other parts of this series here.

We are almost done! Thank you for coming all the way into this journey. In this part we will learn how to broadcast (aka federate) your site posts to your folowers.

Overview

The federation of your posts is pretty straighforward:

sequenceDiagram
    BroadcastTool->>Storage: Retrieve followers (actor uris)
    BroadcastTool->>Follower-instance: Get actor info (including inbox uri)
    BroadcastTool->>Filesystem: Get note json (post)
    BroadcastTool->>Follower-inbox: Send a create action (wrapper of note)

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"
}
  1. Context is the namespace for the JSON request.
  2. Id is a unique ID for the follow request.
  3. Type is the type of the message.
  4. Actor is who is trying to follow you.
  5. 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"
  }
}

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:

  1. Four request headers are necessary:
SHA-256={payloadHash}
(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.