Wie viel ActivityPub kann ein Static Site Generator?

de en

Es gibt bereits verschiedene Blog-Lösungen, die Teil des Fediverse sind: Darunter sind sowohl dedizierte Fediverse-Blogs wie Plume und WriteFreely, aber es gibt auch Plugins, die bestehende CMS nachrüsten, z.B. für Wordpress oder für Drupal.

ActivityPub, das Netzwerk-Protokoll hinter dem Fediverse, lässt sich nur mit einer aktiven Serverkomponente vollständig umsetzen: Unter anderem muss auf eingehende Nachrichten in den Inboxen reagiert werden, teilweise müssen diese auch an andere Server weitergeleitet werden, und ausgehende Nachrichten müssen zeitnah signiert werden.

Die grundsätzlichen Mechanismen hinter ActivityPub: User veröffentlichen Ressourcen in ihrer eigenen Outbox, und rufen empfangene Ressourcen aus ihrer Inbox ab.
Abbildung 1: Die grundsätzlichen Mechanismen hinter ActivityPub: User veröffentlichen Ressourcen in ihrer eigenen Outbox, und rufen empfangene Ressourcen aus ihrer Inbox ab.

Trotzdem wollte ich herausfinden, welche Teile des ActivityPub-Protokolls mit einer rein statischen Website implementiert werden können, und wie gut andere Server im Fediverse damit umgehen können. Mein Ziel war, meinen Blog hier ans Fediverse zu hängen. Der Blog wird mit der Static Site Generator-Software Pelican erzeugt.

Metadaten-Endpunkte

Eine Grundvoraussetzung, um ActivityPub zu implementieren, sind diverse statische Metadaten-Endpunkte, mit denen der Server signalisiert, dass er ActivityPub unterstützt, und unter welchen HTTP-Endpunkten die verschiedenen ActivityPub-Ressourcen zu finden sind:

/.well-known/nodeinfo verlinkt einfach nur auf den "echten" nodeinfo-Endpunkt:

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

Unter dem verlinkten Pfad (der frei gewählt werden kann), werden die globalen Metadaten der ActivityPub-Instanz publiziert:

{
  "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"
  }
}

Mit diesem JSON-Dokument wird der Server an sich beschrieben, nun müssen aber noch die einzelnen User der Instanz gefunden werden. Hierzu dient der webfinger-Endpunkt. Dieser ist zwar üblicherweise unter /.well-known/webfinger zu finden, aber einzelne Softwarelösungen bestehen trotzdem darauf, den Pfad erst unter einem anderen Endpunkt nachzuschlagen, und zwar unter /.well-known/host-meta:

<?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>

Hier sehen wir schon das erste Hindernis, das eine rein statische Implementation verhindert: Der Name des aufzulösenden Users wird als URL-Parameter übergeben, der durch einen HTTP-Server behandelt werden muss.

Hierzu gibt es zwei mögliche Lösungen:

  • Wenn es auf dem Server nur einen einzelnen User gibt, kann der Parameter eigentlich ignoriert werden. Stattdessen wird einfach immer die gleiche, statische Antwort zurückgegeben.
  • Wenn es mehrere User gibt, kann für jeden User ein statischer webfinger-Endpunkt generiert werden. Dies setzt aber voraus, dass der Webserver konfiguriert wird, den eigentlichen webfinger-Endpunkt entsprechend umgeleitet werd.

Dies lässt sich z.B. im Apache-Webserver so umsetzen:

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

Der webfinger-Endpunkt verlinkt nun weiter auf die tatsächlichen User-Ressourcen:

{
  "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 diesem Fall wird der Autor, in diesem Fall @s3lph@s3lph.me auf zwei Alias-URLs aufgelöst: Der Autoren-Feed im Blog, sowie die Person ActivityPub-Ressource.

ActivityPub: Personen und Artikel

Jetzt, wo wir einen Username wie @s3lph@s3lph.me zu einer ActivityPub-URL wie https://s3lph.me/activitypub/users/s3lph auflösen können, können wir die Personen-Ressource unter dieser URL genauer anschauen:

{
  "@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"
  },
}

Diese Ressource ist schon ein gutes Stück grösser, daher schauen wir das am besten Stück für Stück an. Zuerst wird das Schema des JSON-Dokuments beschrieben:

{
  "@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"
    }
  ],

Als nächstes werden die grundlegenden Informationen über die Person beschrieben. Diese werden von ActivityPub-Clients benutzt, um die User-Seite darzustellen:

  "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": {},

Zusätzlich können noch einige weitere Metadaten angegeben werden:

  "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"
  ],

Attachments vom Typ "PropertyValue" werden z.B. von den meisten ActivityPub-Clients als Tabelle im Profil des Users dargestellt. Unter tags werden z.B. Hashtags oder Erwähnungen von anderen Usern im Profil des Users aufgelistet, damit diese von anderen Servern im Fediverse indexiert werden.

Mit movedTo und alsoKnownAs wird angegeben, dass der Account umgezogen ist, und andere User dem neuen Profil folgen sollen.

Damit kommt ein Profil zustande, das mit Fediverse-Clients aufgerufen werden kann. So zum Beispiel in der Mastodon-App Tusky:

Das Profil von @s3lph@s3lph.me, dargestellt in der Mastodon-App Tusky.
Abbildung 2: Das Profil von @s3lph@s3lph.me, dargestellt in der Mastodon-App Tusky.

Schlussendlich müssen noch die URLs zu den verknüpften ActivityPub-Ressourcen angegeben werden:

  "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",

Die Bedeutung von Inbox und Outbox wurden vorhin schon kurz erwähnt. Da in dieser Implementation keine Inboxen verarbeitet werden, steckt hinter der Inbox einfach nur eine leere "Collection" (Liste aus ActivityPub-Ressourcen). Das gleiche gilt für die "following"- und "followers"-Collections:

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

Die Outbox ist ebenfalls eine Collection, die die von diesem User veröffentlichten Artikel enthält. Ein solcher (dieser) Artikel sieht z.B. so aus:

{
  "@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"
    }
  ]
}

Der Anfang eines Artikels sieht mehr oder weniger gleich aus wie bei einer Person, daher wiederhole ich das hier nicht nochmal.

Als nächstes wird die Beziehung zu anderen Ressourcen angegeben, z.B. wer den Artikel verfasst hat, und an wen der Artikel adressiert ist. Die spezielle URL https://www.w3.org/ns/activitystreams#Public beschreibt, dass der Artikel öffentlich ist und z.B. in globalen Timelines aufgeführt werden soll:

  "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"
  ],

Titel und Inhalt des Artikels können mehrsprachig angegeben werden. Allerdings werden contentMap und nameMap nur von wenigen ActivityPub-Servern implementiert. Die meisten Server zeigen einfach immer den unübersetzten Standard-Inhalt an:

  "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,

Schlussendlich werden - genau wie bei Personen auch - weitere Daten wie Tags oder Erwähnungen anderer Personen in maschinenlesbar aufgeführt:

  "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"
    }
  ]

Damit haben wir Autoren und deren Artikel vollständig als ActivityPub-Ressourcen abgebildet. Damit ist auch alles implementiert, was sich sinnvoll als rein statische Seite implementieren lässt.

Der Code, um diese ActivityPub-Ressourcen zu erzeugen, ist als Pelican-Plugin verfügbar. Bevor irgendjemand das Plugin in das eigene Pelican einbaut, würde ich aber dazu raten, diesen Artikel bis zum Ende zu lesen.

Kompatibilität mit Fediverse-Diensten

Zum Testen habe drei verschiedenen Fediverse-Testinstanzen aufgesetzt, um mit meinem Blog zu interagieren: Mastodon, Pleroma und Misskey.

Mit allen drei Diensten kann das Profil @s3lph@s3lph.me aufgerufen werden. Auch die Anzahl an Blogartikeln wird korrekt dargestellt, allerdings werden die Artikel selbst nicht angezeigt. Wie sich herausstellt, werden Artikel von anderen Instanzen üblicherweise nicht automatisch geladen.

Die Artikel würden nur dann angezeigt, wenn sie von ihrer Ursprungsinstanz in die Inbox der Zielinstanz gePOSTed werden. Alternativ war es aber bei allen drei Testinstanzen möglich, die URL des Artikels in der Suche einzugeben und so aufzurufen. Danach wird der jeweilige Artikel auch in der Timeline des Autors angezeigt.

Leider ist dies eigentlich auch schon alles, dass mit einer rein statischen Implementation wirklich funktioniert. Insbesondere die folgenden - doch recht zentralen - Funktionen sind nicht verfügbar:

  • Folgen: Wenn Alice im Fediverse dem Account vom Bob folgen möchte, sendet sie eine Follow Request in die Inbox von Bob. Bob (resp. Bob's Instanz) muss diese Anfrage allerdings erst bestätigen.
  • Antworten: Wenn Alice auf eine Nachricht von Bob antwortet, ist diese Antwort zunächst nur auf der Instanz von Alice sichtbar. Die Antwort wird auch in die Inbox von Bob zugestellt, und Bobs Instanz wäre dafür zuständig, die Antwort an alle anderen involvierten Instanzen weiterzuleiten. Dieser Schritt bleibt im statisch generierten Fall aber aus.
  • Löschen: Wenn ein Artikel im Cache einer anderen Instanz ist, wird er dort üblicherweise behalten, bis er von der Ursprungsinstanz explizit gelöscht wird. Hierzu muss eine signierte Löschanfrage an die Inbox der anderen Instanz gesendet werden.
  • Aktualisieren: Für Änderungen an Artikeln gelten die gleichen Einschränkungen wie für das Löschen von Artikeln. So wird dieses Listenelement niemals auf Instanzen auftauchen, die bereits eine ältere Version des Artikels in ihrem Cache haben.

Was hingegen funktioniert sind Likes und Boosts, allerdings werden diese nicht in den Zählern unter den Artikeln reflektiert.

Zudem stellt z.B. Mastodon nur einen Link zum Artikel dar, da die hier verwendeten Article-Objekte nicht vollständig unterstützt werden; für Kurznachrichten wird der Objecttyp Note verwendet. Sowohl Pleroma als auch Misskey können den Artikel aber vollständig nativ darstelle.

Fazit

Die Frage «Wie viel ActivityPub kann ein Static Site Generator?» lässt sich zusammenfassend wohl am besten beantworten mit «viel, aber nicht genug, um praktische Relevanz zu haben.» Auch für diesen Blog wird eine rein statische ActivityPub-Implementation wahrscheinlich keine wirkliche Relevanz haben, aber die Artikel werden voraussichtlich weiterhin in dieser eingeschränkten Form im Fediverse verfügbar sein.

PS: Falls du diesen Artikel im Fediverse liest, und eine Antwort schreiben willst, adressiere deine Antwort bitte (zusätzlich) an @s3lph@chaos.social, sonst bekomme ich davon nichts mit.