A place to cache linked articles (think custom and personal wayback machine)
Du kannst nicht mehr als 25 Themen auswählen Themen müssen mit entweder einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.

vor 1 Jahr
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>