A place to cache linked articles (think custom and personal wayback machine)
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

index.md 42KB

1 year ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414
  1. title: Adding ActivityPub to your static site
  2. url: https://paul.kinlan.me/adding-activity-pub-to-your-static-site/
  3. hash_url: 1676902071b6e1e7e0d3395bc47956b5
  4. <p>My blog is built on Hugo and hosted on Vercel. It mostly works well.</p>
  5. <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>
  6. <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>
  7. <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>
  8. <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>
  9. <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>
  10. <p>Hopefully this post will help you get started if you want to go down a similar path.</p>
  11. <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>
  12. <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>
  13. <h3 id="discovery">Discovery</h3>
  14. <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>
  15. <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>;
  16. </span></span><span><span>
  17. </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>) {
  18. </span></span><span><span> <span>res</span>.<span>statusCode</span> <span>=</span> <span>200</span>;
  19. </span></span><span><span> <span>res</span>.<span>setHeader</span>(<span>"Content-Type"</span>, <span>`application/jrd+json`</span>);
  20. </span></span><span><span> <span>res</span>.<span>end</span>(<span>`{
  21. </span></span></span><span><span><span> "subject": "acct:paul@paul.kinlan.me",
  22. </span></span></span><span><span><span> "aliases": [
  23. </span></span></span><span><span><span> "https://status.kinlan.me/@paul"
  24. </span></span></span><span><span><span> ],
  25. </span></span></span><span><span><span> "links": [
  26. </span></span></span><span><span><span> {
  27. </span></span></span><span><span><span> "rel": "self",
  28. </span></span></span><span><span><span> "type": "application/activity+json",
  29. </span></span></span><span><span><span> "href": "https://paul.kinlan.me/paul"
  30. </span></span></span><span><span><span> }
  31. </span></span></span><span><span><span> ]
  32. </span></span></span><span><span><span> }`</span>);
  33. </span></span><span><span>}
  34. </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>
  35. <p><strong>Note</strong>: You need to make sure you are sending the correct MIME types.</p>
  36. <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>
  37. <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>
  38. <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>
  39. <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>;
  40. </span></span><span><span>
  41. </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>) {
  42. </span></span><span><span> <span>res</span>.<span>statusCode</span> <span>=</span> <span>200</span>;
  43. </span></span><span><span> <span>res</span>.<span>setHeader</span>(<span>"Content-Type"</span>, <span>`application/activity+json`</span>);
  44. </span></span><span><span> <span>res</span>.<span>json</span>({
  45. </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> }],
  46. </span></span><span><span> <span>"type"</span><span>:</span> <span>"Person"</span>,
  47. </span></span><span><span> <span>"id"</span><span>:</span> <span>"https://paul.kinlan.me/paul"</span>,
  48. </span></span><span><span> <span>"outbox"</span><span>:</span> <span>"https://paul.kinlan.me/outbox"</span>,
  49. </span></span><span><span> <span>"following"</span><span>:</span> <span>"https://paul.kinlan.me/following"</span>,
  50. </span></span><span><span> <span>"followers"</span><span>:</span> <span>"https://paul.kinlan.me/followers"</span>,
  51. </span></span><span><span> <span>"inbox"</span><span>:</span> <span>"https://paul.kinlan.me/inbox"</span>,
  52. </span></span><span><span> <span>"preferredUsername"</span><span>:</span> <span>"paul"</span>,
  53. </span></span><span><span> <span>"name"</span><span>:</span> <span>"Paul Kinlan - Modern Web Development with Chrome"</span>,
  54. </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>,
  55. </span></span><span><span> <span>"icon"</span><span>:</span> [
  56. </span></span><span><span> <span>"https://paul.kinlan.me/images/me.png"</span>
  57. </span></span><span><span> ],
  58. </span></span><span><span> <span>"publicKey"</span><span>:</span> {
  59. </span></span><span><span> <span>"@context"</span><span>:</span> <span>"https://w3id.org/security/v1"</span>,
  60. </span></span><span><span> <span>"@type"</span><span>:</span> <span>"Key"</span>,
  61. </span></span><span><span> <span>"id"</span><span>:</span> <span>"https://paul.kinlan.me/paul#main-key"</span>,
  62. </span></span><span><span> <span>"owner"</span><span>:</span> <span>"https://paul.kinlan.me/paul"</span>,
  63. </span></span><span><span> <span>"publicKeyPem"</span><span>:</span> <span>process</span>.<span>env</span>.<span>ACTIVITYPUB_PUBLIC_KEY</span>
  64. </span></span><span><span> }
  65. </span></span><span><span> });
  66. </span></span><span><span>}
  67. </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>
  68. <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>
  69. <h3 id="following">Following</h3>
  70. <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>
  71. <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>
  72. <p>The entire flow is very complex so I will try and explain it as best I can.</p>
  73. <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>
  74. <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>;
  75. </span></span><span><span><span>import</span> { <span>AP</span> } <span>from</span> <span>'activitypub-core-types'</span>;
  76. </span></span><span><span><span>import</span> <span>type</span> { <span>Readable</span> } <span>from</span> <span>'node:stream'</span>;
  77. </span></span><span><span><span>import</span> <span>*</span> <span>as</span> <span>admin</span> <span>from</span> <span>'firebase-admin'</span>;
  78. </span></span><span><span><span>import</span> { <span>v4</span> <span>as</span> <span>uuid</span> } <span>from</span> <span>'uuid'</span>;
  79. </span></span><span><span><span>import</span> { <span>CoreObject</span>, <span>Entity</span> } <span>from</span> <span>'activitypub-core-types/lib/activitypub/index'</span>;
  80. </span></span><span><span><span>import</span> { <span>sendSignedRequest</span> } <span>from</span> <span>'../../lib/activitypub/sendSignedRequest'</span>;
  81. </span></span><span><span><span>import</span> { <span>parseSignature</span> } <span>from</span> <span>'../../lib/activitypub/utils/parseSignature'</span>;
  82. </span></span><span><span><span>import</span> { <span>fetchActorInformation</span> } <span>from</span> <span>'../../lib/activitypub/utils/fetchActorInformation'</span>;
  83. </span></span><span><span>
  84. </span></span><span><span><span>process</span>.<span>env</span>.<span>NODE_TLS_REJECT_UNAUTHORIZED</span> <span>=</span> <span>'0'</span>;
  85. </span></span><span><span>
  86. </span></span><span><span><span>if</span> (<span>!</span><span>admin</span>.<span>apps</span>.<span>length</span>) {
  87. </span></span><span><span> <span>admin</span>.<span>initializeApp</span>({
  88. </span></span><span><span> <span>credential</span>: <span>admin.credential.cert</span>({
  89. </span></span><span><span> <span>projectId</span>: <span>process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID</span>,
  90. </span></span><span><span> <span>clientEmail</span>: <span>process.env.FIREBASE_CLIENT_EMAIL</span>,
  91. </span></span><span><span> <span>privateKey</span>: <span>process.env.FIREBASE_PRIVATE_KEY.replace</span>(<span>/\\n/g</span>, <span>'\n'</span>)
  92. </span></span><span><span> })
  93. </span></span><span><span> });
  94. </span></span><span><span>}
  95. </span></span><span><span>
  96. </span></span><span><span><span>const</span> <span>db</span> <span>=</span> <span>admin</span>.<span>firestore</span>();
  97. </span></span><span><span>
  98. </span></span><span><span><span>export</span> <span>const</span> <span>config</span> <span>=</span> {
  99. </span></span><span><span> <span>api</span><span>:</span> {
  100. </span></span><span><span> <span>bodyParser</span>: <span>false</span>,
  101. </span></span><span><span> },
  102. </span></span><span><span>};
  103. </span></span><span><span>
  104. </span></span><span><span><span>async</span> <span>function</span> <span>buffer</span>(<span>readable</span>: <span>Readable</span>) {
  105. </span></span><span><span> <span>const</span> <span>chunks</span> <span>=</span> [];
  106. </span></span><span><span> <span>for</span> <span>await</span> (<span>const</span> <span>chunk</span> <span>of</span> <span>readable</span>) {
  107. </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>);
  108. </span></span><span><span> }
  109. </span></span><span><span> <span>return</span> <span>Buffer</span>.<span>concat</span>(<span>chunks</span>);
  110. </span></span><span><span>}
  111. </span></span><span><span>
  112. </span></span><span><span><span>function</span> <span>verifySignature</span>(<span>signature</span>, <span>publicKeyJson</span>) {
  113. </span></span><span><span> <span>let</span> <span>signatureValid</span>;
  114. </span></span><span><span>
  115. </span></span><span><span> <span>try</span> {
  116. </span></span><span><span> <span>// Verify the signature
  117. </span></span></span><span><span><span></span> <span>signatureValid</span> <span>=</span> <span>signature</span>.<span>verify</span>(
  118. </span></span><span><span> <span>publicKeyJson</span>.<span>publicKeyPem</span>, <span>// The PEM string from the public key object
  119. </span></span></span><span><span><span></span> );
  120. </span></span><span><span> } <span>catch</span> (<span>error</span>) {
  121. </span></span><span><span> <span>console</span>.<span>log</span>(<span>"Signature Verification error"</span>, <span>error</span>)
  122. </span></span><span><span> }
  123. </span></span><span><span>
  124. </span></span><span><span> <span>return</span> <span>signatureValid</span>;
  125. </span></span><span><span>}
  126. </span></span><span><span>
  127. </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>) {
  128. </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>;
  129. </span></span><span><span>
  130. </span></span><span><span> <span>res</span>.<span>statusCode</span> <span>=</span> <span>200</span>;
  131. </span></span><span><span> <span>res</span>.<span>setHeader</span>(<span>"Content-Type"</span>, <span>`application/activity+json`</span>);
  132. </span></span><span><span>
  133. </span></span><span><span> <span>// Verify the message some how.
  134. </span></span></span><span><span><span></span> <span>const</span> <span>buf</span> <span>=</span> <span>await</span> <span>buffer</span>(<span>req</span>);
  135. </span></span><span><span> <span>const</span> <span>rawBody</span> <span>=</span> <span>buf</span>.<span>toString</span>(<span>'utf8'</span>);
  136. </span></span><span><span>
  137. </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>);
  138. </span></span><span><span>
  139. </span></span><span><span> <span>console</span>.<span>log</span>(<span>message</span>);
  140. </span></span><span><span>
  141. </span></span><span><span> <span>const</span> <span>signature</span> <span>=</span> <span>parseSignature</span>(<span>req</span>);
  142. </span></span><span><span> <span>const</span> <span>actorInformation</span> <span>=</span> <span>await</span> <span>fetchActorInformation</span>(<span>signature</span>.<span>keyId</span>);
  143. </span></span><span><span> <span>const</span> <span>signatureValid</span> <span>=</span> <span>verifySignature</span>(<span>signature</span>, <span>actorInformation</span>.<span>publicKey</span>);
  144. </span></span><span><span>
  145. </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>) {
  146. </span></span><span><span> <span>res</span>.<span>end</span>(<span>'invalid signature'</span>);
  147. </span></span><span><span> <span>return</span>;
  148. </span></span><span><span> }
  149. </span></span><span><span>
  150. </span></span><span><span> <span>// We should check the digest.
  151. </span></span></span><span><span><span></span> <span>if</span> (<span>message</span>.<span>type</span> <span>==</span> <span>"Follow"</span>) {
  152. </span></span><span><span> <span>// We are following.
  153. </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>;
  154. </span></span><span><span> <span>if</span> (<span>followMessage</span>.<span>id</span> <span>==</span> <span>null</span>) <span>return</span>;
  155. </span></span><span><span>
  156. </span></span><span><span> <span>const</span> <span>collection</span> <span>=</span> <span>db</span>.<span>collection</span>(<span>'followers'</span>);
  157. </span></span><span><span>
  158. </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>();
  159. </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>));
  160. </span></span><span><span> <span>const</span> <span>followDoc</span> <span>=</span> <span>await</span> <span>followDocRef</span>.<span>get</span>();
  161. </span></span><span><span>
  162. </span></span><span><span> <span>if</span> (<span>followDoc</span>.<span>exists</span>) {
  163. </span></span><span><span> <span>console</span>.<span>log</span>(<span>"Already Following"</span>)
  164. </span></span><span><span> <span>return</span> <span>res</span>.<span>end</span>(<span>'already following'</span>);
  165. </span></span><span><span> }
  166. </span></span><span><span>
  167. </span></span><span><span> <span>// Create the follow;
  168. </span></span></span><span><span><span></span> <span>await</span> <span>followDocRef</span>.<span>set</span>(<span>followMessage</span>);
  169. </span></span><span><span>
  170. </span></span><span><span> <span>const</span> <span>guid</span> <span>=</span> <span>uuid</span>();
  171. </span></span><span><span> <span>const</span> <span>domain</span> <span>=</span> <span>'paul.kinlan.me'</span>;
  172. </span></span><span><span>
  173. </span></span><span><span> <span>const</span> <span>acceptRequest</span>: <span>AP.Accept</span> <span>=</span> &lt;<span>AP.Accept</span>&gt;{
  174. </span></span><span><span> <span>"@context"</span><span>:</span> <span>"https://www.w3.org/ns/activitystreams"</span>,
  175. </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>),
  176. </span></span><span><span> <span>'type'</span><span>:</span> <span>'Accept'</span>,
  177. </span></span><span><span> <span>'actor'</span><span>:</span> <span>"https://paul.kinlan.me/paul"</span>,
  178. </span></span><span><span> <span>'object'</span><span>:</span> <span>followMessage</span>
  179. </span></span><span><span> };
  180. </span></span><span><span>
  181. </span></span><span><span> <span>const</span> <span>actorInbox</span> <span>=</span> <span>new</span> <span>URL</span>(<span>actorInformation</span>.<span>inbox</span>);
  182. </span></span><span><span>
  183. </span></span><span><span> <span>const</span> <span>response</span> <span>=</span> <span>await</span> <span>sendSignedRequest</span>(<span>actorInbox</span>, <span>acceptRequest</span>);
  184. </span></span><span><span>
  185. </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>());
  186. </span></span><span><span>
  187. </span></span><span><span> <span>return</span> <span>res</span>.<span>end</span>(<span>"ok"</span>)
  188. </span></span><span><span> }
  189. </span></span><span><span>
  190. </span></span><span><span> <span>if</span> (<span>message</span>.<span>type</span> <span>==</span> <span>"Undo"</span>) {
  191. </span></span><span><span> <span>// Undo a follow.
  192. </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>;
  193. </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>;
  194. </span></span><span><span> <span>if</span> (<span>undoObject</span>.<span>object</span> <span>==</span> <span>null</span>) <span>return</span>;
  195. </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>;
  196. </span></span><span><span>
  197. </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>);
  198. </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>();
  199. </span></span><span><span>
  200. </span></span><span><span> <span>console</span>.<span>log</span>(<span>"Deleted"</span>, <span>res</span>)
  201. </span></span><span><span> }
  202. </span></span><span><span>
  203. </span></span><span><span> <span>res</span>.<span>end</span>();
  204. </span></span><span><span>};
  205. </span></span></code></pre></div><ol>
  206. <li>Parse the <code>POST</code> body and cast it to an Activity object.</li>
  207. <li>Parse the signature of the request to verify the message hasn't been tampered with in transit.</li>
  208. <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>
  209. <li>Verify the message with their Public Key</li>
  210. </ol>
  211. <p>Now we believe that we have a valid messages.</p>
  212. <p>If the message is a <code>Follow</code> request</p>
  213. <ol>
  214. <li>See if the Actor trying to follow is already in the db, if they are return;</li>
  215. <li>Add the <code>Actor</code> to the <code>followers</code> collection in FireStore</li>
  216. <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>
  217. </ol>
  218. <p>If the message is an <code>Undo</code> for a <code>Follow</code> request.</p>
  219. <ol>
  220. <li>Find the data in the <code>followers</code> collection in FireStore</li>
  221. <li>Delete it.</li>
  222. </ol>
  223. <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>
  224. <h3 id="posting">Posting</h3>
  225. <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>
  226. <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>
  227. <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>}}
  228. </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>}}
  229. </span></span><span><span>{{<span>-</span> <span>$</span><span>pages</span> <span>:=</span> <span>slice</span> <span>-</span>}}
  230. </span></span><span><span>{{<span>-</span> <span>if</span> <span>or</span> <span>$</span>.<span>IsHome</span> <span>$</span>.<span>IsSection</span> <span>-</span>}}
  231. </span></span><span><span>{{<span>-</span> <span>$</span><span>pages</span> = <span>$</span><span>pctx</span>.<span>RegularPages</span> <span>-</span>}}
  232. </span></span><span><span>{{<span>-</span> <span>else</span> <span>-</span>}}
  233. </span></span><span><span>{{<span>-</span> <span>$</span><span>pages</span> = <span>$</span><span>pctx</span>.<span>Pages</span> <span>-</span>}}
  234. </span></span><span><span>{{<span>-</span> <span>end</span> <span>-</span>}}
  235. </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>}}
  236. </span></span><span><span>{{<span>-</span> <span>if</span> <span>ge</span> <span>$</span><span>limit</span> <span>1</span> <span>-</span>}}
  237. </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>}}
  238. </span></span><span><span>{{<span>-</span> <span>end</span> <span>-</span>}}
  239. </span></span><span><span>{
  240. </span></span><span><span> <span>"@context"</span>: <span>"https://www.w3.org/ns/activitystreams"</span>,
  241. </span></span><span><span> <span>"id"</span>: <span>"{{ $.Site.BaseURL }}outbox"</span>,
  242. </span></span><span><span> <span>"summary"</span>: <span>"{{$.Site.Author.name}} - {{$.Site.Title}}"</span>,
  243. </span></span><span><span> <span>"type"</span>: <span>"OrderedCollection"</span>,
  244. </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> }}
  245. </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>)}}
  246. </span></span><span><span> <span>"totalItems"</span>: {{(<span>len</span> <span>$</span><span>all</span>)}},
  247. </span></span><span><span> <span>"orderedItems"</span>: [
  248. </span></span><span><span> {{ <span>range</span> <span>$</span><span>index</span>, <span>$</span><span>element</span> <span>:=</span> <span>$</span><span>all</span> }}
  249. </span></span><span><span> {{<span>-</span> <span>if</span> <span>ne</span> <span>$</span><span>index</span> <span>0</span> }}, {{ <span>end</span> }}
  250. </span></span><span><span> {
  251. </span></span><span><span> <span>"@context"</span>: <span>"https://www.w3.org/ns/activitystreams"</span>,
  252. </span></span><span><span> <span>"id"</span>: <span>"{{.Permalink}}-create"</span>,
  253. </span></span><span><span> <span>"type"</span>: <span>"Create"</span>,
  254. </span></span><span><span> <span>"actor"</span>: <span>"https://paul.kinlan.me/paul"</span>,
  255. </span></span><span><span> <span>"object"</span>: {
  256. </span></span><span><span> <span>"id"</span>: <span>"{{ .Permalink }}"</span>,
  257. </span></span><span><span> <span>"type"</span>: <span>"Note"</span>,
  258. </span></span><span><span> <span>"content"</span>: <span>"{{.Title}}&lt;br&gt;{{.Summary}}"</span>,
  259. </span></span><span><span> <span>"url"</span>: <span>"{{.Permalink}}"</span>,
  260. </span></span><span><span> <span>"attributedTo"</span>: <span>"https://paul.kinlan.me/paul"</span>,
  261. </span></span><span><span> <span>"to"</span>: <span>"https://www.w3.org/ns/activitystreams#Public"</span>,
  262. </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> }}
  263. </span></span><span><span> }
  264. </span></span><span><span> }
  265. </span></span><span><span> {{<span>end</span>}}
  266. </span></span><span><span> ]
  267. </span></span><span><span>}
  268. </span></span></code></pre></div><p>I also set up Hugo to generate this file for the "home" output type as follows</p>
  269. <div class="highlight"><pre tabindex="0"><code class="language-toml" data-lang="toml"><span><span>[<span>mediaTypes</span>]
  270. </span></span><span><span>[<span>mediaTypes</span>.<span>"application/activity+json"</span>]
  271. </span></span><span><span><span>suffixes</span> = [<span>"ajson"</span>]
  272. </span></span><span><span>
  273. </span></span><span><span>[<span>outputFormats</span>]
  274. </span></span><span><span>[<span>outputFormats</span>.<span>ACTIVITY_OUTBOX</span>]
  275. </span></span><span><span><span>mediaType</span> = <span>"application/activity+json"</span>
  276. </span></span><span><span><span>notAlternative</span> = <span>true</span>
  277. </span></span><span><span><span>baseName</span> = <span>"outbox"</span>
  278. </span></span><span><span>
  279. </span></span><span><span>[<span>outputs</span>]
  280. </span></span><span><span><span>home</span> = [<span>"HTML"</span>, <span>"RSS"</span>, <span>"ACTIVITY_OUTBOX"</span>]
  281. </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>
  282. <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>;
  283. </span></span><span><span><span>import</span> { <span>join</span> } <span>from</span> <span>'path'</span>;
  284. </span></span><span><span><span>import</span> { <span>cwd</span> } <span>from</span> <span>'process'</span>;
  285. </span></span><span><span><span>import</span> { <span>readFileSync</span> } <span>from</span> <span>'fs'</span>;
  286. </span></span><span><span>
  287. </span></span><span><span><span>/*
  288. </span></span></span><span><span><span> This returns a list of posts for the single user 'Paul'.
  289. </span></span></span><span><span><span> It's a GET request. This doesn't post it to anyone's timeline.
  290. </span></span></span><span><span><span>*/</span>
  291. </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>) {
  292. </span></span><span><span> <span>// All of the outbox data is generated at build time, so just return that static file.
  293. </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>);
  294. </span></span><span><span> <span>const</span> <span>stringified</span> <span>=</span> <span>readFileSync</span>(<span>file</span>, <span>'utf8'</span>);
  295. </span></span><span><span>
  296. </span></span><span><span> <span>res</span>.<span>statusCode</span> <span>=</span> <span>200</span>;
  297. </span></span><span><span> <span>res</span>.<span>setHeader</span>(<span>"Content-Type"</span>, <span>`application/activity+json`</span>);
  298. </span></span><span><span>
  299. </span></span><span><span> <span>return</span> <span>res</span>.<span>end</span>(<span>stringified</span>);
  300. </span></span><span><span>};
  301. </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>
  302. <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>;
  303. </span></span><span><span><span>import</span> { <span>AP</span> } <span>from</span> <span>'activitypub-core-types'</span>;
  304. </span></span><span><span><span>import</span> <span>*</span> <span>as</span> <span>admin</span> <span>from</span> <span>'firebase-admin'</span>;
  305. </span></span><span><span><span>import</span> { <span>OrderedCollection</span> } <span>from</span> <span>'activitypub-core-types/lib/activitypub/index'</span>;
  306. </span></span><span><span><span>import</span> { <span>sendSignedRequest</span> } <span>from</span> <span>'../../lib/activitypub/utils/sendSignedRequest'</span>;
  307. </span></span><span><span><span>import</span> { <span>fetchActorInformation</span> } <span>from</span> <span>'../../lib/activitypub/utils/fetchActorInformation'</span>;
  308. </span></span><span><span>
  309. </span></span><span><span><span>process</span>.<span>env</span>.<span>NODE_TLS_REJECT_UNAUTHORIZED</span> <span>=</span> <span>'0'</span>;
  310. </span></span><span><span>
  311. </span></span><span><span><span>if</span> (<span>!</span><span>admin</span>.<span>apps</span>.<span>length</span>) {
  312. </span></span><span><span> <span>admin</span>.<span>initializeApp</span>({
  313. </span></span><span><span> <span>credential</span>: <span>admin.credential.cert</span>({
  314. </span></span><span><span> <span>projectId</span>: <span>process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID</span>,
  315. </span></span><span><span> <span>clientEmail</span>: <span>process.env.FIREBASE_CLIENT_EMAIL</span>,
  316. </span></span><span><span> <span>privateKey</span>: <span>process.env.FIREBASE_PRIVATE_KEY.replace</span>(<span>/\\n/g</span>, <span>'\n'</span>)
  317. </span></span><span><span> })
  318. </span></span><span><span> });
  319. </span></span><span><span>}
  320. </span></span><span><span>
  321. </span></span><span><span><span>const</span> <span>db</span> <span>=</span> <span>admin</span>.<span>firestore</span>();
  322. </span></span><span><span>
  323. </span></span><span><span><span>export</span> <span>const</span> <span>config</span> <span>=</span> {
  324. </span></span><span><span> <span>api</span><span>:</span> {
  325. </span></span><span><span> <span>bodyParser</span>: <span>false</span>
  326. </span></span><span><span> }
  327. </span></span><span><span>};
  328. </span></span><span><span>
  329. </span></span><span><span><span>/*
  330. </span></span></span><span><span><span> Sends the latest not that hasn't yet been sent.
  331. </span></span></span><span><span><span>*/</span>
  332. </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>) {
  333. </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>;
  334. </span></span><span><span> <span>const</span> { <span>token</span> } <span>=</span> <span>query</span>;
  335. </span></span><span><span>
  336. </span></span><span><span> <span>if</span> (<span>method</span> <span>!=</span> <span>"POST"</span>) {
  337. </span></span><span><span> <span>res</span>.<span>status</span>(<span>401</span>).<span>end</span>(<span>"Invalid Method, must be POST"</span>);
  338. </span></span><span><span> <span>return</span>;
  339. </span></span><span><span> }
  340. </span></span><span><span>
  341. </span></span><span><span> <span>if</span> (<span>token</span> <span>!=</span> <span>process</span>.<span>env</span>.<span>ACTIVITYPUB_CREATE_TOKEN</span>) {
  342. </span></span><span><span> <span>res</span>.<span>status</span>(<span>401</span>).<span>end</span>(<span>"Invalid token"</span>);
  343. </span></span><span><span> <span>return</span>;
  344. </span></span><span><span> }
  345. </span></span><span><span>
  346. </span></span><span><span> <span>const</span> <span>configCollection</span> <span>=</span> <span>db</span>.<span>collection</span>(<span>'config'</span>);
  347. </span></span><span><span> <span>const</span> <span>configRef</span> <span>=</span> <span>configCollection</span>.<span>doc</span>(<span>"config"</span>);
  348. </span></span><span><span> <span>const</span> <span>config</span> <span>=</span> <span>await</span> <span>configRef</span>.<span>get</span>();
  349. </span></span><span><span>
  350. </span></span><span><span> <span>if</span> (<span>config</span>.<span>exists</span> <span>==</span> <span>false</span>) {
  351. </span></span><span><span> <span>// Config doesn't exist, make something
  352. </span></span></span><span><span><span></span> <span>configRef</span>.<span>set</span>({
  353. </span></span><span><span> <span>"lastId"</span><span>:</span> <span>0</span>
  354. </span></span><span><span> });
  355. </span></span><span><span> }
  356. </span></span><span><span>
  357. </span></span><span><span> <span>const</span> <span>configData</span> <span>=</span> <span>config</span>.<span>data</span>();
  358. </span></span><span><span> <span>let</span> <span>lastId</span> <span>=</span> <span>0</span>;
  359. </span></span><span><span> <span>if</span> (<span>configData</span> <span>!=</span> <span>undefined</span>) {
  360. </span></span><span><span> <span>lastId</span> <span>=</span> <span>configData</span>.<span>lastId</span>;
  361. </span></span><span><span> }
  362. </span></span><span><span>
  363. </span></span><span><span> <span>// Get my outbox because it contains all my notes.
  364. </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>);
  365. </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>());
  366. </span></span><span><span>
  367. </span></span><span><span> <span>const</span> <span>followersCollection</span> <span>=</span> <span>db</span>.<span>collection</span>(<span>'followers'</span>);
  368. </span></span><span><span> <span>const</span> <span>followersQuerySnapshot</span> <span>=</span> <span>await</span> <span>followersCollection</span>.<span>get</span>();
  369. </span></span><span><span>
  370. </span></span><span><span> <span>for</span> (<span>const</span> <span>followerDoc</span> <span>of</span> <span>followersQuerySnapshot</span>.<span>docs</span>) {
  371. </span></span><span><span> <span>const</span> <span>follower</span> <span>=</span> <span>followerDoc</span>.<span>data</span>();
  372. </span></span><span><span> <span>try</span> {
  373. </span></span><span><span> <span>const</span> <span>actorInformation</span> <span>=</span> <span>await</span> <span>fetchActorInformation</span>(<span>follower</span>.<span>actor</span>);
  374. </span></span><span><span> <span>const</span> <span>actorInbox</span> <span>=</span> <span>new</span> <span>URL</span>(<span>actorInformation</span>.<span>inbox</span>);
  375. </span></span><span><span>
  376. </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>)) {
  377. </span></span><span><span> <span>// We have to break somewhere... do it after the first.
  378. </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>];
  379. </span></span><span><span>
  380. </span></span><span><span> <span>if</span> (<span>item</span>.<span>object</span> <span>!=</span> <span>undefined</span>) {
  381. </span></span><span><span> <span>// We might not need this.
  382. </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>();
  383. </span></span><span><span> }
  384. </span></span><span><span>
  385. </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>);
  386. </span></span><span><span>
  387. </span></span><span><span> <span>// Item will be an entity, i.e, { Create { Note } }
  388. </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>);
  389. </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>());
  390. </span></span><span><span>
  391. </span></span><span><span> <span>break</span>;
  392. </span></span><span><span> }
  393. </span></span><span><span> } <span>catch</span> (<span>ex</span>) {
  394. </span></span><span><span> <span>console</span>.<span>log</span>(<span>"Error"</span>, <span>ex</span>, <span>follower</span>);
  395. </span></span><span><span> }
  396. </span></span><span><span> }
  397. </span></span><span><span>
  398. </span></span><span><span> <span>res</span>.<span>status</span>(<span>200</span>).<span>end</span>(<span>"ok"</span>);
  399. </span></span><span><span>};
  400. </span></span></code></pre></div><p>The above code is relative long but the summary of it is as follows:</p>
  401. <ol>
  402. <li>Scan the outbox</li>
  403. <li>Pick the first post (I am only sending one note)</li>
  404. <li>For each follower in the <code>followers</code> table
  405. <ol>
  406. <li>Get their actor information (where their inbox is)</li>
  407. <li>Send the <code>Create</code> object from the outbox to them via a signed HTTP request</li>
  408. </ol>
  409. </li>
  410. </ol>
  411. <h3 id="voila">Voila</h3>
  412. <p>Simple... Nah. I think it's pretty complex, but it works.</p>
  413. <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>