title: Adding ActivityPub to your static site
url: https://paul.kinlan.me/adding-activity-pub-to-your-static-site/
hash_url: 1676902071b6e1e7e0d3395bc47956b5

<p>My blog is built on Hugo and hosted on Vercel. It mostly works well.</p>
<p>I wanted to have my blog automatically publish posts that I create in a way that I didn't need to spin up an instance of Mastodon.</p>
<p>I got a minimal version of it working. You can discover my page, follow my account, and it will post updates when my blog deploys a new page.</p>
<p>The biggest learning that I had was that ActivityPub is a Message protocol. You can't just output a feed of posts and be done (I tried) - so even if you are a statically generated site you need a Server component because you need to POST message replies to people who send a 'Follow' request to your account and POST to the people who follow your account to 'Create' a 'Note'.</p>
<p>To see it all in action, you can subscribe to my blog on any ActivityPub system by following this account: @paul@paul.kinlan.me &lt; try it.</p>
<p>While I had fun, I will say that I found it <strong>very</strong> hard to get started - I found the spec hard to read; testing was almost impossible (there seem to be no easy test harnesses to determine if you are building a compatible client) so I had to test against a live instance; and there is little documentation of what messages should look like; and I hit snags in all places.</p>
<p>Hopefully this post will help you get started if you want to go down a similar path.</p>
<p>My implementation uses Hugo to create my posts and feed data, Vercel Serverless functions to handle in bound messages, and Firebase Firestore to store the data.</p>
<p>This post will assume that you know the terminology of ActivityPub, but I will try and link to the relevant part of the spec. I also made a lot of assumptions that I am a single user host.</p>
<h3 id="discovery">Discovery</h3>
<p><a href="https://docs.joinmastodon.org/spec/webfinger/">Mastodon uses Web Finger</a> to discover where to look for your servers Actor configuration. WebFinger files are served from a <code>./well-known/webfinger</code> file. I created serverless function which returns the required WebFinger configuration. <a href="https://github.com/PaulKinlan/paul.kinlan.me/blob/main/api/well-known/webfinger.ts">Code</a></p>
<div class="highlight"><pre tabindex="0"><code class="language-typescript" data-lang="typescript"><span><span><span>import</span> <span>type</span> { <span>VercelRequest</span>, <span>VercelResponse</span> } <span>from</span> <span>'@vercel/node'</span>;
</span></span><span><span>
</span></span><span><span><span>export</span> <span>default</span> <span>function</span> (<span>req</span>: <span>VercelRequest</span>, <span>res</span>: <span>VercelResponse</span>) {
</span></span><span><span>  <span>res</span>.<span>statusCode</span> <span>=</span> <span>200</span>;
</span></span><span><span>  <span>res</span>.<span>setHeader</span>(<span>"Content-Type"</span>, <span>`application/jrd+json`</span>);
</span></span><span><span>  <span>res</span>.<span>end</span>(<span>`{  
</span></span></span><span><span><span>    "subject": "acct:paul@paul.kinlan.me",
</span></span></span><span><span><span>    "aliases": [
</span></span></span><span><span><span>      "https://status.kinlan.me/@paul"
</span></span></span><span><span><span>    ],
</span></span></span><span><span><span>    "links": [
</span></span></span><span><span><span>      {
</span></span></span><span><span><span>        "rel": "self",
</span></span></span><span><span><span>        "type": "application/activity+json",
</span></span></span><span><span><span>        "href": "https://paul.kinlan.me/paul"
</span></span></span><span><span><span>      }
</span></span></span><span><span><span>    ]
</span></span></span><span><span><span>  }`</span>);
</span></span><span><span>}
</span></span></code></pre></div><p>The JSON above describes a number of aliases for my ActivityPub account '@paul@paul.kinlan.me' and it points to where I host my ActivityPub Actor information.</p>
<p><strong>Note</strong>: You need to make sure you are sending the correct MIME types.</p>
<p>Why do I have a serverless function for static content? Vercel... That's why. I couldn't set the <code>Content-Type</code> configuration properly for any static file in the <code>.well-known</code> folder. In the future if I add multiple accounts I will need to parse the query string to be able to target the <code>links</code> and <code>subject</code> fields correctly/</p>
<p>Next you need to create an <a href="https://www.w3.org/TR/activitypub/#actor-objects">Actor</a>. The Actor is a configuration file that tells ActivityPub servers where to find many core functions such as the 'inbox' (which will receive messages from other clients), 'outbox' that contains all the messages that a user has created (like an RSS feed), 'publicKey' for verifying messages, how my face should appear etc.</p>
<p>To serve the actor file, I just send a JSON response from my <a href="https://github.com/PaulKinlan/paul.kinlan.me/tree/main/api"><code>api</code></a><code>/</code><a href="https://github.com/PaulKinlan/paul.kinlan.me/tree/main/api/activitypub"><code>activitypub</code></a><code>/</code><strong><code>actor.ts</code></strong>. You can see the <a href="https://github.com/PaulKinlan/paul.kinlan.me/blob/main/api/activitypub/actor.ts">code</a> and the <a href="https://paul.kinlan.me/paul">output</a>.</p>
<div class="highlight"><pre tabindex="0"><code class="language-typescript" data-lang="typescript"><span><span><span>import</span> <span>type</span> { <span>VercelRequest</span>, <span>VercelResponse</span> } <span>from</span> <span>'@vercel/node'</span>;
</span></span><span><span>
</span></span><span><span><span>export</span> <span>default</span> <span>function</span> (<span>req</span>: <span>VercelRequest</span>, <span>res</span>: <span>VercelResponse</span>) {
</span></span><span><span>  <span>res</span>.<span>statusCode</span> <span>=</span> <span>200</span>;
</span></span><span><span>  <span>res</span>.<span>setHeader</span>(<span>"Content-Type"</span>, <span>`application/activity+json`</span>);
</span></span><span><span>  <span>res</span>.<span>json</span>({
</span></span><span><span>    <span>"@context"</span><span>:</span> [<span>"https://www.w3.org/ns/activitystreams"</span>, { <span>"@language"</span><span>:</span> <span>"en- GB"</span> }],
</span></span><span><span>    <span>"type"</span><span>:</span> <span>"Person"</span>,
</span></span><span><span>    <span>"id"</span><span>:</span> <span>"https://paul.kinlan.me/paul"</span>,
</span></span><span><span>    <span>"outbox"</span><span>:</span> <span>"https://paul.kinlan.me/outbox"</span>,
</span></span><span><span>    <span>"following"</span><span>:</span> <span>"https://paul.kinlan.me/following"</span>,
</span></span><span><span>    <span>"followers"</span><span>:</span> <span>"https://paul.kinlan.me/followers"</span>,
</span></span><span><span>    <span>"inbox"</span><span>:</span> <span>"https://paul.kinlan.me/inbox"</span>,
</span></span><span><span>    <span>"preferredUsername"</span><span>:</span> <span>"paul"</span>,
</span></span><span><span>    <span>"name"</span><span>:</span> <span>"Paul Kinlan - Modern Web Development with Chrome"</span>,
</span></span><span><span>    <span>"summary"</span><span>:</span> <span>"Paul is a Developer Advocate for Chrome and the Open Web at Google and loves to help make web development easier."</span>,
</span></span><span><span>    <span>"icon"</span><span>:</span> [
</span></span><span><span>      <span>"https://paul.kinlan.me/images/me.png"</span>
</span></span><span><span>    ],
</span></span><span><span>    <span>"publicKey"</span><span>:</span> {
</span></span><span><span>      <span>"@context"</span><span>:</span> <span>"https://w3id.org/security/v1"</span>,
</span></span><span><span>      <span>"@type"</span><span>:</span> <span>"Key"</span>,
</span></span><span><span>      <span>"id"</span><span>:</span> <span>"https://paul.kinlan.me/paul#main-key"</span>,
</span></span><span><span>      <span>"owner"</span><span>:</span> <span>"https://paul.kinlan.me/paul"</span>,
</span></span><span><span>      <span>"publicKeyPem"</span><span>:</span> <span>process</span>.<span>env</span>.<span>ACTIVITYPUB_PUBLIC_KEY</span>
</span></span><span><span>    }
</span></span><span><span>  });
</span></span><span><span>}
</span></span></code></pre></div><p>I used a serverless function because for similar reasons to webfinger (setting the correct Content-type) <em>and</em> I wanted to embed a publicKey that I previously generated and store in Vercel's environment variables configuration.</p>
<p>Now that Mastodon can find me and ActivityPub services know where my inboxes are all I needed to do now was to handle what happens when people follow and unfollow me, and what happens when I create a new post.</p>
<h3 id="following">Following</h3>
<p>I found this one particularly hard - it was almost impossible to find an example of what a Follow message looks like, so I ended up spending a lot of time following my account from a Mastodon client and seeing what data was <code>HTTP</code> <code>POST</code>ed; <strong>and</strong> I also need to maintain the state of who followed me (so I can send them messages later). I chose Firebase Firestore to store all follow requests because it's pretty simple, has a good client and can store JSON directly.</p>
<p>ActivityPub clients will send all messages to an <code>Actor</code>'s inbox. My inbox can only handle <code>Follow</code> and <code>Undo</code> a <code>Follow</code> requests. Once a request is sent to me, I store the data in FireStore and send a response back.</p>
<p>The entire flow is very complex so I will try and explain it as best I can.</p>
<p><a href="https://github.com/PaulKinlan/paul.kinlan.me/tree/main/api">api</a>/<a href="https://github.com/PaulKinlan/paul.kinlan.me/tree/main/api/activitypub">activitypub</a>/<strong>inbox.ts</strong></p>
<div class="highlight"><pre tabindex="0"><code class="language-typescript" data-lang="typescript"><span><span><span>import</span> <span>type</span> { <span>VercelRequest</span>, <span>VercelResponse</span> } <span>from</span> <span>'@vercel/node'</span>;
</span></span><span><span><span>import</span> { <span>AP</span> } <span>from</span> <span>'activitypub-core-types'</span>;
</span></span><span><span><span>import</span> <span>type</span> { <span>Readable</span> } <span>from</span> <span>'node:stream'</span>;
</span></span><span><span><span>import</span> <span>*</span> <span>as</span> <span>admin</span> <span>from</span> <span>'firebase-admin'</span>;
</span></span><span><span><span>import</span> { <span>v4</span> <span>as</span> <span>uuid</span> } <span>from</span> <span>'uuid'</span>;
</span></span><span><span><span>import</span> { <span>CoreObject</span>, <span>Entity</span> } <span>from</span> <span>'activitypub-core-types/lib/activitypub/index'</span>;
</span></span><span><span><span>import</span> { <span>sendSignedRequest</span> } <span>from</span> <span>'../../lib/activitypub/sendSignedRequest'</span>;
</span></span><span><span><span>import</span> { <span>parseSignature</span> } <span>from</span> <span>'../../lib/activitypub/utils/parseSignature'</span>;
</span></span><span><span><span>import</span> { <span>fetchActorInformation</span> } <span>from</span> <span>'../../lib/activitypub/utils/fetchActorInformation'</span>;
</span></span><span><span>
</span></span><span><span><span>process</span>.<span>env</span>.<span>NODE_TLS_REJECT_UNAUTHORIZED</span> <span>=</span> <span>'0'</span>;
</span></span><span><span>
</span></span><span><span><span>if</span> (<span>!</span><span>admin</span>.<span>apps</span>.<span>length</span>) {
</span></span><span><span>  <span>admin</span>.<span>initializeApp</span>({
</span></span><span><span>    <span>credential</span>: <span>admin.credential.cert</span>({
</span></span><span><span>      <span>projectId</span>: <span>process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID</span>,
</span></span><span><span>      <span>clientEmail</span>: <span>process.env.FIREBASE_CLIENT_EMAIL</span>,
</span></span><span><span>      <span>privateKey</span>: <span>process.env.FIREBASE_PRIVATE_KEY.replace</span>(<span>/\\n/g</span>, <span>'\n'</span>)
</span></span><span><span>    })
</span></span><span><span>  });
</span></span><span><span>}
</span></span><span><span>
</span></span><span><span><span>const</span> <span>db</span> <span>=</span> <span>admin</span>.<span>firestore</span>();
</span></span><span><span>
</span></span><span><span><span>export</span> <span>const</span> <span>config</span> <span>=</span> {
</span></span><span><span>  <span>api</span><span>:</span> {
</span></span><span><span>    <span>bodyParser</span>: <span>false</span>,
</span></span><span><span>  },
</span></span><span><span>};
</span></span><span><span>
</span></span><span><span><span>async</span> <span>function</span> <span>buffer</span>(<span>readable</span>: <span>Readable</span>) {
</span></span><span><span>  <span>const</span> <span>chunks</span> <span>=</span> [];
</span></span><span><span>  <span>for</span> <span>await</span> (<span>const</span> <span>chunk</span> <span>of</span> <span>readable</span>) {
</span></span><span><span>    <span>chunks</span>.<span>push</span>(<span>typeof</span> <span>chunk</span> <span>===</span> <span>'string'</span> <span>?</span> <span>Buffer</span>.<span>from</span>(<span>chunk</span>) <span>:</span> <span>chunk</span>);
</span></span><span><span>  }
</span></span><span><span>  <span>return</span> <span>Buffer</span>.<span>concat</span>(<span>chunks</span>);
</span></span><span><span>}
</span></span><span><span>
</span></span><span><span><span>function</span> <span>verifySignature</span>(<span>signature</span>, <span>publicKeyJson</span>) {
</span></span><span><span>  <span>let</span> <span>signatureValid</span>;
</span></span><span><span>
</span></span><span><span>  <span>try</span> {
</span></span><span><span>    <span>// Verify the signature
</span></span></span><span><span><span></span>    <span>signatureValid</span> <span>=</span> <span>signature</span>.<span>verify</span>(
</span></span><span><span>      <span>publicKeyJson</span>.<span>publicKeyPem</span>,	<span>// The PEM string from the public key object
</span></span></span><span><span><span></span>    );
</span></span><span><span>  } <span>catch</span> (<span>error</span>) {
</span></span><span><span>    <span>console</span>.<span>log</span>(<span>"Signature Verification error"</span>, <span>error</span>)
</span></span><span><span>  }
</span></span><span><span>
</span></span><span><span>  <span>return</span> <span>signatureValid</span>;
</span></span><span><span>}
</span></span><span><span>
</span></span><span><span><span>export</span> <span>default</span> <span>async</span> <span>function</span> (<span>req</span>: <span>VercelRequest</span>, <span>res</span>: <span>VercelResponse</span>) {
</span></span><span><span>  <span>const</span> { <span>body</span>, <span>query</span>, <span>method</span>, <span>url</span>, <span>headers</span> } <span>=</span> <span>req</span>;
</span></span><span><span>
</span></span><span><span>  <span>res</span>.<span>statusCode</span> <span>=</span> <span>200</span>;
</span></span><span><span>  <span>res</span>.<span>setHeader</span>(<span>"Content-Type"</span>, <span>`application/activity+json`</span>);
</span></span><span><span>
</span></span><span><span>  <span>// Verify the message some how.
</span></span></span><span><span><span></span>  <span>const</span> <span>buf</span> <span>=</span> <span>await</span> <span>buffer</span>(<span>req</span>);
</span></span><span><span>  <span>const</span> <span>rawBody</span> <span>=</span> <span>buf</span>.<span>toString</span>(<span>'utf8'</span>);
</span></span><span><span>
</span></span><span><span>  <span>const</span> <span>message</span> <span>=</span> &lt;<span>AP.Activity</span>&gt;<span>JSON</span>.<span>parse</span>(<span>rawBody</span>);
</span></span><span><span>
</span></span><span><span>  <span>console</span>.<span>log</span>(<span>message</span>);
</span></span><span><span>
</span></span><span><span>  <span>const</span> <span>signature</span> <span>=</span> <span>parseSignature</span>(<span>req</span>);
</span></span><span><span>  <span>const</span> <span>actorInformation</span> <span>=</span> <span>await</span> <span>fetchActorInformation</span>(<span>signature</span>.<span>keyId</span>);
</span></span><span><span>  <span>const</span> <span>signatureValid</span> <span>=</span> <span>verifySignature</span>(<span>signature</span>, <span>actorInformation</span>.<span>publicKey</span>);
</span></span><span><span>
</span></span><span><span>  <span>if</span> (<span>signatureValid</span> <span>==</span> <span>null</span> <span>||</span> <span>signatureValid</span> <span>==</span> <span>false</span>) {
</span></span><span><span>    <span>res</span>.<span>end</span>(<span>'invalid signature'</span>);
</span></span><span><span>    <span>return</span>;
</span></span><span><span>  }
</span></span><span><span>
</span></span><span><span>  <span>// We should check the digest.
</span></span></span><span><span><span></span>  <span>if</span> (<span>message</span>.<span>type</span> <span>==</span> <span>"Follow"</span>) {
</span></span><span><span>    <span>// We are following.
</span></span></span><span><span><span></span>    <span>const</span> <span>followMessage</span>: <span>AP.Follow</span> <span>=</span> &lt;<span>AP.Follow</span>&gt;<span>message</span>;
</span></span><span><span>    <span>if</span> (<span>followMessage</span>.<span>id</span> <span>==</span> <span>null</span>) <span>return</span>;
</span></span><span><span>
</span></span><span><span>    <span>const</span> <span>collection</span> <span>=</span> <span>db</span>.<span>collection</span>(<span>'followers'</span>);
</span></span><span><span>
</span></span><span><span>    <span>const</span> <span>actorID</span> <span>=</span> (&lt;<span>URL</span>&gt;<span>followMessage</span>.<span>actor</span>).<span>toString</span>();
</span></span><span><span>    <span>const</span> <span>followDocRef</span> <span>=</span> <span>collection</span>.<span>doc</span>(<span>actorID</span>.<span>replace</span>(<span>/\//g</span>, <span>"_"</span>));
</span></span><span><span>    <span>const</span> <span>followDoc</span> <span>=</span> <span>await</span> <span>followDocRef</span>.<span>get</span>();
</span></span><span><span>
</span></span><span><span>    <span>if</span> (<span>followDoc</span>.<span>exists</span>) {
</span></span><span><span>      <span>console</span>.<span>log</span>(<span>"Already Following"</span>)
</span></span><span><span>      <span>return</span> <span>res</span>.<span>end</span>(<span>'already following'</span>);
</span></span><span><span>    }
</span></span><span><span>
</span></span><span><span>    <span>// Create the follow;
</span></span></span><span><span><span></span>    <span>await</span> <span>followDocRef</span>.<span>set</span>(<span>followMessage</span>);
</span></span><span><span>
</span></span><span><span>    <span>const</span> <span>guid</span> <span>=</span> <span>uuid</span>();
</span></span><span><span>    <span>const</span> <span>domain</span> <span>=</span> <span>'paul.kinlan.me'</span>;
</span></span><span><span>
</span></span><span><span>    <span>const</span> <span>acceptRequest</span>: <span>AP.Accept</span> <span>=</span> &lt;<span>AP.Accept</span>&gt;{
</span></span><span><span>      <span>"@context"</span><span>:</span> <span>"https://www.w3.org/ns/activitystreams"</span>,
</span></span><span><span>      <span>'id'</span><span>:</span> <span>new</span> <span>URL</span>(<span>`https://</span><span>${</span><span>domain</span><span>}</span><span>/</span><span>${</span><span>guid</span><span>}</span><span>`</span>),
</span></span><span><span>      <span>'type'</span><span>:</span> <span>'Accept'</span>,
</span></span><span><span>      <span>'actor'</span><span>:</span> <span>"https://paul.kinlan.me/paul"</span>,
</span></span><span><span>      <span>'object'</span><span>:</span> <span>followMessage</span>
</span></span><span><span>    };
</span></span><span><span>
</span></span><span><span>    <span>const</span> <span>actorInbox</span> <span>=</span> <span>new</span> <span>URL</span>(<span>actorInformation</span>.<span>inbox</span>);
</span></span><span><span>
</span></span><span><span>    <span>const</span> <span>response</span> <span>=</span> <span>await</span> <span>sendSignedRequest</span>(<span>actorInbox</span>, <span>acceptRequest</span>);
</span></span><span><span>
</span></span><span><span>    <span>console</span>.<span>log</span>(<span>"Following result"</span>, <span>response</span>.<span>status</span>, <span>response</span>.<span>statusText</span>, <span>await</span> <span>response</span>.<span>text</span>());
</span></span><span><span>
</span></span><span><span>    <span>return</span> <span>res</span>.<span>end</span>(<span>"ok"</span>)
</span></span><span><span>  }
</span></span><span><span>
</span></span><span><span>  <span>if</span> (<span>message</span>.<span>type</span> <span>==</span> <span>"Undo"</span>) {
</span></span><span><span>    <span>// Undo a follow.
</span></span></span><span><span><span></span>    <span>const</span> <span>undoObject</span>: <span>AP.Undo</span> <span>=</span> &lt;<span>AP.Undo</span>&gt;<span>message</span>;
</span></span><span><span>    <span>if</span> (<span>undoObject</span> <span>==</span> <span>null</span> <span>||</span> <span>undoObject</span>.<span>id</span> <span>==</span> <span>null</span>) <span>return</span>;
</span></span><span><span>    <span>if</span> (<span>undoObject</span>.<span>object</span> <span>==</span> <span>null</span>) <span>return</span>;
</span></span><span><span>    <span>if</span> (<span>"actor"</span> <span>in</span> <span>undoObject</span>.<span>object</span> <span>==</span> <span>false</span> <span>&amp;&amp;</span> (&lt;<span>CoreObject</span>&gt;<span>undoObject</span>.<span>object</span>).<span>type</span> <span>!=</span> <span>"Follow"</span>) <span>return</span>;
</span></span><span><span>
</span></span><span><span>    <span>const</span> <span>docId</span> <span>=</span> <span>undoObject</span>.<span>actor</span>.<span>toString</span>().<span>replace</span>(<span>/\//g</span>, <span>"_"</span>);
</span></span><span><span>    <span>const</span> <span>res</span> <span>=</span> <span>await</span> <span>db</span>.<span>collection</span>(<span>'followers'</span>).<span>doc</span>(<span>docId</span>).<span>delete</span>();
</span></span><span><span>
</span></span><span><span>    <span>console</span>.<span>log</span>(<span>"Deleted"</span>, <span>res</span>)
</span></span><span><span>  }
</span></span><span><span>
</span></span><span><span>  <span>res</span>.<span>end</span>();
</span></span><span><span>};
</span></span></code></pre></div><ol>
<li>Parse the <code>POST</code> body and cast it to an Activity object.</li>
<li>Parse the signature of the request to verify the message hasn't been tampered with in transit.</li>
<li>From the signature HTTP header get the <code>Actor</code> that wants to follow you and <a href="https://github.com/PaulKinlan/paul.kinlan.me/blob/main/lib/activitypub/utils/fetchActorInformation.ts">fetch their Public Key </a>(from their Actor file).</li>
<li>Verify the message with their Public Key</li>
</ol>
<p>Now we believe that we have a valid messages.</p>
<p>If the message is a <code>Follow</code> request</p>
<ol>
<li>See if the Actor trying to follow is already in the db, if they are return;</li>
<li>Add the <code>Actor</code> to the <code>followers</code> collection in FireStore</li>
<li><a href="https://github.com/PaulKinlan/paul.kinlan.me/blob/main/api/activitypub/inbox.ts#L100">Prepare</a> an <code>Accept</code> message to the <code>Actor</code> indicating that the Follow has been accepted and <a href="https://github.com/PaulKinlan/paul.kinlan.me/blob/main/lib/activitypub/utils/sendSignedRequest.ts">send it</a>.</li>
</ol>
<p>If the message is an <code>Undo</code> for a <code>Follow</code> request.</p>
<ol>
<li>Find the data in the <code>followers</code> collection in FireStore</li>
<li>Delete it.</li>
</ol>
<p><strong>Note</strong>: I found it hard to find much information about sending requests to servers - so after a lot of reading and experimenting I created this <a href="https://github.com/PaulKinlan/paul.kinlan.me/blob/main/lib/activitypub/utils/sendSignedRequest.ts">routine</a>. It will successfully sign the HTTP request with your configured private key and attach a digest.</p>
<h3 id="posting">Posting</h3>
<p>Like many static sites there is no CMS that knows when new content is posted (it is static after all) so I needed to create a routine that would send my posts to all the people that follow the account.</p>
<p>Firstly I generate the <code>outbox</code> so that people can read all my public posts. I use a hugo template (<a href="https://github.com/PaulKinlan/paul.kinlan.me/blob/main/config.toml">layouts/index.activity_outbox.ajson</a>) that reads through all my posts and creates a <code>Create</code> object with an embedded <code>Note</code> - this is what Mastodon needs to show a Toot.</p>
<div class="highlight"><pre tabindex="0"><code class="language-go" data-lang="go"><span><span>{{<span>-</span> <span>$</span><span>pctx</span> <span>:=</span> . <span>-</span>}}
</span></span><span><span>{{<span>-</span> <span>if</span> .<span>IsHome</span> <span>-</span>}}{{ <span>$</span><span>pctx</span> = .<span>Site</span> }}{{<span>-</span> <span>end</span> <span>-</span>}}
</span></span><span><span>{{<span>-</span> <span>$</span><span>pages</span> <span>:=</span> <span>slice</span> <span>-</span>}}
</span></span><span><span>{{<span>-</span> <span>if</span> <span>or</span> <span>$</span>.<span>IsHome</span> <span>$</span>.<span>IsSection</span> <span>-</span>}}
</span></span><span><span>{{<span>-</span> <span>$</span><span>pages</span> = <span>$</span><span>pctx</span>.<span>RegularPages</span> <span>-</span>}}
</span></span><span><span>{{<span>-</span> <span>else</span> <span>-</span>}}
</span></span><span><span>{{<span>-</span> <span>$</span><span>pages</span> = <span>$</span><span>pctx</span>.<span>Pages</span> <span>-</span>}}
</span></span><span><span>{{<span>-</span> <span>end</span> <span>-</span>}}
</span></span><span><span>{{<span>-</span> <span>$</span><span>limit</span> <span>:=</span> .<span>Site</span>.<span>Config</span>.<span>Services</span>.<span>RSS</span>.<span>Limit</span> <span>-</span>}}
</span></span><span><span>{{<span>-</span> <span>if</span> <span>ge</span> <span>$</span><span>limit</span> <span>1</span> <span>-</span>}}
</span></span><span><span>{{<span>-</span> <span>$</span><span>pages</span> = <span>$</span><span>pages</span> | <span>first</span> <span>$</span><span>limit</span> <span>-</span>}}
</span></span><span><span>{{<span>-</span> <span>end</span> <span>-</span>}}
</span></span><span><span>{
</span></span><span><span>  <span>"@context"</span>: <span>"https://www.w3.org/ns/activitystreams"</span>,
</span></span><span><span>  <span>"id"</span>: <span>"{{ $.Site.BaseURL }}outbox"</span>,
</span></span><span><span>  <span>"summary"</span>: <span>"{{$.Site.Author.name}} - {{$.Site.Title}}"</span>,
</span></span><span><span>  <span>"type"</span>: <span>"OrderedCollection"</span>,
</span></span><span><span>  {{ <span>$</span><span>notdrafts</span> <span>:=</span> <span>where</span> <span>$</span><span>pages</span> <span>".Draft"</span> <span>"!="</span> <span>true</span> }}
</span></span><span><span>  {{ <span>$</span><span>all</span> <span>:=</span>  <span>where</span> <span>$</span><span>notdrafts</span> <span>"Type"</span> <span>"in"</span> (<span>slice</span> <span>"journal"</span> <span>"post"</span> <span>"page"</span>)}}
</span></span><span><span>  <span>"totalItems"</span>: {{(<span>len</span> <span>$</span><span>all</span>)}},
</span></span><span><span>  <span>"orderedItems"</span>: [
</span></span><span><span>  {{ <span>range</span> <span>$</span><span>index</span>, <span>$</span><span>element</span> <span>:=</span> <span>$</span><span>all</span>  }}
</span></span><span><span>    {{<span>-</span> <span>if</span> <span>ne</span> <span>$</span><span>index</span> <span>0</span> }}, {{ <span>end</span> }}
</span></span><span><span>    {
</span></span><span><span>      <span>"@context"</span>: <span>"https://www.w3.org/ns/activitystreams"</span>,
</span></span><span><span>      <span>"id"</span>: <span>"{{.Permalink}}-create"</span>,
</span></span><span><span>      <span>"type"</span>: <span>"Create"</span>,
</span></span><span><span>      <span>"actor"</span>: <span>"https://paul.kinlan.me/paul"</span>,
</span></span><span><span>      <span>"object"</span>: {
</span></span><span><span>        <span>"id"</span>: <span>"{{ .Permalink }}"</span>,
</span></span><span><span>        <span>"type"</span>: <span>"Note"</span>,
</span></span><span><span>        <span>"content"</span>: <span>"{{.Title}}&lt;br&gt;{{.Summary}}"</span>,
</span></span><span><span>        <span>"url"</span>: <span>"{{.Permalink}}"</span>,
</span></span><span><span>        <span>"attributedTo"</span>: <span>"https://paul.kinlan.me/paul"</span>,
</span></span><span><span>        <span>"to"</span>: <span>"https://www.w3.org/ns/activitystreams#Public"</span>,
</span></span><span><span>        <span>"published"</span>: {{ <span>dateFormat</span> <span>"2006-01-02T15:04:05-07:00"</span> .<span>Date</span> | <span>jsonify</span> }}
</span></span><span><span>      }
</span></span><span><span>    }
</span></span><span><span>  {{<span>end</span>}}
</span></span><span><span>  ]
</span></span><span><span>}
</span></span></code></pre></div><p>I also set up Hugo to generate this file for the "home" output type as follows</p>
<div class="highlight"><pre tabindex="0"><code class="language-toml" data-lang="toml"><span><span>[<span>mediaTypes</span>]
</span></span><span><span>[<span>mediaTypes</span>.<span>"application/activity+json"</span>]
</span></span><span><span><span>suffixes</span> = [<span>"ajson"</span>]
</span></span><span><span>
</span></span><span><span>[<span>outputFormats</span>]
</span></span><span><span>[<span>outputFormats</span>.<span>ACTIVITY_OUTBOX</span>]
</span></span><span><span><span>mediaType</span> = <span>"application/activity+json"</span>
</span></span><span><span><span>notAlternative</span> = <span>true</span>
</span></span><span><span><span>baseName</span> = <span>"outbox"</span>
</span></span><span><span>
</span></span><span><span>[<span>outputs</span>]
</span></span><span><span><span>home</span> = [<span>"HTML"</span>, <span>"RSS"</span>, <span>"ACTIVITY_OUTBOX"</span>]
</span></span></code></pre></div><p>I then serve the file:<a href="https://github.com/PaulKinlan/paul.kinlan.me/tree/main/api/activitypub"> /api/activitypub/outbox.ts</a></p>
<div class="highlight"><pre tabindex="0"><code class="language-typescript" data-lang="typescript"><span><span><span>import</span> <span>type</span> { <span>VercelRequest</span>, <span>VercelResponse</span> } <span>from</span> <span>'@vercel/node'</span>;
</span></span><span><span><span>import</span> { <span>join</span> } <span>from</span> <span>'path'</span>;
</span></span><span><span><span>import</span> { <span>cwd</span> } <span>from</span> <span>'process'</span>;
</span></span><span><span><span>import</span> { <span>readFileSync</span> } <span>from</span> <span>'fs'</span>;
</span></span><span><span>
</span></span><span><span><span>/*
</span></span></span><span><span><span>  This returns a list of posts for the single user 'Paul'.
</span></span></span><span><span><span>  It's a GET request. This doesn't post it to anyone's timeline.
</span></span></span><span><span><span>*/</span>
</span></span><span><span><span>export</span> <span>default</span> <span>function</span> (<span>req</span>: <span>VercelRequest</span>, <span>res</span>: <span>VercelResponse</span>) {
</span></span><span><span>  <span>// All of the outbox data is generated at build time, so just return that static file.
</span></span></span><span><span><span></span>  <span>const</span> <span>file</span> <span>=</span> <span>join</span>(<span>cwd</span>(), <span>'public'</span>, <span>'outbox.ajson'</span>);
</span></span><span><span>  <span>const</span> <span>stringified</span> <span>=</span> <span>readFileSync</span>(<span>file</span>, <span>'utf8'</span>);
</span></span><span><span>
</span></span><span><span>  <span>res</span>.<span>statusCode</span> <span>=</span> <span>200</span>;
</span></span><span><span>  <span>res</span>.<span>setHeader</span>(<span>"Content-Type"</span>, <span>`application/activity+json`</span>);
</span></span><span><span>
</span></span><span><span>  <span>return</span> <span>res</span>.<span>end</span>(<span>stringified</span>);
</span></span><span><span>};
</span></span></code></pre></div><p>Finally, when my Vercel build completes, I scan the generated <a href="https://paul.kinlan.me/outbox">outbox</a> using my <a href="https://paul.kinlan.me/post-deploy-webhook-for-vercel/">post-deploy Webhook for vercel</a> and calling <a href="https://github.com/PaulKinlan/paul.kinlan.me/blob/main/api/activitypub/sendNote.ts">api/activitypub/sendNote.ts</a> endpoint to post to all the followers.</p>
<div class="highlight"><pre tabindex="0"><code class="language-typescript" data-lang="typescript"><span><span><span>import</span> <span>type</span> { <span>VercelRequest</span>, <span>VercelResponse</span> } <span>from</span> <span>'@vercel/node'</span>;
</span></span><span><span><span>import</span> { <span>AP</span> } <span>from</span> <span>'activitypub-core-types'</span>;
</span></span><span><span><span>import</span> <span>*</span> <span>as</span> <span>admin</span> <span>from</span> <span>'firebase-admin'</span>;
</span></span><span><span><span>import</span> { <span>OrderedCollection</span> } <span>from</span> <span>'activitypub-core-types/lib/activitypub/index'</span>;
</span></span><span><span><span>import</span> { <span>sendSignedRequest</span> } <span>from</span> <span>'../../lib/activitypub/utils/sendSignedRequest'</span>;
</span></span><span><span><span>import</span> { <span>fetchActorInformation</span> } <span>from</span> <span>'../../lib/activitypub/utils/fetchActorInformation'</span>;
</span></span><span><span>
</span></span><span><span><span>process</span>.<span>env</span>.<span>NODE_TLS_REJECT_UNAUTHORIZED</span> <span>=</span> <span>'0'</span>;
</span></span><span><span>
</span></span><span><span><span>if</span> (<span>!</span><span>admin</span>.<span>apps</span>.<span>length</span>) {
</span></span><span><span>  <span>admin</span>.<span>initializeApp</span>({
</span></span><span><span>    <span>credential</span>: <span>admin.credential.cert</span>({
</span></span><span><span>      <span>projectId</span>: <span>process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID</span>,
</span></span><span><span>      <span>clientEmail</span>: <span>process.env.FIREBASE_CLIENT_EMAIL</span>,
</span></span><span><span>      <span>privateKey</span>: <span>process.env.FIREBASE_PRIVATE_KEY.replace</span>(<span>/\\n/g</span>, <span>'\n'</span>)
</span></span><span><span>    })
</span></span><span><span>  });
</span></span><span><span>}
</span></span><span><span>
</span></span><span><span><span>const</span> <span>db</span> <span>=</span> <span>admin</span>.<span>firestore</span>();
</span></span><span><span>
</span></span><span><span><span>export</span> <span>const</span> <span>config</span> <span>=</span> {
</span></span><span><span>  <span>api</span><span>:</span> {
</span></span><span><span>    <span>bodyParser</span>: <span>false</span>
</span></span><span><span>  }
</span></span><span><span>};
</span></span><span><span>
</span></span><span><span><span>/*
</span></span></span><span><span><span>  Sends the latest not that hasn't yet been sent.
</span></span></span><span><span><span>*/</span>
</span></span><span><span><span>export</span> <span>default</span> <span>async</span> <span>function</span> (<span>req</span>: <span>VercelRequest</span>, <span>res</span>: <span>VercelResponse</span>) {
</span></span><span><span>  <span>const</span> { <span>body</span>, <span>query</span>, <span>method</span>, <span>url</span>, <span>headers</span> } <span>=</span> <span>req</span>;
</span></span><span><span>  <span>const</span> { <span>token</span> } <span>=</span> <span>query</span>;
</span></span><span><span>
</span></span><span><span>  <span>if</span> (<span>method</span> <span>!=</span> <span>"POST"</span>) {
</span></span><span><span>    <span>res</span>.<span>status</span>(<span>401</span>).<span>end</span>(<span>"Invalid Method, must be POST"</span>);
</span></span><span><span>    <span>return</span>;
</span></span><span><span>  }
</span></span><span><span>
</span></span><span><span>  <span>if</span> (<span>token</span> <span>!=</span> <span>process</span>.<span>env</span>.<span>ACTIVITYPUB_CREATE_TOKEN</span>) {
</span></span><span><span>    <span>res</span>.<span>status</span>(<span>401</span>).<span>end</span>(<span>"Invalid token"</span>);
</span></span><span><span>    <span>return</span>;
</span></span><span><span>  }
</span></span><span><span>
</span></span><span><span>  <span>const</span> <span>configCollection</span> <span>=</span> <span>db</span>.<span>collection</span>(<span>'config'</span>);
</span></span><span><span>  <span>const</span> <span>configRef</span> <span>=</span> <span>configCollection</span>.<span>doc</span>(<span>"config"</span>);
</span></span><span><span>  <span>const</span> <span>config</span> <span>=</span> <span>await</span> <span>configRef</span>.<span>get</span>();
</span></span><span><span>
</span></span><span><span>  <span>if</span> (<span>config</span>.<span>exists</span> <span>==</span> <span>false</span>) {
</span></span><span><span>    <span>// Config doesn't exist, make something
</span></span></span><span><span><span></span>    <span>configRef</span>.<span>set</span>({
</span></span><span><span>      <span>"lastId"</span><span>:</span> <span>0</span>
</span></span><span><span>    });
</span></span><span><span>  }
</span></span><span><span>
</span></span><span><span>  <span>const</span> <span>configData</span> <span>=</span> <span>config</span>.<span>data</span>();
</span></span><span><span>  <span>let</span> <span>lastId</span> <span>=</span> <span>0</span>;
</span></span><span><span>  <span>if</span> (<span>configData</span> <span>!=</span> <span>undefined</span>) {
</span></span><span><span>    <span>lastId</span> <span>=</span> <span>configData</span>.<span>lastId</span>;
</span></span><span><span>  }
</span></span><span><span>
</span></span><span><span>  <span>// Get my outbox because it contains all my notes.
</span></span></span><span><span><span></span>  <span>const</span> <span>outboxResponse</span> <span>=</span> <span>await</span> <span>fetch</span>(<span>'https://paul.kinlan.me/outbox'</span>);
</span></span><span><span>  <span>const</span> <span>outbox</span> <span>=</span> &lt;<span>OrderedCollection</span>&gt;(<span>await</span> <span>outboxResponse</span>.<span>json</span>());
</span></span><span><span>
</span></span><span><span>  <span>const</span> <span>followersCollection</span> <span>=</span> <span>db</span>.<span>collection</span>(<span>'followers'</span>);
</span></span><span><span>  <span>const</span> <span>followersQuerySnapshot</span> <span>=</span> <span>await</span> <span>followersCollection</span>.<span>get</span>();
</span></span><span><span>
</span></span><span><span>  <span>for</span> (<span>const</span> <span>followerDoc</span> <span>of</span> <span>followersQuerySnapshot</span>.<span>docs</span>) {
</span></span><span><span>    <span>const</span> <span>follower</span> <span>=</span> <span>followerDoc</span>.<span>data</span>();
</span></span><span><span>    <span>try</span> {
</span></span><span><span>      <span>const</span> <span>actorInformation</span> <span>=</span> <span>await</span> <span>fetchActorInformation</span>(<span>follower</span>.<span>actor</span>);
</span></span><span><span>      <span>const</span> <span>actorInbox</span> <span>=</span> <span>new</span> <span>URL</span>(<span>actorInformation</span>.<span>inbox</span>);
</span></span><span><span>
</span></span><span><span>      <span>for</span> (<span>const</span> <span>iteIdx</span> <span>in</span> (&lt;<span>AP.EntityReference</span><span>[]</span>&gt;<span>outbox</span>.<span>orderedItems</span>)) {
</span></span><span><span>        <span>// We have to break somewhere... do it after the first.
</span></span></span><span><span><span></span>        <span>const</span> <span>item</span> <span>=</span> (&lt;<span>AP.EntityReference</span><span>[]</span>&gt;<span>outbox</span>.<span>orderedItems</span>)[<span>iteIdx</span>];
</span></span><span><span>
</span></span><span><span>        <span>if</span> (<span>item</span>.<span>object</span> <span>!=</span> <span>undefined</span>) {
</span></span><span><span>          <span>// We might not need this.
</span></span></span><span><span><span></span>          <span>item</span>.<span>object</span>.<span>published</span> <span>=</span> (<span>new</span> Date()).<span>toISOString</span>();
</span></span><span><span>        }
</span></span><span><span>
</span></span><span><span>        <span>console</span>.<span>log</span>(<span>`Sending to </span><span>${</span><span>actorInbox</span><span>}</span><span>`</span>, <span>item</span>);
</span></span><span><span>        
</span></span><span><span>        <span>// Item will be an entity, i.e, { Create { Note } }
</span></span></span><span><span><span></span>        <span>const</span> <span>response</span> <span>=</span> <span>await</span> <span>sendSignedRequest</span>(<span>actorInbox</span>, &lt;<span>AP.Activity</span>&gt; <span>item</span>);
</span></span><span><span>        <span>console</span>.<span>log</span>(<span>"Send result: "</span>, <span>actorInbox</span>, <span>response</span>.<span>status</span>, <span>response</span>.<span>statusText</span>, <span>await</span> <span>response</span>.<span>text</span>());
</span></span><span><span>
</span></span><span><span>        <span>break</span>;
</span></span><span><span>      }
</span></span><span><span>    } <span>catch</span> (<span>ex</span>) {
</span></span><span><span>      <span>console</span>.<span>log</span>(<span>"Error"</span>, <span>ex</span>, <span>follower</span>);
</span></span><span><span>    }
</span></span><span><span>  }
</span></span><span><span>
</span></span><span><span>  <span>res</span>.<span>status</span>(<span>200</span>).<span>end</span>(<span>"ok"</span>);
</span></span><span><span>};
</span></span></code></pre></div><p>The above code is relative long but the summary of it is as follows:</p>
<ol>
<li>Scan the outbox</li>
<li>Pick the first post (I am only sending one note)</li>
<li>For each follower in the <code>followers</code> table
<ol>
<li>Get their actor information (where their inbox is)</li>
<li>Send the <code>Create</code> object from the outbox to them via a signed HTTP request</li>
</ol>
</li>
</ol>
<h3 id="voila">Voila</h3>
<p>Simple... Nah. I think it's pretty complex, but it works.</p>
<p>If you have created something similar, send me a comment. I'd love to improve what I have and share that with more people.</p>
