How much ActivityPub can a Static Site Generator implement?

de en

There already are multiple blogging solutions which are part of the Fediverse. Among them are dedicated Fediverse blogs such as Plume or WriteFreely, but there also are plugins which retrofit existing CMS, e.g. for Wordpress or Drupal.

ActivityPub, the network protocol behind the Fediverse can only be fully implemented by means of an active server component: Among other things, incoming messages delivered to inboxes have to be processed. Sometimes they need to be forwarded, and outgoing messages need to be signed.

The basic mechanisms behind ActivityPub: Users publish resources in their outbox, and retrieve received resources from their inbox.
Figure 1: The basic mechanisms behind ActivityPub: Users publish resources in their outbox, and retrieve received resources from their inbox.

Nevertheless I wanted to figure out, which parts of the ActivityPub protocol can be implemented in a purely static website, and how well other servers in the Fediverse interact with it. My goal was to attach this blog to the Fediverse. The blog is generated using the static site generator software Pelican.

Metadata Endpoints

One prerequisite for implementing ActivityPub are multiple static metadata endpoints, with which a server signals whether it supports ActivityPub, and under which HTTP endpoints the various ActivityPub resources are reachable:

/.well-known/nodeinfo simply links to the "real" nodeinfo endpoint:

{
  "links": [
    {
      "href": "https://s3lph.me/activitypub/nodeinfo",
      "rel": "http://nodeinfo.diaspora.software/ns/schema/2.0"
    }
  ]
}

Behind the linked path (which can be chosen freely), the global metadata of this ActivityPub instance is published:

{
  "version": "2.0",
  "software": {
    "name": "pelican-activitypub",
    "version": "0.1"
  },
  "protocols": [
    "activitypub"
  ],
  "services": {
    "inbound": [],
    "outbound": [
      "atom1.0",
      "rss2.0"
    ]
  },
  "openRegistrations": false,
  "usage": {
    "users": {
      "total": 1
    },
    "localPosts": 27
  },
  "metadata": {
    "nodeName": "s3lph made"
  }
}

This JSON document describes the server, however we still need to discover the individual users of this instance. This is where the webfinger endpoint comes in. Usually it is found at /.well-known/webfinger, but some pieces of software insist on first resolving this path using the /.well-known/host-meta endpoint:

<?xml version="1.0" encoding="UTF-8"?>
<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
  <Link rel="lrdd" template="https://s3lph.me/.well-known/webfinger?resource={uri}" type="application/xrd+xml" />
</XRD>

Now we already stumbled over the fist obstacle preventing a purely static implementation: The name of the user to be resolved is passed as an URL parameter, which needs to be handled by a HTTP server.

There are two possible workarounds:

  • If there only is a single user on the server, the parameter can be ignored. Instead, always the same static response is returned, no matter the URL parameter.
  • If there are multiple users, we can generate a static webfinger endpoint per user. This requires the webserver to be configured to redirect the regular webfinger endpoint to these user specific endpoints.

For example, using the Apache webserver, this can be achieved like this:

RewriteEngine on
RewriteRule ^/.well-known/webfinger?resource=acct:([^@]+)@s3lph.me$ /.well-known/_webfinger/$1 [L]

The webfinger endpoint links to the actual user resources:

{
  "subject": "acct:s3lph@s3lph.me",
  "aliases": [
    "https://s3lph.me/author/s3lph.html",
    "https://s3lph.me/activitypub/users/s3lph"
  ],
  "links": [
    {
      "rel": "http://webfinger.net/rel/profile-page",
      "type": "text/html",
      "href": "https://s3lph.me/author/s3lph.html"
    },
    {
      "rel": "self",
      "type": "application/activity+json",
      "href": "https://s3lph.me/activitypub/users/s3lph"
    }
  ]
}

In this case, the author @s3lph@s3lph.me is resolved to two alias URLs: The author's feed in the blog, as well as the ActivityPub Person resource.

ActivityPub: Persons and Articles

Now that we can resolve usernames such as @s3lph@s3lph.me to ActivityPub URLs such as https://s3lph.me/activitypub/users/s3lph, we can take a closer look at the Person resource behind this URL:

{
  "@context": [
    "https://www.w3.org/ns/activitystreams",
    {
      "schema": "http://schema.org#",
      "toot": "http://joinmastodon.org/ns#",
      "PropertyValue": "schema:PropertyValue",
      "value": "schema:value",
      "alsoKnownAs": {
        "@id": "as:alsoKnownAs",
        "@type": "@id"
      },
      "movedTo": {
        "@id": "as:movedTo",
        "@type": "@id"
      },
      "discoverable": "toot:discoverable"
    }
  ],
  "type": "Person",
  "id": "https://s3lph.me/activitypub/users/s3lph",
  "preferredUsername": "s3lph",
  "url": "https://s3lph.me/author/s3lph.html",
  "name": "s3lph made",
  "summary": "This is an EXPERIMENTAL implementation for a read-only ActivityPub feed of my blog.",
  "icon": {
    "type": "Image",
    "mediaType": "image/png",
    "url": "https://s3lph.me/favicon.ico"
  },
  "image": {},
  "tag": [],
  "attachment": [
    {
      "type": "PropertyValue",
      "name": "Web",
      "value": "<a href=\"https://s3lph.me\">s3lph.me</a>"
    },
    {
      "type": "PropertyValue",
      "name": "Mastodon",
      "value": "<a rel=\"me\" href=\"https://chaos.social/@s3lph\">@s3lph@chaos.social</a>"
    },
    {
      "type": "PropertyValue",
      "name": "Matrix",
      "value": "<a rel=\"me\" href=\"https://mto.kabelsalat.ch/#/@s3lph:kabelsalat.ch\">@s3lph:kabelsalat.ch</a>"
    }
  ],
  "movedTo": "https://chaos.social/users/s3lph",
  "alsoKnownAs": [
    "https://chaos.social/users/s3lph"
  ],
  "inbox": "https://s3lph.me/activitypub/collections/inbox/s3lph",
  "outbox": "https://s3lph.me/activitypub/collections/outbox/s3lph",
  "following": "https://s3lph.me/activitypub/collections/following/s3lph",
  "followers": "https://s3lph.me/activitypub/collections/followers/s3lph",
  "discoverable": true,
  "manuallyApprovesFollowers": true,
  "published": "2020-02-05T01:36:00+01:00",
  "updated": "2022-11-12T14:05:23Z",
  "endpoints": {
    "sharedInbox": "https://s3lph.me/activitypub/collections/inbox/s3lph"
  },
}

This resource is a bit bigger than before, so let's look at it piece by piece. At first the schema of this JSON document is described:

{
  "@context": [
    "https://www.w3.org/ns/activitystreams",
    {
      "schema": "http://schema.org#",
      "toot": "http://joinmastodon.org/ns#",
      "PropertyValue": "schema:PropertyValue",
      "value": "schema:value",
      "alsoKnownAs": {
        "@id": "as:alsoKnownAs",
        "@type": "@id"
      },
      "movedTo": {
        "@id": "as:movedTo",
        "@type": "@id"
      },
      "discoverable": "toot:discoverable"
    }
  ],

Next the basic information about the person are described. These are used by ActivityPub-Clients to display the user's profile page:

  "type": "Person",
  "id": "https://s3lph.me/activitypub/users/s3lph",
  "preferredUsername": "s3lph",
  "url": "https://s3lph.me/author/s3lph.html",
  "name": "s3lph made",
  "summary": "This is an EXPERIMENTAL implementation for a read-only ActivityPub feed of my blog...",
  "icon": {
    "type": "Image",
    "mediaType": "image/png",
    "url": "https://s3lph.me/favicon.ico"
  },
  "image": {},

Some additional metadata can be provided:

  "tag": [],
  "attachment": [
    {
      "type": "PropertyValue",
      "name": "Web",
      "value": "<a href=\"https://s3lph.me\">s3lph.me</a>"
    },
    {
      "type": "PropertyValue",
      "name": "Mastodon",
      "value": "<a rel=\"me\" href=\"https://chaos.social/@s3lph\">@s3lph@chaos.social</a>"
    },
    {
      "type": "PropertyValue",
      "name": "Matrix",
      "value": "<a rel=\"me\" href=\"https://mto.kabelsalat.ch/#/@s3lph:kabelsalat.ch\">@s3lph:kabelsalat.ch</a>"
    }
  ],
  "movedTo": "https://chaos.social/users/s3lph",
  "alsoKnownAs": [
    "https://chaos.social/users/s3lph"
  ],

"PropertyValue" attachments are rendered as tables on the user's profile page by most ActivityPub clients. tags list attributes such as hashtags and mentions of other users within the profile's description, so that they can be easily indexed by other servers in the Fediverse.

movedTo and alsoKnownAs are used to indicate that the account has moved, and the new account should be followed instead.

With all of this, we get a user profile that can be viewed on Fediverse client applications, such as the Mastodon app "Tusky":

The profile of @s3lph@s3lph.me, as shown in the Mastodon app Tusky.
Figure 2: The profile of @s3lph@s3lph.me, as shown in the Mastodon app Tusky

Finally, the URLs to linked ActivityPub resources have to be provided:

  "inbox": "https://s3lph.me/activitypub/collections/inbox/s3lph",
  "outbox": "https://s3lph.me/activitypub/collections/outbox/s3lph",
  "following": "https://s3lph.me/activitypub/collections/following/s3lph",
  "followers": "https://s3lph.me/activitypub/collections/followers/s3lph",

I've already mentioned the relevance of the inbox and outbox before. Since this implementation does not process inboxes, there only is an empty colllection (list of ActivityPub resources) behind the inbox URL. The same goes for the "following" and "followers" collections:

{
  "@context": [
    "https://www.w3.org/ns/activitystreams"
  ],
  "type": "OrderedCollection",
  "id": "https://s3lph.me/activitypub/collections/inbox/s3lph",
  "totalItems": 0,
  "orderedItems": []
}

The outbox is a collection as well, containing the articles published by this user. One of these articles (actually this one) looks like this:

{
  "@context": [
    "https://www.w3.org/ns/activitystreams"
  ],
  "type": "Article",
  "id": "https://s3lph.me/activitypub/posts/activitypub-static-site",
  "published": "2022-11-17T02:00:00+01:00",
  "inReplyTo": null,
  "url": "https://s3lph.me/activitypub-static-site-de.html",
  "attributedTo": "https://s3lph.me/activitypub/users/s3lph",
  "to": [
    "https://www.w3.org/ns/activitystreams#Public"
  ],
  "cc": [
    "https://s3lph.me/activitypub/collections/followers/s3lph",
    "https://chaos.social/users/s3lph"
  ],
  "name": "How much ActivityPub can a Static Site Generator implement?",
  "nameMap": {
    "en": "How much ActivityPub can a Static Site Generator implement?",
    "de": "Wie viel ActivityPub kann ein Static Site Generator?"
  },
  "content": "<p>There already are multiple blogging solutions...",
  "contentMap": {
    "en": "<p>There already are multiple blogging solutions...",
    "de": "<p>Es gibt bereits verschiedene Blog-Lösungen...",
  },
  "summary": null,
  "attachment": [],
  "tag": [
    {
      "type": "Hashtag",
      "name": "#activitypub",
      "href": "https://s3lph.me/activitypub/tags/activitypub"
    },
    {
      "type": "Hashtag",
      "name": "#pelican",
      "href": "https://s3lph.me/activitypub/tags/pelican"
    },
    {
      "type": "Mention",
      "href": "https://chaos.social/users/s3lph",
      "name": "@s3lph@chaos.social"
    }
  ]
}

The beginning of an article is more or less equal to that of a person, so I won't be repeating it here.

Next the relationships to other resources are described, e.g. who wrote the article, and to whom it is addressed. The special URL https://www.w3.org/ns/activitystreams#Public describes that the article is public, and should e.g. be listed in global timelines:

  "inReplyTo": null,
  "url": "https://s3lph.me/activitypub-static-site-de.html",
  "attributedTo": "https://s3lph.me/activitypub/users/s3lph",
  "to": [
    "https://www.w3.org/ns/activitystreams#Public"
  ],
  "cc": [
    "https://s3lph.me/activitypub/collections/followers/s3lph",
    "https://chaos.social/users/s3lph"
  ],

Title and content of the article can be multilingual. However, the contentMap und nameMap attributes are only supported by a few ActivityPub servers or clients. Most others simply always show the untranslated default content:

  "name": "How much ActivityPub can a Static Site Generator implement?",
  "nameMap": {
    "en": "How much ActivityPub can a Static Site Generator implement?",
    "de": "Wie viel ActivityPub kann ein Static Site Generator?"
  },
  "content": "<p>There already are multiple blogging solutions...",
  "contentMap": {
    "en": "<p>There already are multiple blogging solutions...",
    "de": "<p>Es gibt bereits verschiedene Blog-Lösungen...",
  },
  "summary": null,

Finally, same as with persons, additional data like tags and mentions of other users are listed in a machine readable form:

  "attachment": [],
  "tag": [
    {
      "type": "Hashtag",
      "name": "#activitypub",
      "href": "https://s3lph.me/activitypub/tags/activitypub"
    },
    {
      "type": "Hashtag",
      "name": "#pelican",
      "href": "https://s3lph.me/activitypub/tags/pelican"
    },
    {
      "type": "Mention",
      "href": "https://chaos.social/users/s3lph",
      "name": "@s3lph@chaos.social"
    }
  ]

Sow now we have modelled authors and their articles as ActivityPub resources. This is also everything which can be reasonably implemented as a purely static site.

The code for generating these ActivityPub resources is available as a Pelican plugin. However, before you go ahead and install this on your own Pelican blog, I'd advise you to finish reading this article.

Compatibility to Fediverse Services

For testing my implementation, I set up three different Fediverse instances to interact with my blog: Mastodon, Pleroma and Misskey.

All three services are able to retrieve and show the profile @s3lph@s3lph.me, and the number of blog articles is shown. However, the articles themselves are not shown. As it turns out, articles from other instances are usually not retrieved automatically.

The articles would only be shown if they were POSTed from their origin instance to the target instance. Alternatively, all three test instances were able to show the articles by pasting the article's URL into the search bar. Afterwards these articles were also shown in the timeline of their author.

Unfortunately, this is almost everything that can be achieved with a purely static implementation. Especially the following important functions are not available:

  • Following: If Alice wants to follow the Fediverse account of Bob, she has to send a follow request to Bob. Bob (or his instance) has to confirm this request before it becomes effective.
  • Replies: If Alice wants to reply to a message of Bob, her response is only visible on Alice's instance at first. The response will also be posted to the inbox of Bob, and it would be in his instance's responsibility to forward the reply to all other involved instances. This crucial step is not possible with a static ActivityPub endpoint.
  • Deletion: If an article ha been cached by another instance, it is usually kept in the cache until it's explicitly deleted by its origin instance. For this, a cryptographically signed deletion request would have to be submitted to the instance's inbox.
  • Updates: The same limitations that apply to deletions apply to updates as well. In fact, this list item will never show up on instances that already have an earlier version of this article in their cache.

What does work are likes and boosts, tough they do not increment the counters under an article.

Additionally, it turns out that e.g. Mastodon only shows a link preview to the article, since the Article object type is not fully supported; the Note type is used for short messages. However, both Pleroma and Misskey show the full article natively.

Conclusion

The question "How much ActivityPub can a Static Site Generator implement?" can thus be best answered as "quite a lot, but not enough to be relevant in practice." Even for this blog, the purely static ActivityPub implementation will most likely not have any relevance, however the articles will presumably keep being published on the Fediverse in this limited form.

PS: If you're reading this article on the Fediverse and want to write a reply, please (additionally) address it to @s3lph@chaos.social, otherwise I won't see it.