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.html 50KB

1 year ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588
  1. <!doctype html><!-- This is a valid HTML5 document. -->
  2. <!-- Screen readers, SEO, extensions and so on. -->
  3. <html lang="fr">
  4. <!-- Has to be within the first 1024 bytes, hence before the `title` element
  5. See: https://www.w3.org/TR/2012/CR-html5-20121217/document-metadata.html#charset -->
  6. <meta charset="utf-8">
  7. <!-- Why no `X-UA-Compatible` meta: https://stackoverflow.com/a/6771584 -->
  8. <!-- The viewport meta is quite crowded and we are responsible for that.
  9. See: https://codepen.io/tigt/post/meta-viewport-for-2015 -->
  10. <meta name="viewport" content="width=device-width,initial-scale=1">
  11. <!-- Required to make a valid HTML5 document. -->
  12. <title>Adding ActivityPub to your static site (archive) — David Larlet</title>
  13. <meta name="description" content="Publication mise en cache pour en conserver une trace.">
  14. <!-- That good ol' feed, subscribe :). -->
  15. <link rel="alternate" type="application/atom+xml" title="Feed" href="/david/log/">
  16. <!-- Generated from https://realfavicongenerator.net/ such a mess. -->
  17. <link rel="apple-touch-icon" sizes="180x180" href="/static/david/icons2/apple-touch-icon.png">
  18. <link rel="icon" type="image/png" sizes="32x32" href="/static/david/icons2/favicon-32x32.png">
  19. <link rel="icon" type="image/png" sizes="16x16" href="/static/david/icons2/favicon-16x16.png">
  20. <link rel="manifest" href="/static/david/icons2/site.webmanifest">
  21. <link rel="mask-icon" href="/static/david/icons2/safari-pinned-tab.svg" color="#07486c">
  22. <link rel="shortcut icon" href="/static/david/icons2/favicon.ico">
  23. <meta name="msapplication-TileColor" content="#f7f7f7">
  24. <meta name="msapplication-config" content="/static/david/icons2/browserconfig.xml">
  25. <meta name="theme-color" content="#f7f7f7" media="(prefers-color-scheme: light)">
  26. <meta name="theme-color" content="#272727" media="(prefers-color-scheme: dark)">
  27. <!-- Documented, feel free to shoot an email. -->
  28. <link rel="stylesheet" href="/static/david/css/style_2021-01-20.css">
  29. <!-- See https://www.zachleat.com/web/comprehensive-webfonts/ for the trade-off. -->
  30. <link rel="preload" href="/static/david/css/fonts/triplicate_t4_poly_regular.woff2" as="font" type="font/woff2" media="(prefers-color-scheme: light), (prefers-color-scheme: no-preference)" crossorigin>
  31. <link rel="preload" href="/static/david/css/fonts/triplicate_t4_poly_bold.woff2" as="font" type="font/woff2" media="(prefers-color-scheme: light), (prefers-color-scheme: no-preference)" crossorigin>
  32. <link rel="preload" href="/static/david/css/fonts/triplicate_t4_poly_italic.woff2" as="font" type="font/woff2" media="(prefers-color-scheme: light), (prefers-color-scheme: no-preference)" crossorigin>
  33. <link rel="preload" href="/static/david/css/fonts/triplicate_t3_regular.woff2" as="font" type="font/woff2" media="(prefers-color-scheme: dark)" crossorigin>
  34. <link rel="preload" href="/static/david/css/fonts/triplicate_t3_bold.woff2" as="font" type="font/woff2" media="(prefers-color-scheme: dark)" crossorigin>
  35. <link rel="preload" href="/static/david/css/fonts/triplicate_t3_italic.woff2" as="font" type="font/woff2" media="(prefers-color-scheme: dark)" crossorigin>
  36. <script>
  37. function toggleTheme(themeName) {
  38. document.documentElement.classList.toggle(
  39. 'forced-dark',
  40. themeName === 'dark'
  41. )
  42. document.documentElement.classList.toggle(
  43. 'forced-light',
  44. themeName === 'light'
  45. )
  46. }
  47. const selectedTheme = localStorage.getItem('theme')
  48. if (selectedTheme !== 'undefined') {
  49. toggleTheme(selectedTheme)
  50. }
  51. </script>
  52. <meta name="robots" content="noindex, nofollow">
  53. <meta content="origin-when-cross-origin" name="referrer">
  54. <!-- Canonical URL for SEO purposes -->
  55. <link rel="canonical" href="https://paul.kinlan.me/adding-activity-pub-to-your-static-site/">
  56. <body class="remarkdown h1-underline h2-underline h3-underline em-underscore hr-center ul-star pre-tick" data-instant-intensity="viewport-all">
  57. <article>
  58. <header>
  59. <h1>Adding ActivityPub to your static site</h1>
  60. </header>
  61. <nav>
  62. <p class="center">
  63. <a href="/david/" title="Aller à l’accueil"><svg class="icon icon-home">
  64. <use xlink:href="/static/david/icons2/symbol-defs-2021-12.svg#icon-home"></use>
  65. </svg> Accueil</a> •
  66. <a href="https://paul.kinlan.me/adding-activity-pub-to-your-static-site/" title="Lien vers le contenu original">Source originale</a>
  67. </p>
  68. </nav>
  69. <hr>
  70. <p>My blog is built on Hugo and hosted on Vercel. It mostly works well.</p>
  71. <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>
  72. <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>
  73. <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>
  74. <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>
  75. <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>
  76. <p>Hopefully this post will help you get started if you want to go down a similar path.</p>
  77. <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>
  78. <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>
  79. <h3 id="discovery">Discovery</h3>
  80. <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>
  81. <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>;
  82. </span></span><span><span>
  83. </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>) {
  84. </span></span><span><span> <span>res</span>.<span>statusCode</span> <span>=</span> <span>200</span>;
  85. </span></span><span><span> <span>res</span>.<span>setHeader</span>(<span>"Content-Type"</span>, <span>`application/jrd+json`</span>);
  86. </span></span><span><span> <span>res</span>.<span>end</span>(<span>`{
  87. </span></span></span><span><span><span> "subject": "acct:paul@paul.kinlan.me",
  88. </span></span></span><span><span><span> "aliases": [
  89. </span></span></span><span><span><span> "https://status.kinlan.me/@paul"
  90. </span></span></span><span><span><span> ],
  91. </span></span></span><span><span><span> "links": [
  92. </span></span></span><span><span><span> {
  93. </span></span></span><span><span><span> "rel": "self",
  94. </span></span></span><span><span><span> "type": "application/activity+json",
  95. </span></span></span><span><span><span> "href": "https://paul.kinlan.me/paul"
  96. </span></span></span><span><span><span> }
  97. </span></span></span><span><span><span> ]
  98. </span></span></span><span><span><span> }`</span>);
  99. </span></span><span><span>}
  100. </span></span></code></pre></div>
  101. <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>
  102. <p><strong>Note</strong>: You need to make sure you are sending the correct MIME types.</p>
  103. <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>
  104. <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>
  105. <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>
  106. <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>;
  107. </span></span><span><span>
  108. </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>) {
  109. </span></span><span><span> <span>res</span>.<span>statusCode</span> <span>=</span> <span>200</span>;
  110. </span></span><span><span> <span>res</span>.<span>setHeader</span>(<span>"Content-Type"</span>, <span>`application/activity+json`</span>);
  111. </span></span><span><span> <span>res</span>.<span>json</span>({
  112. </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> }],
  113. </span></span><span><span> <span>"type"</span><span>:</span> <span>"Person"</span>,
  114. </span></span><span><span> <span>"id"</span><span>:</span> <span>"https://paul.kinlan.me/paul"</span>,
  115. </span></span><span><span> <span>"outbox"</span><span>:</span> <span>"https://paul.kinlan.me/outbox"</span>,
  116. </span></span><span><span> <span>"following"</span><span>:</span> <span>"https://paul.kinlan.me/following"</span>,
  117. </span></span><span><span> <span>"followers"</span><span>:</span> <span>"https://paul.kinlan.me/followers"</span>,
  118. </span></span><span><span> <span>"inbox"</span><span>:</span> <span>"https://paul.kinlan.me/inbox"</span>,
  119. </span></span><span><span> <span>"preferredUsername"</span><span>:</span> <span>"paul"</span>,
  120. </span></span><span><span> <span>"name"</span><span>:</span> <span>"Paul Kinlan - Modern Web Development with Chrome"</span>,
  121. </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>,
  122. </span></span><span><span> <span>"icon"</span><span>:</span> [
  123. </span></span><span><span> <span>"https://paul.kinlan.me/images/me.png"</span>
  124. </span></span><span><span> ],
  125. </span></span><span><span> <span>"publicKey"</span><span>:</span> {
  126. </span></span><span><span> <span>"@context"</span><span>:</span> <span>"https://w3id.org/security/v1"</span>,
  127. </span></span><span><span> <span>"@type"</span><span>:</span> <span>"Key"</span>,
  128. </span></span><span><span> <span>"id"</span><span>:</span> <span>"https://paul.kinlan.me/paul#main-key"</span>,
  129. </span></span><span><span> <span>"owner"</span><span>:</span> <span>"https://paul.kinlan.me/paul"</span>,
  130. </span></span><span><span> <span>"publicKeyPem"</span><span>:</span> <span>process</span>.<span>env</span>.<span>ACTIVITYPUB_PUBLIC_KEY</span>
  131. </span></span><span><span> }
  132. </span></span><span><span> });
  133. </span></span><span><span>}
  134. </span></span></code></pre></div>
  135. <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>
  136. <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>
  137. <h3 id="following">Following</h3>
  138. <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>
  139. <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>
  140. <p>The entire flow is very complex so I will try and explain it as best I can.</p>
  141. <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>
  142. <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>;
  143. </span></span><span><span><span>import</span> { <span>AP</span> } <span>from</span> <span>'activitypub-core-types'</span>;
  144. </span></span><span><span><span>import</span> <span>type</span> { <span>Readable</span> } <span>from</span> <span>'node:stream'</span>;
  145. </span></span><span><span><span>import</span> <span>*</span> <span>as</span> <span>admin</span> <span>from</span> <span>'firebase-admin'</span>;
  146. </span></span><span><span><span>import</span> { <span>v4</span> <span>as</span> <span>uuid</span> } <span>from</span> <span>'uuid'</span>;
  147. </span></span><span><span><span>import</span> { <span>CoreObject</span>, <span>Entity</span> } <span>from</span> <span>'activitypub-core-types/lib/activitypub/index'</span>;
  148. </span></span><span><span><span>import</span> { <span>sendSignedRequest</span> } <span>from</span> <span>'../../lib/activitypub/sendSignedRequest'</span>;
  149. </span></span><span><span><span>import</span> { <span>parseSignature</span> } <span>from</span> <span>'../../lib/activitypub/utils/parseSignature'</span>;
  150. </span></span><span><span><span>import</span> { <span>fetchActorInformation</span> } <span>from</span> <span>'../../lib/activitypub/utils/fetchActorInformation'</span>;
  151. </span></span><span><span>
  152. </span></span><span><span><span>process</span>.<span>env</span>.<span>NODE_TLS_REJECT_UNAUTHORIZED</span> <span>=</span> <span>'0'</span>;
  153. </span></span><span><span>
  154. </span></span><span><span><span>if</span> (<span>!</span><span>admin</span>.<span>apps</span>.<span>length</span>) {
  155. </span></span><span><span> <span>admin</span>.<span>initializeApp</span>({
  156. </span></span><span><span> <span>credential</span>: <span>admin.credential.cert</span>({
  157. </span></span><span><span> <span>projectId</span>: <span>process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID</span>,
  158. </span></span><span><span> <span>clientEmail</span>: <span>process.env.FIREBASE_CLIENT_EMAIL</span>,
  159. </span></span><span><span> <span>privateKey</span>: <span>process.env.FIREBASE_PRIVATE_KEY.replace</span>(<span>/\\n/g</span>, <span>'\n'</span>)
  160. </span></span><span><span> })
  161. </span></span><span><span> });
  162. </span></span><span><span>}
  163. </span></span><span><span>
  164. </span></span><span><span><span>const</span> <span>db</span> <span>=</span> <span>admin</span>.<span>firestore</span>();
  165. </span></span><span><span>
  166. </span></span><span><span><span>export</span> <span>const</span> <span>config</span> <span>=</span> {
  167. </span></span><span><span> <span>api</span><span>:</span> {
  168. </span></span><span><span> <span>bodyParser</span>: <span>false</span>,
  169. </span></span><span><span> },
  170. </span></span><span><span>};
  171. </span></span><span><span>
  172. </span></span><span><span><span>async</span> <span>function</span> <span>buffer</span>(<span>readable</span>: <span>Readable</span>) {
  173. </span></span><span><span> <span>const</span> <span>chunks</span> <span>=</span> [];
  174. </span></span><span><span> <span>for</span> <span>await</span> (<span>const</span> <span>chunk</span> <span>of</span> <span>readable</span>) {
  175. </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>);
  176. </span></span><span><span> }
  177. </span></span><span><span> <span>return</span> <span>Buffer</span>.<span>concat</span>(<span>chunks</span>);
  178. </span></span><span><span>}
  179. </span></span><span><span>
  180. </span></span><span><span><span>function</span> <span>verifySignature</span>(<span>signature</span>, <span>publicKeyJson</span>) {
  181. </span></span><span><span> <span>let</span> <span>signatureValid</span>;
  182. </span></span><span><span>
  183. </span></span><span><span> <span>try</span> {
  184. </span></span><span><span> <span>// Verify the signature
  185. </span></span></span><span><span><span></span> <span>signatureValid</span> <span>=</span> <span>signature</span>.<span>verify</span>(
  186. </span></span><span><span> <span>publicKeyJson</span>.<span>publicKeyPem</span>, <span>// The PEM string from the public key object
  187. </span></span></span><span><span><span></span> );
  188. </span></span><span><span> } <span>catch</span> (<span>error</span>) {
  189. </span></span><span><span> <span>console</span>.<span>log</span>(<span>"Signature Verification error"</span>, <span>error</span>)
  190. </span></span><span><span> }
  191. </span></span><span><span>
  192. </span></span><span><span> <span>return</span> <span>signatureValid</span>;
  193. </span></span><span><span>}
  194. </span></span><span><span>
  195. </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>) {
  196. </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>;
  197. </span></span><span><span>
  198. </span></span><span><span> <span>res</span>.<span>statusCode</span> <span>=</span> <span>200</span>;
  199. </span></span><span><span> <span>res</span>.<span>setHeader</span>(<span>"Content-Type"</span>, <span>`application/activity+json`</span>);
  200. </span></span><span><span>
  201. </span></span><span><span> <span>// Verify the message some how.
  202. </span></span></span><span><span><span></span> <span>const</span> <span>buf</span> <span>=</span> <span>await</span> <span>buffer</span>(<span>req</span>);
  203. </span></span><span><span> <span>const</span> <span>rawBody</span> <span>=</span> <span>buf</span>.<span>toString</span>(<span>'utf8'</span>);
  204. </span></span><span><span>
  205. </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>);
  206. </span></span><span><span>
  207. </span></span><span><span> <span>console</span>.<span>log</span>(<span>message</span>);
  208. </span></span><span><span>
  209. </span></span><span><span> <span>const</span> <span>signature</span> <span>=</span> <span>parseSignature</span>(<span>req</span>);
  210. </span></span><span><span> <span>const</span> <span>actorInformation</span> <span>=</span> <span>await</span> <span>fetchActorInformation</span>(<span>signature</span>.<span>keyId</span>);
  211. </span></span><span><span> <span>const</span> <span>signatureValid</span> <span>=</span> <span>verifySignature</span>(<span>signature</span>, <span>actorInformation</span>.<span>publicKey</span>);
  212. </span></span><span><span>
  213. </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>) {
  214. </span></span><span><span> <span>res</span>.<span>end</span>(<span>'invalid signature'</span>);
  215. </span></span><span><span> <span>return</span>;
  216. </span></span><span><span> }
  217. </span></span><span><span>
  218. </span></span><span><span> <span>// We should check the digest.
  219. </span></span></span><span><span><span></span> <span>if</span> (<span>message</span>.<span>type</span> <span>==</span> <span>"Follow"</span>) {
  220. </span></span><span><span> <span>// We are following.
  221. </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>;
  222. </span></span><span><span> <span>if</span> (<span>followMessage</span>.<span>id</span> <span>==</span> <span>null</span>) <span>return</span>;
  223. </span></span><span><span>
  224. </span></span><span><span> <span>const</span> <span>collection</span> <span>=</span> <span>db</span>.<span>collection</span>(<span>'followers'</span>);
  225. </span></span><span><span>
  226. </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>();
  227. </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>));
  228. </span></span><span><span> <span>const</span> <span>followDoc</span> <span>=</span> <span>await</span> <span>followDocRef</span>.<span>get</span>();
  229. </span></span><span><span>
  230. </span></span><span><span> <span>if</span> (<span>followDoc</span>.<span>exists</span>) {
  231. </span></span><span><span> <span>console</span>.<span>log</span>(<span>"Already Following"</span>)
  232. </span></span><span><span> <span>return</span> <span>res</span>.<span>end</span>(<span>'already following'</span>);
  233. </span></span><span><span> }
  234. </span></span><span><span>
  235. </span></span><span><span> <span>// Create the follow;
  236. </span></span></span><span><span><span></span> <span>await</span> <span>followDocRef</span>.<span>set</span>(<span>followMessage</span>);
  237. </span></span><span><span>
  238. </span></span><span><span> <span>const</span> <span>guid</span> <span>=</span> <span>uuid</span>();
  239. </span></span><span><span> <span>const</span> <span>domain</span> <span>=</span> <span>'paul.kinlan.me'</span>;
  240. </span></span><span><span>
  241. </span></span><span><span> <span>const</span> <span>acceptRequest</span>: <span>AP.Accept</span> <span>=</span> &lt;<span>AP.Accept</span>&gt;{
  242. </span></span><span><span> <span>"@context"</span><span>:</span> <span>"https://www.w3.org/ns/activitystreams"</span>,
  243. </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>),
  244. </span></span><span><span> <span>'type'</span><span>:</span> <span>'Accept'</span>,
  245. </span></span><span><span> <span>'actor'</span><span>:</span> <span>"https://paul.kinlan.me/paul"</span>,
  246. </span></span><span><span> <span>'object'</span><span>:</span> <span>followMessage</span>
  247. </span></span><span><span> };
  248. </span></span><span><span>
  249. </span></span><span><span> <span>const</span> <span>actorInbox</span> <span>=</span> <span>new</span> <span>URL</span>(<span>actorInformation</span>.<span>inbox</span>);
  250. </span></span><span><span>
  251. </span></span><span><span> <span>const</span> <span>response</span> <span>=</span> <span>await</span> <span>sendSignedRequest</span>(<span>actorInbox</span>, <span>acceptRequest</span>);
  252. </span></span><span><span>
  253. </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>());
  254. </span></span><span><span>
  255. </span></span><span><span> <span>return</span> <span>res</span>.<span>end</span>(<span>"ok"</span>)
  256. </span></span><span><span> }
  257. </span></span><span><span>
  258. </span></span><span><span> <span>if</span> (<span>message</span>.<span>type</span> <span>==</span> <span>"Undo"</span>) {
  259. </span></span><span><span> <span>// Undo a follow.
  260. </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>;
  261. </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>;
  262. </span></span><span><span> <span>if</span> (<span>undoObject</span>.<span>object</span> <span>==</span> <span>null</span>) <span>return</span>;
  263. </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>;
  264. </span></span><span><span>
  265. </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>);
  266. </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>();
  267. </span></span><span><span>
  268. </span></span><span><span> <span>console</span>.<span>log</span>(<span>"Deleted"</span>, <span>res</span>)
  269. </span></span><span><span> }
  270. </span></span><span><span>
  271. </span></span><span><span> <span>res</span>.<span>end</span>();
  272. </span></span><span><span>};
  273. </span></span></code></pre></div>
  274. <ol>
  275. <li>Parse the <code>POST</code> body and cast it to an Activity object.</li>
  276. <li>Parse the signature of the request to verify the message hasn't been tampered with in transit.</li>
  277. <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>
  278. <li>Verify the message with their Public Key</li>
  279. </ol>
  280. <p>Now we believe that we have a valid messages.</p>
  281. <p>If the message is a <code>Follow</code> request</p>
  282. <ol>
  283. <li>See if the Actor trying to follow is already in the db, if they are return;</li>
  284. <li>Add the <code>Actor</code> to the <code>followers</code> collection in FireStore</li>
  285. <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>
  286. </ol>
  287. <p>If the message is an <code>Undo</code> for a <code>Follow</code> request.</p>
  288. <ol>
  289. <li>Find the data in the <code>followers</code> collection in FireStore</li>
  290. <li>Delete it.</li>
  291. </ol>
  292. <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>
  293. <h3 id="posting">Posting</h3>
  294. <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>
  295. <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>
  296. <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>}}
  297. </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>}}
  298. </span></span><span><span>{{<span>-</span> <span>$</span><span>pages</span> <span>:=</span> <span>slice</span> <span>-</span>}}
  299. </span></span><span><span>{{<span>-</span> <span>if</span> <span>or</span> <span>$</span>.<span>IsHome</span> <span>$</span>.<span>IsSection</span> <span>-</span>}}
  300. </span></span><span><span>{{<span>-</span> <span>$</span><span>pages</span> = <span>$</span><span>pctx</span>.<span>RegularPages</span> <span>-</span>}}
  301. </span></span><span><span>{{<span>-</span> <span>else</span> <span>-</span>}}
  302. </span></span><span><span>{{<span>-</span> <span>$</span><span>pages</span> = <span>$</span><span>pctx</span>.<span>Pages</span> <span>-</span>}}
  303. </span></span><span><span>{{<span>-</span> <span>end</span> <span>-</span>}}
  304. </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>}}
  305. </span></span><span><span>{{<span>-</span> <span>if</span> <span>ge</span> <span>$</span><span>limit</span> <span>1</span> <span>-</span>}}
  306. </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>}}
  307. </span></span><span><span>{{<span>-</span> <span>end</span> <span>-</span>}}
  308. </span></span><span><span>{
  309. </span></span><span><span> <span>"@context"</span>: <span>"https://www.w3.org/ns/activitystreams"</span>,
  310. </span></span><span><span> <span>"id"</span>: <span>"{{ $.Site.BaseURL }}outbox"</span>,
  311. </span></span><span><span> <span>"summary"</span>: <span>"{{$.Site.Author.name}} - {{$.Site.Title}}"</span>,
  312. </span></span><span><span> <span>"type"</span>: <span>"OrderedCollection"</span>,
  313. </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> }}
  314. </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>)}}
  315. </span></span><span><span> <span>"totalItems"</span>: {{(<span>len</span> <span>$</span><span>all</span>)}},
  316. </span></span><span><span> <span>"orderedItems"</span>: [
  317. </span></span><span><span> {{ <span>range</span> <span>$</span><span>index</span>, <span>$</span><span>element</span> <span>:=</span> <span>$</span><span>all</span> }}
  318. </span></span><span><span> {{<span>-</span> <span>if</span> <span>ne</span> <span>$</span><span>index</span> <span>0</span> }}, {{ <span>end</span> }}
  319. </span></span><span><span> {
  320. </span></span><span><span> <span>"@context"</span>: <span>"https://www.w3.org/ns/activitystreams"</span>,
  321. </span></span><span><span> <span>"id"</span>: <span>"{{.Permalink}}-create"</span>,
  322. </span></span><span><span> <span>"type"</span>: <span>"Create"</span>,
  323. </span></span><span><span> <span>"actor"</span>: <span>"https://paul.kinlan.me/paul"</span>,
  324. </span></span><span><span> <span>"object"</span>: {
  325. </span></span><span><span> <span>"id"</span>: <span>"{{ .Permalink }}"</span>,
  326. </span></span><span><span> <span>"type"</span>: <span>"Note"</span>,
  327. </span></span><span><span> <span>"content"</span>: <span>"{{.Title}}&lt;br&gt;{{.Summary}}"</span>,
  328. </span></span><span><span> <span>"url"</span>: <span>"{{.Permalink}}"</span>,
  329. </span></span><span><span> <span>"attributedTo"</span>: <span>"https://paul.kinlan.me/paul"</span>,
  330. </span></span><span><span> <span>"to"</span>: <span>"https://www.w3.org/ns/activitystreams#Public"</span>,
  331. </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> }}
  332. </span></span><span><span> }
  333. </span></span><span><span> }
  334. </span></span><span><span> {{<span>end</span>}}
  335. </span></span><span><span> ]
  336. </span></span><span><span>}
  337. </span></span></code></pre></div>
  338. <p>I also set up Hugo to generate this file for the "home" output type as follows</p>
  339. <div class="highlight"><pre tabindex="0"><code class="language-toml" data-lang="toml"><span><span>[<span>mediaTypes</span>]
  340. </span></span><span><span>[<span>mediaTypes</span>.<span>"application/activity+json"</span>]
  341. </span></span><span><span><span>suffixes</span> = [<span>"ajson"</span>]
  342. </span></span><span><span>
  343. </span></span><span><span>[<span>outputFormats</span>]
  344. </span></span><span><span>[<span>outputFormats</span>.<span>ACTIVITY_OUTBOX</span>]
  345. </span></span><span><span><span>mediaType</span> = <span>"application/activity+json"</span>
  346. </span></span><span><span><span>notAlternative</span> = <span>true</span>
  347. </span></span><span><span><span>baseName</span> = <span>"outbox"</span>
  348. </span></span><span><span>
  349. </span></span><span><span>[<span>outputs</span>]
  350. </span></span><span><span><span>home</span> = [<span>"HTML"</span>, <span>"RSS"</span>, <span>"ACTIVITY_OUTBOX"</span>]
  351. </span></span></code></pre></div>
  352. <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>
  353. <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>;
  354. </span></span><span><span><span>import</span> { <span>join</span> } <span>from</span> <span>'path'</span>;
  355. </span></span><span><span><span>import</span> { <span>cwd</span> } <span>from</span> <span>'process'</span>;
  356. </span></span><span><span><span>import</span> { <span>readFileSync</span> } <span>from</span> <span>'fs'</span>;
  357. </span></span><span><span>
  358. </span></span><span><span><span>/*
  359. </span></span></span><span><span><span> This returns a list of posts for the single user 'Paul'.
  360. </span></span></span><span><span><span> It's a GET request. This doesn't post it to anyone's timeline.
  361. </span></span></span><span><span><span>*/</span>
  362. </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>) {
  363. </span></span><span><span> <span>// All of the outbox data is generated at build time, so just return that static file.
  364. </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>);
  365. </span></span><span><span> <span>const</span> <span>stringified</span> <span>=</span> <span>readFileSync</span>(<span>file</span>, <span>'utf8'</span>);
  366. </span></span><span><span>
  367. </span></span><span><span> <span>res</span>.<span>statusCode</span> <span>=</span> <span>200</span>;
  368. </span></span><span><span> <span>res</span>.<span>setHeader</span>(<span>"Content-Type"</span>, <span>`application/activity+json`</span>);
  369. </span></span><span><span>
  370. </span></span><span><span> <span>return</span> <span>res</span>.<span>end</span>(<span>stringified</span>);
  371. </span></span><span><span>};
  372. </span></span></code></pre></div>
  373. <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>
  374. <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>;
  375. </span></span><span><span><span>import</span> { <span>AP</span> } <span>from</span> <span>'activitypub-core-types'</span>;
  376. </span></span><span><span><span>import</span> <span>*</span> <span>as</span> <span>admin</span> <span>from</span> <span>'firebase-admin'</span>;
  377. </span></span><span><span><span>import</span> { <span>OrderedCollection</span> } <span>from</span> <span>'activitypub-core-types/lib/activitypub/index'</span>;
  378. </span></span><span><span><span>import</span> { <span>sendSignedRequest</span> } <span>from</span> <span>'../../lib/activitypub/utils/sendSignedRequest'</span>;
  379. </span></span><span><span><span>import</span> { <span>fetchActorInformation</span> } <span>from</span> <span>'../../lib/activitypub/utils/fetchActorInformation'</span>;
  380. </span></span><span><span>
  381. </span></span><span><span><span>process</span>.<span>env</span>.<span>NODE_TLS_REJECT_UNAUTHORIZED</span> <span>=</span> <span>'0'</span>;
  382. </span></span><span><span>
  383. </span></span><span><span><span>if</span> (<span>!</span><span>admin</span>.<span>apps</span>.<span>length</span>) {
  384. </span></span><span><span> <span>admin</span>.<span>initializeApp</span>({
  385. </span></span><span><span> <span>credential</span>: <span>admin.credential.cert</span>({
  386. </span></span><span><span> <span>projectId</span>: <span>process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID</span>,
  387. </span></span><span><span> <span>clientEmail</span>: <span>process.env.FIREBASE_CLIENT_EMAIL</span>,
  388. </span></span><span><span> <span>privateKey</span>: <span>process.env.FIREBASE_PRIVATE_KEY.replace</span>(<span>/\\n/g</span>, <span>'\n'</span>)
  389. </span></span><span><span> })
  390. </span></span><span><span> });
  391. </span></span><span><span>}
  392. </span></span><span><span>
  393. </span></span><span><span><span>const</span> <span>db</span> <span>=</span> <span>admin</span>.<span>firestore</span>();
  394. </span></span><span><span>
  395. </span></span><span><span><span>export</span> <span>const</span> <span>config</span> <span>=</span> {
  396. </span></span><span><span> <span>api</span><span>:</span> {
  397. </span></span><span><span> <span>bodyParser</span>: <span>false</span>
  398. </span></span><span><span> }
  399. </span></span><span><span>};
  400. </span></span><span><span>
  401. </span></span><span><span><span>/*
  402. </span></span></span><span><span><span> Sends the latest not that hasn't yet been sent.
  403. </span></span></span><span><span><span>*/</span>
  404. </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>) {
  405. </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>;
  406. </span></span><span><span> <span>const</span> { <span>token</span> } <span>=</span> <span>query</span>;
  407. </span></span><span><span>
  408. </span></span><span><span> <span>if</span> (<span>method</span> <span>!=</span> <span>"POST"</span>) {
  409. </span></span><span><span> <span>res</span>.<span>status</span>(<span>401</span>).<span>end</span>(<span>"Invalid Method, must be POST"</span>);
  410. </span></span><span><span> <span>return</span>;
  411. </span></span><span><span> }
  412. </span></span><span><span>
  413. </span></span><span><span> <span>if</span> (<span>token</span> <span>!=</span> <span>process</span>.<span>env</span>.<span>ACTIVITYPUB_CREATE_TOKEN</span>) {
  414. </span></span><span><span> <span>res</span>.<span>status</span>(<span>401</span>).<span>end</span>(<span>"Invalid token"</span>);
  415. </span></span><span><span> <span>return</span>;
  416. </span></span><span><span> }
  417. </span></span><span><span>
  418. </span></span><span><span> <span>const</span> <span>configCollection</span> <span>=</span> <span>db</span>.<span>collection</span>(<span>'config'</span>);
  419. </span></span><span><span> <span>const</span> <span>configRef</span> <span>=</span> <span>configCollection</span>.<span>doc</span>(<span>"config"</span>);
  420. </span></span><span><span> <span>const</span> <span>config</span> <span>=</span> <span>await</span> <span>configRef</span>.<span>get</span>();
  421. </span></span><span><span>
  422. </span></span><span><span> <span>if</span> (<span>config</span>.<span>exists</span> <span>==</span> <span>false</span>) {
  423. </span></span><span><span> <span>// Config doesn't exist, make something
  424. </span></span></span><span><span><span></span> <span>configRef</span>.<span>set</span>({
  425. </span></span><span><span> <span>"lastId"</span><span>:</span> <span>0</span>
  426. </span></span><span><span> });
  427. </span></span><span><span> }
  428. </span></span><span><span>
  429. </span></span><span><span> <span>const</span> <span>configData</span> <span>=</span> <span>config</span>.<span>data</span>();
  430. </span></span><span><span> <span>let</span> <span>lastId</span> <span>=</span> <span>0</span>;
  431. </span></span><span><span> <span>if</span> (<span>configData</span> <span>!=</span> <span>undefined</span>) {
  432. </span></span><span><span> <span>lastId</span> <span>=</span> <span>configData</span>.<span>lastId</span>;
  433. </span></span><span><span> }
  434. </span></span><span><span>
  435. </span></span><span><span> <span>// Get my outbox because it contains all my notes.
  436. </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>);
  437. </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>());
  438. </span></span><span><span>
  439. </span></span><span><span> <span>const</span> <span>followersCollection</span> <span>=</span> <span>db</span>.<span>collection</span>(<span>'followers'</span>);
  440. </span></span><span><span> <span>const</span> <span>followersQuerySnapshot</span> <span>=</span> <span>await</span> <span>followersCollection</span>.<span>get</span>();
  441. </span></span><span><span>
  442. </span></span><span><span> <span>for</span> (<span>const</span> <span>followerDoc</span> <span>of</span> <span>followersQuerySnapshot</span>.<span>docs</span>) {
  443. </span></span><span><span> <span>const</span> <span>follower</span> <span>=</span> <span>followerDoc</span>.<span>data</span>();
  444. </span></span><span><span> <span>try</span> {
  445. </span></span><span><span> <span>const</span> <span>actorInformation</span> <span>=</span> <span>await</span> <span>fetchActorInformation</span>(<span>follower</span>.<span>actor</span>);
  446. </span></span><span><span> <span>const</span> <span>actorInbox</span> <span>=</span> <span>new</span> <span>URL</span>(<span>actorInformation</span>.<span>inbox</span>);
  447. </span></span><span><span>
  448. </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>)) {
  449. </span></span><span><span> <span>// We have to break somewhere... do it after the first.
  450. </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>];
  451. </span></span><span><span>
  452. </span></span><span><span> <span>if</span> (<span>item</span>.<span>object</span> <span>!=</span> <span>undefined</span>) {
  453. </span></span><span><span> <span>// We might not need this.
  454. </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>();
  455. </span></span><span><span> }
  456. </span></span><span><span>
  457. </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>);
  458. </span></span><span><span>
  459. </span></span><span><span> <span>// Item will be an entity, i.e, { Create { Note } }
  460. </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>);
  461. </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>());
  462. </span></span><span><span>
  463. </span></span><span><span> <span>break</span>;
  464. </span></span><span><span> }
  465. </span></span><span><span> } <span>catch</span> (<span>ex</span>) {
  466. </span></span><span><span> <span>console</span>.<span>log</span>(<span>"Error"</span>, <span>ex</span>, <span>follower</span>);
  467. </span></span><span><span> }
  468. </span></span><span><span> }
  469. </span></span><span><span>
  470. </span></span><span><span> <span>res</span>.<span>status</span>(<span>200</span>).<span>end</span>(<span>"ok"</span>);
  471. </span></span><span><span>};
  472. </span></span></code></pre></div>
  473. <p>The above code is relative long but the summary of it is as follows:</p>
  474. <ol>
  475. <li>Scan the outbox</li>
  476. <li>Pick the first post (I am only sending one note)</li>
  477. <li>For each follower in the <code>followers</code> table
  478. <ol>
  479. <li>Get their actor information (where their inbox is)</li>
  480. <li>Send the <code>Create</code> object from the outbox to them via a signed HTTP request</li>
  481. </ol>
  482. </li>
  483. </ol>
  484. <h3 id="voila">Voila</h3>
  485. <p>Simple... Nah. I think it's pretty complex, but it works.</p>
  486. <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>
  487. </article>
  488. <hr>
  489. <footer>
  490. <p>
  491. <a href="/david/" title="Aller à l’accueil"><svg class="icon icon-home">
  492. <use xlink:href="/static/david/icons2/symbol-defs-2021-12.svg#icon-home"></use>
  493. </svg> Accueil</a> •
  494. <a href="/david/log/" title="Accès au flux RSS"><svg class="icon icon-rss2">
  495. <use xlink:href="/static/david/icons2/symbol-defs-2021-12.svg#icon-rss2"></use>
  496. </svg> Suivre</a> •
  497. <a href="http://larlet.com" title="Go to my English profile" data-instant><svg class="icon icon-user-tie">
  498. <use xlink:href="/static/david/icons2/symbol-defs-2021-12.svg#icon-user-tie"></use>
  499. </svg> Pro</a> •
  500. <a href="mailto:david%40larlet.fr" title="Envoyer un courriel"><svg class="icon icon-mail">
  501. <use xlink:href="/static/david/icons2/symbol-defs-2021-12.svg#icon-mail"></use>
  502. </svg> Email</a> •
  503. <abbr class="nowrap" title="Hébergeur : Alwaysdata, 62 rue Tiquetonne 75002 Paris, +33184162340"><svg class="icon icon-hammer2">
  504. <use xlink:href="/static/david/icons2/symbol-defs-2021-12.svg#icon-hammer2"></use>
  505. </svg> Légal</abbr>
  506. </p>
  507. <template id="theme-selector">
  508. <form>
  509. <fieldset>
  510. <legend><svg class="icon icon-brightness-contrast">
  511. <use xlink:href="/static/david/icons2/symbol-defs-2021-12.svg#icon-brightness-contrast"></use>
  512. </svg> Thème</legend>
  513. <label>
  514. <input type="radio" value="auto" name="chosen-color-scheme" checked> Auto
  515. </label>
  516. <label>
  517. <input type="radio" value="dark" name="chosen-color-scheme"> Foncé
  518. </label>
  519. <label>
  520. <input type="radio" value="light" name="chosen-color-scheme"> Clair
  521. </label>
  522. </fieldset>
  523. </form>
  524. </template>
  525. </footer>
  526. <script src="/static/david/js/instantpage-5.1.0.min.js" type="module"></script>
  527. <script>
  528. function loadThemeForm(templateName) {
  529. const themeSelectorTemplate = document.querySelector(templateName)
  530. const form = themeSelectorTemplate.content.firstElementChild
  531. themeSelectorTemplate.replaceWith(form)
  532. form.addEventListener('change', (e) => {
  533. const chosenColorScheme = e.target.value
  534. localStorage.setItem('theme', chosenColorScheme)
  535. toggleTheme(chosenColorScheme)
  536. })
  537. const selectedTheme = localStorage.getItem('theme')
  538. if (selectedTheme && selectedTheme !== 'undefined') {
  539. form.querySelector(`[value="${selectedTheme}"]`).checked = true
  540. }
  541. }
  542. const prefersColorSchemeDark = '(prefers-color-scheme: dark)'
  543. window.addEventListener('load', () => {
  544. let hasDarkRules = false
  545. for (const styleSheet of Array.from(document.styleSheets)) {
  546. let mediaRules = []
  547. for (const cssRule of styleSheet.cssRules) {
  548. if (cssRule.type !== CSSRule.MEDIA_RULE) {
  549. continue
  550. }
  551. // WARNING: Safari does not have/supports `conditionText`.
  552. if (cssRule.conditionText) {
  553. if (cssRule.conditionText !== prefersColorSchemeDark) {
  554. continue
  555. }
  556. } else {
  557. if (cssRule.cssText.startsWith(prefersColorSchemeDark)) {
  558. continue
  559. }
  560. }
  561. mediaRules = mediaRules.concat(Array.from(cssRule.cssRules))
  562. }
  563. // WARNING: do not try to insert a Rule to a styleSheet you are
  564. // currently iterating on, otherwise the browser will be stuck
  565. // in a infinite loop…
  566. for (const mediaRule of mediaRules) {
  567. styleSheet.insertRule(mediaRule.cssText)
  568. hasDarkRules = true
  569. }
  570. }
  571. if (hasDarkRules) {
  572. loadThemeForm('#theme-selector')
  573. }
  574. })
  575. </script>
  576. </body>
  577. </html>