Back to Blogging in the Fediverse
Last time in this short series, we looked at how a blog such as this one could become discoverable through ActivityPub, and how a Mastodon user could send a Follow request; we also saw how the blog could respond to a Follow with an Accept message. What we didn't get as far as covering was the message that arrives at the blog when a user unfollows, or the other events that can happen relating to the blog. To recap what we missed on the list from last time:
- 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.
Now that our blog has a follower (or in an ideal world, more than one) in the database, let's first look at how one may get posts into their timeline.
Publishing posts
Each item in a timeline is a Note
object, so to inform our followers that we've created a note we need to send a Create
object which wraps a Note
object. There are a few things to keep in mind regarding the Note
we're creating, however:
- Canonical URL: Each item in ActivityPub has a unique
id
, and notes in particular have aurl
which serves aNote
object when hit. We'll need an endpoint on the blog which can detect ActivityPub requests and serve the object in question; this can be at the same URL as the human-readable blog post itself or a different URL. - Visibility: We can tune the visibility of a post on the Fediverse by stating the destinations in the note's
to
field. In our case, we're not looking to make private posts or those visible only to our followers, so we'll be sending to:https://www.w3.org/ns/activitystreams#Public
- Content mapping: ActivityPub allows for translations of the
content
of a Note to be made available in acontentMap
field; in the case of our little blog, the content is only available in English, so thecontentMap
contains anen
key which holds the same information as thecontent
itself.
With this in mind, we can start to put things together. Our first piece of the puzzle is a layout helper for Note
objects:
Note object helper
private function noteFormatter($item) { $content = join('', [ '<h1>', $item['title'], '</h1>', $item['content_html'], ]); return [ '@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://imrannazar.com/ap/item?id=' . $item['id'], 'url' => 'https://imrannazar.com/ap/item?id=' . $item['id'], 'atomUri' => 'https://imrannazar.com/ap/item?id=' . $item['id'], 'type' => 'Note', 'attributedTo' => 'https://imrannazar.com/ap/blog', 'to' => ['https://www.w3.org/ns/activitystreams#Public'], 'cc' => ['https://imrannazar.com/ap/followers'], 'attachment' => [], 'published' => date( 'Y-m-d\TH:i:s\Z', strtotime($item['created'])), 'content' => $content, 'contentMap' => ['en' => $content], 'summary' => null, 'sensitive' => false, ]; }
Then we can build out the publish action itself, which will need to send the note (wrapped in a Create
object, as mentioned above) to each of the blog's followers:
Controller: Publish action
public function publishAction($item) { $msg = [ 'type' => 'Create', 'to' => ['https://www.w3.org/ns/activitystreams#Public'], 'cc' => ['https://imrannazar.com/ap/followers'], 'object' => $this->noteFormatter($item), ]; $actors = new ActorModel(); $followers = $actors->fetch_followers(); foreach ($followers as $follower) { $this->send($follower['id'], $msg); } }
There is an inefficiency in this naive approach to publication, though. Let's say the blog has three followers, as follows:
- Two9A@hachyderm.io
- alice@tech.lgbt
- charlie@hachyderm.io
With the above code, we'll be connecting to Hachyderm twice to send the same event. Mastodon supports "shared inboxes" as discussed in the first part of this series[1]Imran Nazar, "Implementing HTTP Signatures with PHP and OpenSSL", May 2024, to allow us to send the event once for each server that hosts any of our followers; to get the URLs for these shared inboxes, we'll need to interrogate the actor objects as stored in our database to extract the sharedInbox
endpoints.
Model: Shared inbox extraction
public function get_follower_shared_inboxes() { $st = $this->dbc->prepare('SELECT full_data FROM ap_actors WHERE follows_us=1 ORDER BY id'); $st->execute(); $inboxes = []; foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $row) { $actor = json_decode($row['full_data'], true); if (isset($actor['endpoints'], $actor['endpoints']['sharedInbox'])) { $inboxes[$actor['endpoints']['sharedInbox']] = true; } else {// Some instances don't support shared inboxes$inboxes[$actor['inbox']] = true; } }// Use PHP's associative arrays as a deduping devicereturn array_keys($inboxes); }
Controller: Updated publish action
public function publishAction($item) { $msg = [ 'type' => 'Create', 'to' => ['https://www.w3.org/ns/activitystreams#Public'], 'cc' => ['https://imrannazar.com/ap/followers'], 'object' => $this->noteFormatter($item), ]; $actors = new ActorModel(); $inboxes = $actors->get_follower_shared_inboxes(); foreach ($inboxes as $inbox) { $this->send($inbox, $msg); } }
Now, loading /ap/publish
with the item you're looking to serve will send it out to the blog's followers. Pulling the item itself from the blog's database is left as an exercise for the reader, as the data structure of the blog will invariably vary.
Storing comments
When the newly published post appears on our followers' timelines, they may wish to reply to the post from within Mastodon. Doing this will, to no-one's surprise, cause an event analogous to our own publication of a Note
to arrive at the blog, as the reply will be treated by ActivityPub as a Note
in turn.
There are three circumstances in which a comment can arrive at our blog[A](There are also second- or subsequent-level replies to one of the blog's posts, that don't mention the blog account directly. As these notes aren't directly in reply to our account, and don't mention the account, they will not be delivered to us as ActivityPub doesn't keep track of the root-level conversation ID.):
- Comment on a post: These
Note
objects will have aninReplyTo
value that matches theid
of the blog article we published. Looking above, we see that our publish action sends outNote
objects with anid
starting:https://imrannazar.com/ap/item?id=
, so any comments that arrive with this in theirinReplyTo
can be treated as top-level replies. - Reply to a comment that mentions the blog: Similarly to top-level comments, these objects will have a
inReplyTo
, but it will reference a previous comment'sid
. ActivityPub as implemented by Mastodon doesn't directly provide a "conversation ID", so we'll need to keep track of top-level replies and check theinReplyTo
of an incoming comment to see if we've already stored it as a top-level comment'sid
. - Mention that isn't on a post: This will be delivered to us with no
inReplyTo
, so will need to be stored separately in some fashion.
Any comments that arrive at our blog will have an attributedTo
value which will allow us to retrieve data about the user who wrote the comment, so we can show the user's preferred name and avatar against the comment once we have it stored. Given that, we've already seen all the objects that will be involved: Note
objects for the comments, and Actor
objects for the user data, so let's look at the database table and model.
Comments table in MySQL
CREATE TABLE ap_comments( id VARCHAR(255) NOT NULL PRIMARY KEY, page_id INT NOT NULL, actor_id VARCHAR(255) NOT NULL, in_reply_to VARCHAR(255) NOT NULL, comment_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP(), content TEXT, full_data TEXT );
Comment data model
class CommentModel { protected $db;// PDO MySQL connector// The inbox handler will parse out all the data we need herepublic function insert($data) { $st = $this->db->prepare( 'INSERT INTO ap_comments( id, page_id, actor_id, in_reply_to, content, full_data ) VALUES( :id, :page_id, :actor_id, :in_reply_to, :content, :full_data )' ); foreach ($data as $k => $v) { $st->bindValue(":{$k}", $v); } $st->execute(); } public function fetch($id) { $st = $this->db->prepare( 'SELECT * FROM ap_comments WHERE id=:id' ); $st->bindValue(':id', $id); $st->execute(); return $st->fetch(PDO::FETCH_ASSOC); } }
The model here is a simple wrapper on the table, to support insertion of new comments. The controller's handler for receiving Create
events is only slightly more complicated, as it needs to deal with the three cases mentioned above:
Inbox handler for comment creation
public function createHandler($input) {// For this implementation we're only handling creation of notesif (!isset($input['object'], $input['object']['type'])) { throw new Exception('Malformed create message'); } if ($input['object']['type'] !== 'Note') { throw new Exception('Unsupported create message'); } $cm = new CommentModel(); $comment = $input['object'];// First, store the actor's data // As per the previous article, this will fetch from the remote server // if we don't already have the actor in our database$actor = new ActorModel($comment['attributedTo']); $comment_data = [ 'id' => $comment['id'], 'actor_id' => $comment['attributedTo'], 'content' => $comment['content'], 'full_data' => json_encode($comment), ]; $parent = parse_url($comment['inReplyTo']);// If the parent is the blog post, we can infer the page ID // This also means the comment is top-level; in_reply_to is nullif ($parent['host'] === 'imrannazar.com') { parse_str($parent['query'], $parent_q); if (!isset($parent_q['id'])) { throw new Exception('Mistargeted comment'); } $comment_data += [ 'page_id' => $parent_q['id'], 'in_reply_to' => null, ]; }// Otherwise, we're receiving this because it's a // reply to an existing comment, or a mentionelse { $parent_comment = $cm->get($comment['inReplyTo']); if (!$parent_comment) {// Not associated with a particular page$comment_data += [ 'page_id' => 0, 'in_reply_to' => null, ]; } else { $comment_data += [ 'page_id' => $parent_comment['page_id'], 'in_reply_to' => $comment['inReplyTo'], ]; } } $cm->insert($comment_data); }
Now we have a continual stream of comments and replies coming to our blog, we'll want a way to fetch and display them against a given post. Fortunately, storing both the page and parent comment IDs separately makes the data extraction relatively easy; displaying the comments is a matter of iterating over the top-level comments returned and recursing into the children
, and won't be covered here as the handling is specific to a given blog's display implementation.
Comment model: Fetch comments for a page
public function fetch_for_page($page_id) { $st = $this->db->prepare( 'SELECT c.id, c.in_reply_to, c.content, c.comment_date, c.actor_id, a.url, a.name, a.avatar_url FROM ap_comments c LEFT JOIN ap_actors a ON c.actor_id = a.id WHERE c.page_id = :page_id ORDER BY c.comment_date' ); $st->bindValue(':page_id', $page_id); $st->execute(); $data = $st->fetchAll(PDO::FETCH_ASSOC);// Build the tree by adding child comments as references$comments_by_id = []; foreach ($data as $c) { $comments_by_id[$c['id']] = $c + ['children' => []]; if ($c['in_reply_to']) { $comments_by_id['in_reply_to']['children'][] = &$comments_by_id[$c['id']]; } } return $comments_by_id; }
It's also possible for a user to delete their comment once it's been left, and we'll want our blog's view to reflect that; the deletion of the comment will arrive at our blog as a Delete
event. One thing to note here is that we're building a tree view of comments, but a comment can be deleted at any level of the tree and will still need to be made available as a place to hold its children; we can accomplish this by sanitising the comment content at the time of deletion, allowing fetch_for_page
above to continue unchanged.
Comment model: Sanitisation
public function sanitise($id) { $st->execute( 'UPDATE ap_comments SET content=NULL, actor_id=NULL, full_data=NULL WHERE id=:id' ); $st->bindValue(':id', $id); $st->execute(); }
Controller: Delete handler
public function deleteHandler($input) { $cm = new CommentModel(); if ($cm->fetch($input['object']['id'])) { $cm->sanitise($input['object']['id']); } }
Handling reactions
Replying isn't the only thing that can happen to a post on Mastodon; it can also be favourited (or starred) and boosted by a user into their followers' timelines. Both of these are issued as ActivityPub events: favourites translate into ActivityPub Like
events, and boosts into Announce
events. As these both relate to pages on our blog, we can store them in a generic "interactions" table.
Unlike Create
events which we've both seen and generated before, we haven't seen these new events, so let's have a look at an example of a Like
event:
{ "@context": "https://www.w3.org/ns/activitystreams", "id": "https://mastodon.social/users/0110100001101001#likes/183241819", "type": "Like", "actor": "https://mastodon.social/users/0110100001101001", "object": "https://imrannazar.com/ap/item?id=89" }
It turns out that Like
and Announce
events are fairly simple: the fields with which we need to be concerned are the actor and object, where the object is a canonical URL for one of the blog's pages. We can use similar URL parsing code to that which we used for comments, to pull out the page ID and store interactions against it in a new database table; we can then use that table to pull individual events and aggregate counts for a given page.
Interactions table in MySQL
CREATE TABLE ap_interactions( page_id INT NOT NULL, actor_id VARCHAR(255) NOT NULL, interaction ENUM('Like', 'Announce') NOT NULL, event_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP() );
Interaction model
class InteractionModel { protected $db;// PDO MySQL connectorpublic function save($type, $page_id, $actor_id) { $st = $this->db->prepare( 'INSERT INTO ap_interactions(interaction, page_id, actor_id) VALUES(:type, :page_id, :actor_id)' ); $st->bindValue(':type', $type); $st->bindValue(':page_id', $page_id); $st->bindValue(':actor_id', $actor_id); $st->execute(); } public function counts_for_page($page_id) { $st = $this->db->prepare( 'SELECT interaction, COUNT(*) AS cnt FROM ap_interactions WHERE page_id=:page_id GROUP BY interaction' ); $st->bindValue(':page_id', $page_id); $st->execute(); return $st->fetchAll(PDO::FETCH_ASSOC); } public function details_for_page($page_id) { $st = $this->db->prepare( 'SELECT i.type, i.event_date, a.url, a.name, a.avatar_url FROM ap_interactions i LEFT JOIN ap_actors a ON i.actor_id = a.id WHERE i.page_id = :page_id' ); $st->bindValue(':page_id', $page_id); $st->execute(); return $st->fetchAll(PDO::FETCH_ASSOC); } }
Controller: Interaction handlers
protected function interactionHandler($input) { $page = parse_url($input['object']); if ($page['host'] !== 'imrannazar.com') { throw new Exception('Mistargeted interaction'); } parse_str($page['query'], $page_q); if (!isset($page_q['id'])) { throw new Exception('Mistargeted interaction'); } $im = new InteractionModel(); $im->save($input['type'], $page_q['id'], $input['actor']); } public function likeHandler($input) { $this->interactionHandler($input); } public function announceHandler($input) { $this->interactionHandler($input); }
Just as a user can decide to delete their comment on one of our posts, it's also possible to undo a favourite or boost in Mastodon; this will send an Undo
event to us, where the object
is a copy of the original interaction event. Unlike comments, however, there's no need for us to retain context of the interaction, so we can simply delete it from our database when the Undo
comes in.
Interaction model: Delete an interaction
public function delete($type, $page_id, $actor_id) { $st = $this->db->prepare( 'DELETE FROM ap_interactions WHERE type=:type AND page_id=:page_id AND actor_id=:actor_id' ); $st->bindValue(':type', $type); $st->bindValue(':page_id', $page_id); $st->bindValue(':actor_id', $actor_id); $st->execute(); }
Controller: Undo handler
public function undoHandler($input) { if (!isset($input['object'], $input['object']['type'])) { throw new Exception('Malformed undo message'); } switch ($input['object']['type']) { case 'Like': case 'Announce':// The original page being interacted with // is the Undo's object (the interaction)'s object$page = parse_url($input['object']['object']); if ($page['host'] !== 'imrannazar.com') { throw new Exception('Mistargeted interaction'); } parse_str($page['query'], $page_q); if (!isset($page_q['id'])) { throw new Exception('Mistargeted interaction'); } $im = new InteractionModel(); $im->delete( $input['object']['type'], $page_q['id'], $input['actor'] ); break; default:// Unhandled} }
And finally we come to the question I posed as the start: what happens when a user unfollows the blog? As it turns out, the Follow
event is undone: an Undo
event is sent to us. We just built out an Undo
handler for interactions, so we can add events where the object is a Follow
:
Controller: Undo handler for follows
public function undoHandler($input) { ... switch ($input['object']['type']) { ... case 'Follow': $actor = new ActorModel($input['actor']); $actor->set_follows_us(false); break; } }
All done: an ActivityPub-enabled blog
As Eugen put it in his article on HTTP Signatures[2]Eugen Rochko, "How to make friends and verify requests", Jul 2018 cited in the first part of this series:
Primarily this means having a publicly accessible inbox and validating HTTP signatures. Once that works, everything else is just semantics.
It took us two articles to get through the implementation of those semantics, but we're out the other side, and we should now have the framework of an ActivityPub-enabled blog: articles can be published on the Fediverse, and users can leave comments and interactions. Having those interactions display on the blog is, as mentioned earlier, an exercise for the reader and their particular blog framework.