Back to Blogging in the Fediverse
Previously[1]Imran Nazar, "Implementing HTTP Signatures with PHP and OpenSSL", May 2024 I looked at the main prerequisite for supporting ActivityPub on this blog, which is to handle the HTTP Signature specification as implemented by AP (and by Mastodon specifically). This time, let's look at the particular messages and operations that might arise in the course of running a blog that's connected to the Fediverse:
- Finding the blog and knowing the endpoints to communicate with it;
- Following the blog to receive future posts in your timeline;
- Publishing a post to the blog's followers;
- Commenting on a post and having the comment appear on the blog;
- Reacting to a post and having the reactions recorded on the blog;
- Deleting a reply and having the blog remove it;
- Undoing a follow or reaction and having the blog remove it.
Aside from finding the blog, each of these has a direction in which it propagates, and only publishing a post is an outward event: a post is written on the blog, it's published and pushed out. The other events outlined above are inward events, which are received by the blog from the source server. We should deal with these in order though, as it won't make sense to publish to our followers without having received any follows, so we'll look at receiving events first.
Finding the blog with WebFinger
However, before any of the other events can happen, a user on Mastodon will need to find our blog, and the server on which they reside will need to know how to get messages to us. Mastodon uses WebFinger[2]Mastodon documentation, "WebFinger", Feb 2023 for this, and that means our blog will need to respond with something when the URL /.well-known/webfinger
is loaded.
The documentation linked in note 2 states that the WebFinger endpoint will receive a request detailing which user is being "fingered", in order to determine how best to get information to that user. This is important for multi-user setups like Mastodon or Lemmy instances; fortunately, our blog is the kind of site that won't have multiple users. As such, we can ignore the body of the WebFinger request entirely, and serve a static response such as the following (which happens to be this blog's actual response).
The blog's WebFinger response
{ "subject": "acct:blog@imrannazar.com", "aliases": [ "https://imrannazar.com/@blog", "https://imrannazar.com/ap/blog" ], "links": [ { "rel": "http://webfinger.net/rel/profile-page", "type": "text/html", "href": "https://imrannazar.com/@blog" }, { "rel": "self", "type": "application/activity+json", "href": "https://imrannazar.com/ap/blog" } ] }
The above response indicates that, for the account @blog@imrannazar.com
(which is the address I always hand out on Mastodon), its profile page (serving text/html
) is /@blog
, and its self
(the canonical URL for ActivityPub's purposes) is /ap/blog
; it's the latter of these which is important for our purposes, as it's the main repository of data regarding the blog and its available endpoints.
Let's have a quick look at this ActivityPub canonical URL: it serves a Person
object with (almost) all the information a Mastodon instance might need to draw up a profile page for this account if it doesn't already have this information cached. We've already seen Person
objects referenced in the previous instalment in relation to verifying an ActivityPub actor's public key, but let's have a look at the blog's actor data in more detail to see which endpoints we'll need to implement.
The blog's actor object
{ "@context": [...], "publicKey": {...}, "id": "https://imrannazar.com/ap/blog", "preferredUsername": "blog", "type": "Person", "name": "Imran Nazar's Blog", "url": "https://imrannazar.com/@blog", "summary": "<p>Imran Nazar: Things I've written over the years, a blog on software development topics with the occasional sci-fi short thrown in.</p>", "published": "2006-09-22T00:00:00Z", "attachment": [ { "name": "RSS feed", "type": "PropertyValue", "value": "<a href=\"https://imrannazar.com/rss.xml\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">https://imrannazar.com/rss.xml</a>" } ], "icon": { "mediaType": "image/png", "type": "Image", "url": "https://imrannazar.com/assets/ap-icon.png" }, "image": { "mediaType": "image/jpeg", "type": "Image", "url": "https://imrannazar.com/assets/ap-image.jpg" }, "discoverable": true, "indexable": true, "manuallyApprovesFollowers": true, "memorial": false, "following": "https://imrannazar.com/ap/following", "followers": "https://imrannazar.com/ap/followers", "inbox": "https://imrannazar.com/ap/inbox", "outbox": "https://imrannazar.com/ap/outbox" }
For our purposes, this can again be a static file served from the endpoint /ap/blog
. There are a few groups of keys here:
- User information: Things like the ID (the canonical URL of this user), name, summary and date of publication (which for a
Person
-type object is the date the user was created); - Display data: This includes the avatar/icon, background/header image, and any additional URLs or links to be shown on the profile;
- Behavioural flags: In this case, we want the account to be publicly visible ("discoverable") as well as come up in search results ("indexable"), but this is not a memorial account. The most important flag here is
manuallyApprovesFollowers
, as it determines what we do when a Follow request is received, but we'll come to that later; - Endpoints: These are the locations of interest to us. Following and Followers are read-only collections which inform any Mastodon instance reading them how many of each the blog has, and their canonical actor IDs; the Inbox is where a remote instance will send AP messages; and the Outbox is where an instance can expect messages from the blog to be coming from.
So let's say a Mastodon user wants to follow this blog, and they have the address (@blog@imrannazar.com
) but they're the first user on their instance to want to follow us. There are a few requests the remote instance will make in order to build up a complete profile:
- First a WebFinger request is needed to find the actor ID; the URL for this is taken directly from the address given (
@blog@imrannazar.com
leads us to https://imrannazar.com/.well-known/webfinger). - The result contains a link of
rel: self
which in turn informs us where the actor resides, which when fetched contains the summary, icon, image and such. - Once the remote instance has the actor data, it can then fetch the following and followers lists in order to show those counts.
Having done all this, the remote instance is able to draw up a page that looks a little like this:
(We can see in this screenshot that the blog has six followers, but we haven't yet implemented /ap/followers
to allow the remote instance to know that; we'll get to it though.)
Receiving AP events
Finally we have a profile page with an inviting Follow button, and the remote Mastodon instance knows where to send the request when that button is pressed: the actor's inbox
. So what would one of those requests look like? Let's have a look at how that endpoint might translate to a controller in a PHP MVC framework like BirSaat[3]"BirSaat PHP MVC microframework", Imran Nazar, 2013, so we can start logging the requests that come in:
class ApController extends bsControllerBase { public function inboxAction() { if ($_SERVER['REQUEST_METHOD'] != 'POST') { throw new Exception('Inboxes are POSTed to'); } $post = file_get_contents('php://input'); file_put_contents('/tmp/ap.log', $post); } }
It's rudimentary, but this code will allow us to see the last request that came in to /ap/inbox
; the specification states that all messages will come in as POSTed JSON, so an additional check is made for the method of the request. If I now press the Follow button on this blog, the following message comes in:
{ "@context": "https://www.w3.org/ns/activitystreams", "id": "https://hachyderm.io/af444650-1827-4bf7-94cd-0ec0b57bdb44", "type": "Follow", "actor": "https://hachyderm.io/users/Two9A", "object": "https://imrannazar.com/ap/blog" }
Every ActivityPub message has an ID, which in our use case we don't need to track; the important things here are the type
and the actor
associated with the message. The actor
who is following the blog should match up with the signature verification headers explained in the previous article, which will allow us to authenticate this as a valid message to be handled. So we can add a couple more clauses to our inbox handler before the message-specific handling begins:
class ApController extends bsControllerBase { public function inboxAction() { if ($_SERVER['REQUEST_METHOD'] != 'POST') { throw new Exception('Inboxes are POSTed to'); } $post = file_get_contents('php://input'); try {// Signature verification code from the previous article$actor = $this->verifyHeaders(); $input = json_decode($post, true); if (!$input) { throw new Exception('Malformed inbox message'); } if ($actor['id'] != $input['actor']) { throw new Exception('Inbox message misattributed'); } $handler = strtolower($input['type']) . 'Handler'; if (is_callable($this, $handler)) { $this->$handler($input); } } catch (Exception $e) {// Any logging you may wish to performthrow $e; }// No response required except for a blank 200die(); } }
Handling Follows
To follow the blog is to receive new posts when they're published, so we'll need to record the fact that this user is following us in order to send a publication event to their server when the time comes. We'll want a database table for this, and it so happens that this blog has its page structure data held in MySQL, so we'll make another table alongside the page list.
One thing to note is that signature verification for ActivityPub messages depends on us having the public key of the actor creating the message; if we don't already have the public key, we'll need to fetch it from the remote ActivityPub instance. In order to reduce the need to do this every time a message comes in, we can cache the actor object in the database once fetched, and use that for future verification requirements.
Actors table in MySQL
CREATE TABLE ap_actors( id VARCHAR(255) NOT NULL PRIMARY KEY, url VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, avatar_url TEXT, follows_us TINYINT(1) NOT NULL DEFAULT 0, full_data TEXT );
Actor data model: Cached fetch, preferring database first
class ActorModel { protected $db;// PDO MySQL connectorprotected $id; public $data; public function __construct($id = null) { if ($id) { $this->data = $this->cached_fetch($id); return $this->data; } } public function cached_fetch($id) { $this->id = $id; $st = $this->db->prepare('SELECT * FROM ap_actors WHERE id=:id'); $st->bindValue(':id', $id); $st->execute(); $row = $st->fetch(PDO::FETCH_ASSOC); if ($row) { return json_decode($row['full_data'], true); }// If we don't have the actor cached, fetch and save // See "Fetching the signing actor" from the previous article$actor = $this->fetch_from_remote($id); if (!$actor) { throw new Exception('Actor not found'); } $st = $this->db->prepare(' INSERT INTO ap_actors(id, url, name, avatar_url, full_data) VALUES(:id, :url, :name, :avatar, :data) '); $st->bindValue(':id', $id); $st->bindValue(':url', $actor['url']); $st->bindValue(':name', $actor['name']); $st->bindValue(':data', json_encode($actor));// Some accounts don't have avatars$st->bindValue(':avatar', isset($actor['icon'], $actor['icon']['url']) ? $actor['icon']['url'] : null ); $st->execute(); return $actor; } }
Now we have the framework in place for handling messages, and an actor data model to store the fetched actors, all we need is to record the fact that the actor making this request wants to follow us:
Controller: Follow handler
private function followHandler($input) { $actor = new ActorModel($input['actor']); $actor->set_follows_us(true); }
Model: Record the follow
public function set_follows_us($id, $follows) { $st = $this->db->prepare( 'UPDATE ap_actors SET follows_us = :f WHERE id = :id' ); $st->bindValue(':id', $id); $st->bindValue(':f', $follows); $st->execute(); }
Accepting follows
Except that doesn't quite work. If someone wants to follow this blog, and they hit Follow on the blog's profile page in their Mastodon instance, one would expect to see an "Unfollow" button the next time they view the blog, but instead we get "Cancel follow".
This happens because the remote instance is expecting a confirmation from us that the request to follow has been granted, as per this line from the blog's actor data:
/ap/blog
: Blog actor data
{ "@context": [...], "publicKey": {...}, ... "discoverable": true, "indexable": true, "manuallyApprovesFollowers": true, "memorial": false, ... }
Just as the remote actor sent a message to our inbox
to request the follow, we'll need to send an Accept-type message to their inbox
. Luckily, we already have everything we need to do this: the actor's endpoints are part of their full data which we already have either cached or fetched, and the message being accepted is the follow we received.
private function followHandler($input) { $actor = new ActorModel($input['actor']); $actor->set_follows_us(true);// See the previous article for detailed code on // signing and sending ActivityPub messages$this->send($actor->data['inbox'], [ 'type' => 'Accept', 'object' => $input, ]); }
And now we can circle back around to the blog's profile page, and specifically the "6 followers" that it shows. This data is fetched (if the instance doesn't already have a count of followers cached locally) from our actor's followers
endpoint; this (and following
) should return objects of type Collection
. In theory, these collection endpoints should support pagination so any remote instance can fetch a full list of actor IDs, but for the purposes of this blog I don't foresee the list of followers growing to such an extent that serving a full list will cause undue load.
As with the inbox
endpoint, these two collection endpoints will map to action methods in an MVC framework:
Actor data model: Fetch actors who follow us
public function fetch_followers() { $st = $this->db->prepare( 'SELECT id, name, url, avatar_url FROM ap_actors WHERE follows_us = 1' ); $st->execute(); return $st->fetchAll(PDO::FETCH_ASSOC); }
/ap/followers
: Endpoint to list followers
public function followersAction() { $actors = new ActorModel(); $followers = $actors->fetch_followers(); $this->view->set_format('json'); return [ '@context' => 'https://www.w3.org/ns/activitystreams', 'id' => 'https://imrannazar.com/ap/followers', 'type' => 'Collection', 'totalItems' => count($followers), 'items' => array_map(function($item) { return $item['id']; }, $followers), ]; }
/ap/following
: Users the blog is following
public function followingAction() { $this->view->set_format('json'); return [ '@context' => 'https://www.w3.org/ns/activitystreams', 'id' => 'https://imrannazar.com/ap/followers', 'type' => 'Collection', 'totalItems' => 0, 'items' => [], ]; }
With the followers
and following
endpoints in place, and a way to listen to and respond to Follow
-type messages, we now have a profile in the Fediverse (or at least on Mastodon) that can be followed. What happens when you press Unfollow on this profile?
As I've already gone on overly long for this post, we'll cover this and the other remaining operations in a third part; come back next time and we'll talk more about publishing, commenting and reactions.