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 24KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  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>Playing with ActivityPub (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://macwright.com/2022/12/09/activitypub.html">
  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>Playing with ActivityPub</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://macwright.com/2022/12/09/activitypub.html" title="Lien vers le contenu original">Source originale</a>
  67. </p>
  68. </nav>
  69. <hr>
  70. <p><picture><source srcset="https://macwright.com/images/2022-12-09-activitypub-mastodon.webp" type="image/webp"><img alt="Mastodon" src="https://macwright.com/images/2022-12-09-activitypub-mastodon.jpg"></source></picture></p>
  71. <p><a href="https://activitypub.rocks/">ActivityPub</a>, <a href="https://webfinger.net/">WebFinger</a>, and <a href="https://en.wikipedia.org/wiki/Mastodon_(social_network)">Mastodon</a> are getting some attention because of <a href="https://www.nytimes.com/2022/12/07/technology/twitter-rivals-alternative-platforms.html">chaos at Twitter</a>.</p>
  72. <p>It’s anyone’s guess how this all shakes out. As an active user of Twitter, it’ll be sad if it goes away. But in the meantime, let’s have some fun with ActivityPub.</p>
  73. <h2 id="activitypub">ActivityPub</h2>
  74. <p>Under the hood, there’s ActivityPub, WebFinger, and a number of other neat standards like JSON-LD, but for most people, they’re using Mastodon, the application. Mastodon is the software that you sign into and use as a Twitter alternative, and it’d built on all of those standards. There are a few other implementations of social networks based on the same standards, like <a href="https://en.wikipedia.org/wiki/Friendica">Frendica</a>, and <a href="https://pixelfed.org/">Pixelfed</a>, but right now, Mastodon is where the people are.</p>
  75. <p>Mastodon is decentralized through <em>federation</em>: users can choose a Mastodon server on which to create an account, and they can follow and interact with users on other servers. You’re relying on someone else to host the server and <a href="https://github.com/mastodon/mastodon/issues/18079">protect your data</a>, but instead of Twitter, you have a choice of servers. If one Mastodon host crashes, those users will lose their accounts, but other hosts will keep going.</p>
  76. <p>So Mastodon doesn’t offer the sort of serverless decentralization you can get with something more radical like <a href="https://scuttlebutt.nz/">Secure Scuttlebutt</a>, but on the other hand, it’s much more user-friendly. Just like Twitter, you log into a server with a username and a password, and you can easily access it on an iPhone and share content on Mastodon with a link.</p>
  77. <p>But anyway, if we’re going to have this federation system, we might as well take it seriously. One of the benefits of Mastodon is that you can run your own instance. The benefit of Mastodon being built on standards like ActivityPub is that you can interact with Mastodon without running the Mastodon application software in particular: you can build your own. So why not: why not make macwright.com an ActivityPub host?</p>
  78. <h2 id="context">Context</h2>
  79. <p><a href="https://macwright.com/2016/05/03/the-featherweight-website.html">This blog</a> runs on <a href="https://jekyllrb.com/">Jekyll</a>, one of the original static site generators. It’s hosted on <a href="https://www.netlify.com/">Netlify</a>, which has branched out to support a bunch of products, but started out as a static site host.</p>
  80. <p>I’m not going to abandon these systems to support ActivityPub. Jekyll works great for me: I’ve been using it for over a decade and have few complaints. There are spectacular examples of what you can do with custom code and indieweb standards, like <a href="https://aaronparecki.com/">Aaron’s site</a>, but that’s not for me.</p>
  81. <p>So, ActivityPub needs to be a simple addition on top of this existing site. What’s the absolute least I’ll need to implement?</p>
  82. <p>I started by reading the <a href="https://www.w3.org/TR/activitypub/">ActivityPub specification</a>, and then <a href="https://docs.joinmastodon.org/spec/activitypub/">Mastodon’s documentation of ActivityPub</a>. Right off the bat I had a few takeaways:</p>
  83. <ul><li>It isn’t possible to implement ActivityPub without a server and a database. You can’t do it with just a static site.</li><li>ActivityPub is the kind of specification that’s so generic that everything implemented on top of it is a particular “flavor” of the specification. There’s an opinionated kind of ActivityPub that Mastodon speaks, which is different from <a href="https://bookwyrm.social/">bookwyrm</a> or <a href="https://pixelfed.org/">pixelfed</a>.</li><li>The documentation for all of this is sort of spread out - to implement something compatible with Mastodon, you’ll need both WebFinger and ActivityPub support, and make sure that you’re making compatible decisions. Plus do some specialized cryptography to do HTTP signatures - something that the ActivityPub spec doesn’t specify. It’s good that we’re reusing existing specifications instead of inventing a whole new thing, but it fragments documentation and makes it a lot harder to get to a working implementation. So for the intent of getting something done, it’ll be better for me to just find a reference.</li><li>There are still things, like unfollowing, that aren’t implemented in the reference implementation, and aren’t well-documented anywhere.</li></ul>
  84. <p>And a reference arrived, thanks to <a href="https://tinysubversions.com/">Darius Kazemi</a>, perhaps the internet’s most famous bot maker and experimenter. He’s been after this for years, writing ActivityPub servers <a href="https://tinysubversions.com/notes/activitypub-tool/">on Glitch</a>, written <a href="https://tinysubversions.com/notes/reading-activitypub/">guides to ActivityPub</a>, the whole thing.</p>
  85. <p>So, the whole time I was doing this I was looking at <a href="https://github.com/dariusk/express-activitypub">express-activitypub</a>, one of Darius’s projects. It’s great - simple, but it works. Most of my work here was making it even simpler - removing some of the configurability and hardcoding things like accounts - and porting code that was dependent on Node.js to code that could run in Netlify’s edge functions, which are a whitelabeled layer on top of <a href="https://deno.land/">Deno</a> and thus use standard web APIs instead.</p>
  86. <h2 id="what-needs-building">What needs building</h2>
  87. <p>After spelunking in the express-activitypub reference implementation, I eventually ended up with the following <em>extremely minimal ActivityPub essentials</em>, listed nearly in order of difficulty:</p>
  88. <ul><li>A <a href="https://webfinger.net/">WebFinger</a> endpoint that returns account information.</li><li>A user endpoint (<code class="language-plaintext highlighter-rouge">https://macwright.com/u/photos</code>) that returns more account information if you use an <code class="language-plaintext highlighter-rouge">Accept: application/json</code> header.</li><li>An inbox (<code class="language-plaintext highlighter-rouge">https://macwright.com/api/inbox</code>) that receives follow requests.</li><li>A process to post new photos when I publish them.</li></ul>
  89. <p>With all these together, the <a href="https://macwright.com/photos/">photos section</a> of this website is a “user” that you can follow from a Mastodon server: <code class="language-plaintext highlighter-rouge">@photos@macwright.com</code>.</p>
  90. <h2 id="webfinger">WebFinger</h2>
  91. <p>Step one is WebFinger. Computer history buffs might remember the <a href="https://en.wikipedia.org/wiki/Finger_(protocol)">finger protocol</a>. This is that, for the web, without the infamous security exploits, hopefully. It’s an endpoint that you can hit to get account information. Mine only supports one user:</p>
  92. <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>https://macwright.com/.well-known/webfinger
  93. ?resource=acct:photos@macwright.com
  94. </code></pre></div></div>
  95. <p>So, when you search for <code class="language-plaintext highlighter-rouge">@photos@macwright.com</code> from a Mastodon host, this endpoint is what it hits: it extracts <code class="language-plaintext highlighter-rouge">macwright.com</code> from the username, assumes that <code class="language-plaintext highlighter-rouge">.well-known/webfinger</code> is there on the server, and finds the account. Simple as that. <a href="https://gist.github.com/tmcw/a9a359744693861fa6ec2887a6b01715">Here’s the code - it’s nothing all that interesting.</a></p>
  96. <h2 id="user-endpoint">User endpoint</h2>
  97. <p>This, like WebFinger, was easy to implement. It’s just an endpoint that returns some JSON. <a href="https://gist.github.com/tmcw/4ba9dfcf06c98c0d0da932b83519c662">Here it is</a>.</p>
  98. <h2 id="inbox">Inbox</h2>
  99. <p>Here’s where things get a lot more complicated. The <code class="language-plaintext highlighter-rouge">/api/inbox</code> function needs to:</p>
  100. <ul><li>Implement some HTTP signatures cryptography, which is, as far as I can tell, <a href="https://oauth.net/http-signatures/">still a work-in-progress specification</a> and isn’t very well described anywhere.</li><li>Store follow requests, and respond to them with a signed message.</li></ul>
  101. <p>So, there’s more complexity in the specific code file (which you can <a href="https://gist.github.com/tmcw/7394bc8588a63399bea23d15a34fa2fa">see here</a>) as well as in the system. We need <em>persistence</em> to be an ActivityPub host – we’ll need to store a list of all our subscribers, so that we can send them updates.</p>
  102. <p>This is where it sinks in: ActivityPub is <em>totally different from RSS</em>. Of course it is - this is a federated realtime messaging system. But think about it:</p>
  103. <ul><li>You can implement an RSS feed with basically any system. A static site generated by a static site generator like Jekyll? Sure! You can even write an RSS feed by hand and upload it with FTP if you want.</li><li>Your RSS feed doesn’t know who’s reading it. If you have 1 million people subscribed, sure, that’s fine. At most you’ll need to use caching or a CDN to help the server serve those requests, but they’re just GET requests, the simplest possible kind of internet.</li><li>RSS has obvious points of optimization. If 10,000 people subscribe to my RSS feed but 5,000 of them are using Feedbin, those 5,000 can share the same GET request that Feedbin makes to pull the latest posts.</li><li>An RSS feed reader only needs a list of feed URLs and an XML parser. It doesn’t need to have its own domain name or identity in the system. A feed reader can be a command-line script or a desktop application.</li></ul>
  104. <p>RSS (and Atom) might be the most successful “worse is better” standards of all time, up there with Markdown and JSON. Really S-Tier stuff.</p>
  105. <p>Because with ActivityPub:</p>
  106. <ul><li>If 10,000 people follow my blog, I have a database with 10,000 entries in it.</li><li>Every time I publish something, I send an update to every subscriber. If this blog gets popular, it’ll send an enormous amount of updates. Maybe there’s a more efficient way to get this done, but I couldn’t find it.</li><li>There are many Mastodon hosts and they don’t share any kind of cache so popular posts themselves <a href="https://www.jwz.org/blog/2022/11/mastodon-stampede/">have been known to DDoS websites</a>.</li><li>There’s nothing like a “feed reader” in the world of ActivityPub. If you want to subscribe to someone’s content, you need an account and to send and receive messages. You need to be addressable on the internet.</li></ul>
  107. <p>So, given the requirements of being an participant with ActivityPub, this is the edge function that uses a database. I’m using <a href="https://planetscale.com/">PlanetScale</a>, because it’s fun and a good learning experience, but anything would work.</p>
  108. <h2 id="publishing">Publishing</h2>
  109. <p>So, with the Inbox receiving new followers and recording them in a database, when I publish I’ll need to send messages to those followers.</p>
  110. <p>I publish this site by pushing to GitHub: that’s the setup that Netlify gives me, and what I prefer for deploying overall. It’s a nice setup. It also means that, unlike a WordPress site or a hosted service, there’s no “Publish” button.</p>
  111. <p>So, to publish something, I need to devise a <em>trigger</em> and a way for the publishing script to find new content. Here’s the <a href="https://gist.github.com/tmcw/e4410c0255be738379e6dbbefed3f149">publishing script</a> I cooked up. Connecting this to Netlify’s <a href="https://docs.netlify.com/site-deploys/notifications/">webhooks</a> did the trick for a trigger: when the site deploys, it hits the publishing script (which is part of the site) and publishes new updates to followers. It pulls the follower list from the database, pulls posts from the RSS feed, and pushes them.</p>
  112. <p>You might notice - this doesn’t check to see what’s new, it just publishes all the RSS items to all the subscribers. This is because I’ve found that publishing, in ActivityPub, is idempotent: each post has an ID, and if you push that post multiple times, Mastodon servers will check that they already have a post with that ID and ignore it.</p>
  113. <h2 id="architecture">Architecture</h2>
  114. <p><img alt="Flow" src="https://macwright.com/images/2022-12-09-activitypub-flow.png"></p>
  115. <p>So, in the whole loop, this website receives follow requests, stores them in a database, and then sends new posts when I publish something to all of the followers.</p>
  116. <p>My site is still deployed as a static website using Jekyll, but the ActivityPub and WebFinger endpoints are served by <a href="https://docs.netlify.com/edge-functions/overview/">Netlify Edge Functions</a>. This, to be, is a pretty good setup: I keep the simplicity and efficiency of static content, only layering in server-like dynamic systems where necessary.</p>
  117. <p>The publishing flow - a webhook that triggers an edge function - is a hack, and something I’ll change if I can figure out a better way to do it.</p>
  118. <p>It works, so far, with my photos page.</p>
  119. <h2 id="fin">Fin</h2>
  120. <p>So, how does this make you feel? Excited? Overwhelmed? A little of both?</p>
  121. <p>Hacking on ActivityPub was a fun project, but it was chaotic. ActivityPub in practice is a grab-bag of specifications and implementation-specific details. It was hard to find documentation for a lot of things and hard to debug requests that didn’t have their intended effect on Mastodon.</p>
  122. <p>ActivityPub is a distributed architecture, so it’s going to be a lot more complicated than RSS. People smarter than me rightfully wish that <a href="https://ariadne.space/2019/01/07/activitypub-the-worse-is-better-approach-to-federated-social-networking/">ActivityPub was more sophisticated</a> and more on the side of “better” than worse. And the chattiness of the protocol - the fact that if I have thousands of subscribers I’ll have to send out thousands of updates - that comes with the territory. Just look at how much overhead there is in <a href="https://en.wikipedia.org/wiki/BitTorrent">BitTorrent</a>.</p>
  123. <p>What I built isn’t an ActivityPub system as much as a Mastodon-compatible one. I think this is the key contradiction of the ActivityPub system: it’s a specification broad enough to encompass many different services, but ends up being too general to be useful by itself. There are other specifications like this - things like <a href="https://developers.google.com/kml/documentation/kml_tut">KML</a> which are technically open and specified but practically defined by what Google Earth supports and produces.</p>
  124. <p>With this frame of mind, the question becomes, if ActivityPub probably isn’t going to be a self-contained standard and instead the basis for one or two popular, homogenous implementations, and if federation is probably going to be a secondary property of those implementations, is the specification technically good enough, useful enough, correct enough, that a future Twitter-competitor will use it? I’m not sure.</p>
  125. </article>
  126. <hr>
  127. <footer>
  128. <p>
  129. <a href="/david/" title="Aller à l’accueil"><svg class="icon icon-home">
  130. <use xlink:href="/static/david/icons2/symbol-defs-2021-12.svg#icon-home"></use>
  131. </svg> Accueil</a> •
  132. <a href="/david/log/" title="Accès au flux RSS"><svg class="icon icon-rss2">
  133. <use xlink:href="/static/david/icons2/symbol-defs-2021-12.svg#icon-rss2"></use>
  134. </svg> Suivre</a> •
  135. <a href="http://larlet.com" title="Go to my English profile" data-instant><svg class="icon icon-user-tie">
  136. <use xlink:href="/static/david/icons2/symbol-defs-2021-12.svg#icon-user-tie"></use>
  137. </svg> Pro</a> •
  138. <a href="mailto:david%40larlet.fr" title="Envoyer un courriel"><svg class="icon icon-mail">
  139. <use xlink:href="/static/david/icons2/symbol-defs-2021-12.svg#icon-mail"></use>
  140. </svg> Email</a> •
  141. <abbr class="nowrap" title="Hébergeur : Alwaysdata, 62 rue Tiquetonne 75002 Paris, +33184162340"><svg class="icon icon-hammer2">
  142. <use xlink:href="/static/david/icons2/symbol-defs-2021-12.svg#icon-hammer2"></use>
  143. </svg> Légal</abbr>
  144. </p>
  145. <template id="theme-selector">
  146. <form>
  147. <fieldset>
  148. <legend><svg class="icon icon-brightness-contrast">
  149. <use xlink:href="/static/david/icons2/symbol-defs-2021-12.svg#icon-brightness-contrast"></use>
  150. </svg> Thème</legend>
  151. <label>
  152. <input type="radio" value="auto" name="chosen-color-scheme" checked> Auto
  153. </label>
  154. <label>
  155. <input type="radio" value="dark" name="chosen-color-scheme"> Foncé
  156. </label>
  157. <label>
  158. <input type="radio" value="light" name="chosen-color-scheme"> Clair
  159. </label>
  160. </fieldset>
  161. </form>
  162. </template>
  163. </footer>
  164. <script src="/static/david/js/instantpage-5.1.0.min.js" type="module"></script>
  165. <script>
  166. function loadThemeForm(templateName) {
  167. const themeSelectorTemplate = document.querySelector(templateName)
  168. const form = themeSelectorTemplate.content.firstElementChild
  169. themeSelectorTemplate.replaceWith(form)
  170. form.addEventListener('change', (e) => {
  171. const chosenColorScheme = e.target.value
  172. localStorage.setItem('theme', chosenColorScheme)
  173. toggleTheme(chosenColorScheme)
  174. })
  175. const selectedTheme = localStorage.getItem('theme')
  176. if (selectedTheme && selectedTheme !== 'undefined') {
  177. form.querySelector(`[value="${selectedTheme}"]`).checked = true
  178. }
  179. }
  180. const prefersColorSchemeDark = '(prefers-color-scheme: dark)'
  181. window.addEventListener('load', () => {
  182. let hasDarkRules = false
  183. for (const styleSheet of Array.from(document.styleSheets)) {
  184. let mediaRules = []
  185. for (const cssRule of styleSheet.cssRules) {
  186. if (cssRule.type !== CSSRule.MEDIA_RULE) {
  187. continue
  188. }
  189. // WARNING: Safari does not have/supports `conditionText`.
  190. if (cssRule.conditionText) {
  191. if (cssRule.conditionText !== prefersColorSchemeDark) {
  192. continue
  193. }
  194. } else {
  195. if (cssRule.cssText.startsWith(prefersColorSchemeDark)) {
  196. continue
  197. }
  198. }
  199. mediaRules = mediaRules.concat(Array.from(cssRule.cssRules))
  200. }
  201. // WARNING: do not try to insert a Rule to a styleSheet you are
  202. // currently iterating on, otherwise the browser will be stuck
  203. // in a infinite loop…
  204. for (const mediaRule of mediaRules) {
  205. styleSheet.insertRule(mediaRule.cssText)
  206. hasDarkRules = true
  207. }
  208. }
  209. if (hasDarkRules) {
  210. loadThemeForm('#theme-selector')
  211. }
  212. })
  213. </script>
  214. </body>
  215. </html>