ActivityPub Event Handling in PHP and MySQL

Display mode

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:

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:

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:

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 device
  return 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.):

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 here
  public 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 notes
  if (!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 null
  if ($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 mention
  else {
    $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 connector

  public 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.