ActivityPub Follows in PHP

Display mode

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:

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:

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:

Having done all this, the remote instance is able to draw up a page that looks a little like this:

Screenshot of this blog's profile page
Figure 2: Mastodon profile page for this blog

(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 perform
      throw $e;
    }

    // No response required except for a blank 200
    die();
  }
}

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 connector

  protected $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.