ActivityPub 协议的简单实现
<div class="markdown-alert markdown-alert-warning"><p class="markdown-alert-title"><svg class="octicon octicon-alert mr-2" viewBox="0 0 16 16" version="1.1" width="16" height="16" aria-hidden="true"><path d="M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>Warning</p><p>这不是一篇严肃的 ActivityPub 教程,仅仅是一些基于个人实现时的简单概括。该网站并不支持所有 ActivityPub 协议要求。由于联邦宇宙实例众多而本人服务器资源有限,笔者可能会关闭本站 ActivityPub 服务。</p>
</div>
<p><a href="https://www.nytimes.com/2013/01/13/technology/aaron-swartz-internet-activist-dies-at-26.html" rel="noopener" target="_blank">Aaron Swartz 于十年前的这个时候自杀了</a>。他起草的 <a href="https://web.resource.org/rss/1.0/" rel="noopener" target="_blank">RSS (1.0) 协议</a>、<a href="https://daringfireball.net/projects/markdown/" rel="noopener" target="_blank">和 John Gruber 一起设计、创造的 Markdown</a> 至今一直拥有大量互联网用户。这十年间互联网并没有因他的离世而产生 Open Web 原教旨主义者所期待的愿景。类似「剑桥分析公司」的事情你我都有耳闻。万维网的发明人 Tim Berners-Lee 博士后来提出了 <a href="https://solidproject.org/" rel="noopener" target="_blank">SoLiD 项目</a> —— 通过将用户数据和应用彻底分离,来实现用户对自身数据的完全掌控。ActivityPub 协议与之类似,但仅面向社交网站。如今,<a href="https://www.w3.org/TR/activitypub/" rel="noopener" target="_blank">ActivityPub 已经成为了 W3C 的推荐标准</a>;Elon Musk 收购 Twitter 公司之后,由于 "Hardcore Software Engineering" 所展露出的负外部性,Mastodon (长毛象)成为了最火热的分布式/去中心化社交网络平台,而 Mastodon 正是 ActivityPub 的实现之一。这个 <a href="https://activitypub.rocks/implementation-report/" rel="noopener" target="_blank">Implementation Report</a> 页面展示了一些实现了 ActivityPub 协议的网站列表。</p>
<p>折腾了几天,终于在百忙之中将这个小小的网站基本实现了 ActivityPub 最主要的接口。下面简单梳理一下大致实现的 Server to Server 接口,这些接口对于一个静态博客足矣。</p>
<p>本站点实现 ActivityPub 的所有 REST API 均系由 ▲ Vercel Serverless Function (JavaScript) 驱动。</p>
<h2 id="webfinger"><a href="#webfinger">WebFinger</a></h2>
<p>此 API 的定义参考 <a href="https://www.rfc-editor.org/rfc/rfc7033.html" rel="noopener" target="_blank">RFC 7033</a>。这个 WebFinger 协议目的是提供一种针对单个域名的<strong>用户发现</strong>方式。考虑到此 API 必须使用 <code>Content-Type: application/jrd+json</code> 作为 HTTP 的报文响应类型,因此不推荐直接使用静态文件托管 JSON,请使用 REST API 来构建此实现。</p>
<pre class="shiki shiki-themes one-light github-dark-default" style="background-color:#FAFAFA;--shiki-dark-bg:#0d1117;color:#383A42;--shiki-dark:#e6edf3" tabindex="0"><code><span class="line"><span>https://example.com/.well-known/webfinger?resource=acct:lawrence@example.com</span></span></code></pre>
<p><code>subject</code> 中的 URI 内容后半段和电子邮件非常像 —— ActivityPub 最终的实现效果也和电子邮件类似!</p>
<pre class="shiki shiki-themes one-light github-dark-default" style="background-color:#FAFAFA;--shiki-dark-bg:#0d1117;color:#383A42;--shiki-dark:#e6edf3" tabindex="0"><code><span class="line"><span style="color:#383A42;--shiki-dark:#E6EDF3">{</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#7EE787"> "subject"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">: </span><span style="color:#50A14F;--shiki-dark:#A5D6FF">"acct:lawrence@lawrenceli.me"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">,</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#7EE787"> "aliases"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">: [],</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#7EE787"> "links"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">: [</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E6EDF3"> {</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#7EE787"> "rel"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">: </span><span style="color:#50A14F;--shiki-dark:#A5D6FF">"http://webfinger.net/rel/profile-page"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">,</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#7EE787"> "type"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">: </span><span style="color:#50A14F;--shiki-dark:#A5D6FF">"text/html"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">,</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#7EE787"> "href"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">: </span><span style="color:#50A14F;--shiki-dark:#A5D6FF">"https://lawrenceli.me/about"</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E6EDF3"> },</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E6EDF3"> {</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#7EE787"> "rel"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">: </span><span style="color:#50A14F;--shiki-dark:#A5D6FF">"self"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">,</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#7EE787"> "type"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">: </span><span style="color:#50A14F;--shiki-dark:#A5D6FF">"application/activity+json"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">,</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#7EE787"> "href"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">: </span><span style="color:#50A14F;--shiki-dark:#A5D6FF">"https://lawrenceli.me/api/activitypub/actor"</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E6EDF3"> }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E6EDF3"> ]</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E6EDF3">}</span></span></code></pre>
<p><code>links</code> 中会添加上我们即将要实现的 Actor API。</p>
<p><strong><em>除此 WebFinger 之外,以下所有 API 都必须设置 <code>Content-Type: application/activity+json</code> 作为响应头。ActivityPub 服务端(比如一个 Mastodon 实例)都会在请求头使用 <code>Accept: application/activity+json</code> 类似的形式来要求我们的实例返回对应的报文格式。</em></strong></p>
<h2 id="actor"><a href="#actor">Actor</a></h2>
<p>Actor 就是 Activity 的参与者。WebFinger 会暴露此用户信息 (Profile) 接口。通过此 API,可以告知 ActivityPub 所有关于此用户的其他 API Endpoint,比如用户的 Outbox、Inbox、Followers 等等。所以这些 API 的具体 URL 都可以由自己去定义,而非一成不变。</p>
<p>除此之外,需要提供用户的 PublicKey 来验明身份。我们只需要在自己本地生成一对密钥就可以了。服务端通信中,发往不同 ActivityPub 的实例 HTTPS 请求都需要经过密钥加密。</p>
<pre class="shiki shiki-themes one-light github-dark-default" style="background-color:#FAFAFA;--shiki-dark-bg:#0d1117;color:#383A42;--shiki-dark:#e6edf3" tabindex="0"><code><span class="line"><span style="color:#4078F2;--shiki-dark:#FFA657">openssl</span><span style="color:#50A14F;--shiki-dark:#A5D6FF"> genrsa</span><span style="color:#986801;--shiki-dark:#79C0FF"> -out</span><span style="color:#50A14F;--shiki-dark:#A5D6FF"> private.pem</span><span style="color:#986801;--shiki-dark:#79C0FF"> 2048</span></span>
<span class="line"><span style="color:#4078F2;--shiki-dark:#FFA657">openssl</span><span style="color:#50A14F;--shiki-dark:#A5D6FF"> rsa</span><span style="color:#986801;--shiki-dark:#79C0FF"> -in</span><span style="color:#50A14F;--shiki-dark:#A5D6FF"> private.pem</span><span style="color:#986801;--shiki-dark:#79C0FF"> -outform</span><span style="color:#50A14F;--shiki-dark:#A5D6FF"> PEM</span><span style="color:#986801;--shiki-dark:#79C0FF"> -pubout</span><span style="color:#986801;--shiki-dark:#79C0FF"> -out</span><span style="color:#50A14F;--shiki-dark:#A5D6FF"> public.pem</span></span></code></pre>
<pre class="shiki shiki-themes one-light github-dark-default" style="background-color:#FAFAFA;--shiki-dark-bg:#0d1117;color:#383A42;--shiki-dark:#e6edf3" tabindex="0"><code><span class="line"><span style="color:#383A42;--shiki-dark:#E6EDF3">{</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#7EE787"> "@context"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">: [</span><span style="color:#50A14F;--shiki-dark:#A5D6FF">"https://www.w3.org/ns/activitystreams"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">, </span><span style="color:#50A14F;--shiki-dark:#A5D6FF">"https://w3id.org/security/v1"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">],</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#7EE787"> "id"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">: </span><span style="color:#50A14F;--shiki-dark:#A5D6FF">"https://lawrenceli.me/api/activitypub/actor"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">,</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#7EE787"> "type"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">: </span><span style="color:#50A14F;--shiki-dark:#A5D6FF">"Person"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">,</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#7EE787"> "name"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">: </span><span style="color:#50A14F;--shiki-dark:#A5D6FF">"Lawrence Li"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">,</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#7EE787"> "preferredUsername"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">: </span><span style="color:#50A14F;--shiki-dark:#A5D6FF">"lawrence"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">,</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#7EE787"> "summary"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">: </span><span style="color:#50A14F;--shiki-dark:#A5D6FF">"Blog"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">,</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#7EE787"> "inbox"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">: </span><span style="color:#50A14F;--shiki-dark:#A5D6FF">"https://lawrenceli.me/api/activitypub/inbox"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">,</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#7EE787"> "outbox"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">: </span><span style="color:#50A14F;--shiki-dark:#A5D6FF">"https://lawrenceli.me/api/activitypub/outbox"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">,</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#7EE787"> "followers"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">: </span><span style="color:#50A14F;--shiki-dark:#A5D6FF">"https://lawrenceli.me/api/activitypub/followers"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">,</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#7EE787"> "icon"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">: [</span><span style="color:#50A14F;--shiki-dark:#A5D6FF">"https://lawrenceli.me/images/author/Lawrence.png"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">],</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#7EE787"> "publicKey"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">: {</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#7EE787"> "id"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">: </span><span style="color:#50A14F;--shiki-dark:#A5D6FF">"https://lawrenceli.me/api/activitypub/actor#main-key"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">,</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#7EE787"> "owner"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">: </span><span style="color:#50A14F;--shiki-dark:#A5D6FF">"https://lawrenceli.me/api/activitypub/actor"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">,</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#7EE787"> "publicKeyPem"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">: </span><span style="color:#50A14F;--shiki-dark:#A5D6FF">"-----BEGIN PUBLIC KEY-----</span><span style="color:#0184BC;--shiki-dark:#FF7B72">\n</span><span style="color:#50A14F;--shiki-dark:#A5D6FF">MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0RHqCKo3Zl+ZmwsyJUFe</span><span style="color:#0184BC;--shiki-dark:#FF7B72">\n</span><span style="color:#50A14F;--shiki-dark:#A5D6FF">iUBYdiWQe6C3W+d89DEzAEtigH8bI5lDWW0Q7rT60eppaSnoN3ykaWFFOrtUiVJT</span><span style="color:#0184BC;--shiki-dark:#FF7B72">\n</span><span style="color:#50A14F;--shiki-dark:#A5D6FF">NqyMBz3aPbs6BpAE5lId9aPu6s9MFyZrK5QtuWfAGwv9VZPwUHrEJCFiY1G5IgK/</span><span style="color:#0184BC;--shiki-dark:#FF7B72">\n</span><span style="color:#50A14F;--shiki-dark:#A5D6FF">+ZErSKYUTUYw2xSAZnLkalMFTRmLbmj8SlWp/5fryQd4jyRX/tBlsyFs/qvuwBtw</span><span style="color:#0184BC;--shiki-dark:#FF7B72">\n</span><span style="color:#50A14F;--shiki-dark:#A5D6FF">uGSkWgTIMAYV71Wny9ns+Nwr4HYfF5eo2zInpwIYTCEbil79HcikUUTTO/vMMoqx</span><span style="color:#0184BC;--shiki-dark:#FF7B72">\n</span><span style="color:#50A14F;--shiki-dark:#A5D6FF">46IiHcMj0SPlzDXxelZgqm0ojK2Z7BGudjvwSbWq/GtLoaXHeMUVpcOCtpyvtLr2</span><span style="color:#0184BC;--shiki-dark:#FF7B72">\n</span><span style="color:#50A14F;--shiki-dark:#A5D6FF">YwIDAQAB</span><span style="color:#0184BC;--shiki-dark:#FF7B72">\n</span><span style="color:#50A14F;--shiki-dark:#A5D6FF">-----END PUBLIC KEY-----"</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E6EDF3"> }</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E6EDF3">}</span></span></code></pre>
<h2 id="outbox"><a href="#outbox">Outbox</a></h2>
<p>类似 RSS/JSON Feed, 类型为 <code>OrderedCollection</code>,必须按照时间顺序将最新内容放在 <code>orderedItems</code> 的最前。</p>
<pre class="shiki shiki-themes one-light github-dark-default" style="background-color:#FAFAFA;--shiki-dark-bg:#0d1117;color:#383A42;--shiki-dark:#e6edf3" tabindex="0"><code><span class="line"><span style="color:#383A42;--shiki-dark:#E6EDF3">{</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#7EE787"> "@context"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">: </span><span style="color:#50A14F;--shiki-dark:#A5D6FF">"https://www.w3.org/ns/activitystreams"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">,</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#7EE787"> "id"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">: </span><span style="color:#50A14F;--shiki-dark:#A5D6FF">"https://lawrenceli.me/api/activitypub/outbox"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">,</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#7EE787"> "summary"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">: </span><span style="color:#50A14F;--shiki-dark:#A5D6FF">"Blog"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">,</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#7EE787"> "type"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">: </span><span style="color:#50A14F;--shiki-dark:#A5D6FF">"OrderedCollection"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">,</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#7EE787"> "totalItems"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">: </span><span style="color:#986801;--shiki-dark:#79C0FF">1</span><span style="color:#383A42;--shiki-dark:#E6EDF3">,</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#7EE787"> "orderedItems"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">: []</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E6EDF3">}</span></span></code></pre>
<p>OrderedItems 数组中的单个 Item (一般为 Note) 可以是如下形式:</p>
<pre class="shiki shiki-themes one-light github-dark-default" style="background-color:#FAFAFA;--shiki-dark-bg:#0d1117;color:#383A42;--shiki-dark:#e6edf3" tabindex="0"><code><span class="line"><span style="color:#383A42;--shiki-dark:#E6EDF3">{</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#7EE787"> "@context"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">: [</span><span style="color:#50A14F;--shiki-dark:#A5D6FF">"https://www.w3.org/ns/activitystreams"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">],</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#7EE787"> "id"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">: </span><span style="color:#50A14F;--shiki-dark:#A5D6FF">"https://lawrenceli.me/blog/ssg-ssr"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">,</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#7EE787"> "type"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">: </span><span style="color:#50A14F;--shiki-dark:#A5D6FF">"Note"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">,</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#7EE787"> "published"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">: </span><span style="color:#50A14F;--shiki-dark:#A5D6FF">"Thu, 20 Feb 2020 00:00:00 GMT"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">,</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#7EE787"> "attributedTo"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">: </span><span style="color:#50A14F;--shiki-dark:#A5D6FF">"https://lawrenceli.me/api/activitypub/actor"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">,</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#7EE787"> "content"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">: </span><span style="color:#50A14F;--shiki-dark:#A5D6FF">"<a href=</span><span style="color:#0184BC;--shiki-dark:#FF7B72">\"</span><span style="color:#50A14F;--shiki-dark:#A5D6FF">https://lawrenceli.me/blog/ssg-ssr</span><span style="color:#0184BC;--shiki-dark:#FF7B72">\"</span><span style="color:#50A14F;--shiki-dark:#A5D6FF">>When to Use Static Generation v.s. Server-side Rendering</a><br>SSG & SSR"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">,</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#7EE787"> "url"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">: </span><span style="color:#50A14F;--shiki-dark:#A5D6FF">"https://lawrenceli.me/blog/ssg-ssr"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">,</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#7EE787"> "to"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">: [</span><span style="color:#50A14F;--shiki-dark:#A5D6FF">"https://www.w3.org/ns/activitystreams#Public"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">],</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#7EE787"> "cc"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">: [</span><span style="color:#50A14F;--shiki-dark:#A5D6FF">"https://lawrenceli.me/api/activitypub/followers"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">]</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E6EDF3">}</span></span></code></pre>
<p>在 ActivityPub 中,所有的对象都必须要提供一个 <code>id</code> 来作为唯一的全局标识符。而且,这个 id 必须是公开可访问的 URI,即可以通过此 id 来访问到此资源对象本身。 例上述如 Outbox 中的一项 Note 可以通过如下 curl 请求得到:</p>
<pre class="shiki shiki-themes one-light github-dark-default" style="background-color:#FAFAFA;--shiki-dark-bg:#0d1117;color:#383A42;--shiki-dark:#e6edf3" tabindex="0"><code><span class="line"><span style="color:#4078F2;--shiki-dark:#FFA657">curl</span><span style="color:#50A14F;--shiki-dark:#A5D6FF"> https://lawrenceli.me/blog/ssg-ssr</span><span style="color:#986801;--shiki-dark:#79C0FF"> -H</span><span style="color:#50A14F;--shiki-dark:#A5D6FF"> "Accept: application/activity+json"</span></span></code></pre>
<p>而如果你用浏览器直接打开这个 URL,你将会看到的是一个网页。原因就在于 <code>Accept</code> 这个请求头。</p>
<h2 id="inbox"><a href="#inbox">Inbox</a></h2>
<p>本质是一个必须支持 POST 请求的 WebHook。当联邦宇宙中其他用户对你的内容作出了一些交互(比如关注、回复、收藏、转发、删除等操作),会触发此 WebHook。你需要根据 Activity 的类型去处理这些 Payload。一般来说,我们会使用自己的数据库来配合 Inbox Message 做 CRUD。</p>
<p>数据存在自己的数据库之后,你就可以直接在自己的站点上去展示它们。要保持数据于联邦宇宙中的一致性,你需要处理好所有消息类型,并做到接口的幂等 —— 因为 Mastodon 实例会有重试机制。</p>
<h2 id="followers"><a href="#followers">Followers</a></h2>
<p>关注者列表 API。当 Inbox 接收到来自其他用户的关注请求时,可以获取用户账户后保存到数据库然后通过此 API 展示出来。类型为 <code>OrderedCollection</code>。也是最简单的一个接口。</p>
<pre class="shiki shiki-themes one-light github-dark-default" style="background-color:#FAFAFA;--shiki-dark-bg:#0d1117;color:#383A42;--shiki-dark:#e6edf3" tabindex="0"><code><span class="line"><span style="color:#383A42;--shiki-dark:#E6EDF3">{</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#7EE787"> "@context"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">: </span><span style="color:#50A14F;--shiki-dark:#A5D6FF">"https://www.w3.org/ns/activitystreams"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">,</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#7EE787"> "id"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">: </span><span style="color:#50A14F;--shiki-dark:#A5D6FF">"https://lawrenceli.me/api/activitypub/followers"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">,</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#7EE787"> "type"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">: </span><span style="color:#50A14F;--shiki-dark:#A5D6FF">"OrderedCollection"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">,</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#7EE787"> "totalItems"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">: </span><span style="color:#986801;--shiki-dark:#79C0FF">1</span><span style="color:#383A42;--shiki-dark:#E6EDF3">,</span></span>
<span class="line"><span style="color:#E45649;--shiki-dark:#7EE787"> "orderedItems"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">: [</span><span style="color:#50A14F;--shiki-dark:#A5D6FF">"https://mstdn.social/users/lawrence"</span><span style="color:#383A42;--shiki-dark:#E6EDF3">]</span></span>
<span class="line"><span style="color:#383A42;--shiki-dark:#E6EDF3">}</span></span></code></pre>
<h2 id="note--article"><a href="#note--article">Note / Article</a></h2>
<p>需要针对实现 Outbox 中的每一个 <code>orderedItem</code> 的 <code>id</code> 中的 URI 实现一个 JSON 输出。形式可以和 Outbox 中单个 Item 保持一致。
除了 <code>Note</code> 之外,ActivityPub 可以有其他类型的资源,比如长文章的 <code>Article</code>、视频资源 <code>Video</code>。不同 ActivityPub 的实现平台对不同资源的展示方式不尽相同。</p>
<p>我的博客页面地址和对应 Activity ID 的 URI 在 URL 形式上保持了一致。因此在实现此 API 后,用户可以在任何 Mastodon 实例的搜索栏中通过搜索我的博客文章页地址来发现它对应的 Mastodon 贴文(由 Outbox 生成);在完全实现 Inbox 后,对贴文的交互数据就能够展示在我的网站上。比如文章页面最下方的 <code>Replies</code>。</p>
<h2 id="to-do"><a href="#to-do">To Do</a></h2>
<p>我的站点没有完全实现所有 ActivityPub 协议,比如 Inbox 消息目前仅处理了 Create Note 和 Accept Follower,还有许多消息类型亟待实现;大部分接受 GET 请求的接口也应当适当配置缓存;Inbox 要严格验证发送者的密钥。</p>
<h2 id="社区实现"><a href="#社区实现">社区实现</a></h2>
<p>很巧合地发现 Cloudflare 也在同一时间段开发了兼容 Mastodon 的 ActivityPub 实现:<a href="https://github.com/cloudflare/wildebeest" rel="noopener" target="_blank">WildeBeest</a>,有兴趣可以直接用他们的商业化技术栈来部署一个小型实例,或者直接参考他们的代码,用自己擅长的服务端语言实现自己的 ActivityPub。</p>
<div>
<div data-embed="github" data-props='{"user":"cloudflare","repo":"wildebeest"}'></div>
</div>
<h2 id="ref"><a href="#ref">Ref</a></h2>
<ul>
<li><a href="https://www.w3.org/TR/activitypub/" rel="noopener" target="_blank">https://www.w3.org/TR/activitypub/</a></li>
<li><a href="https://blog.joinmastodon.org/2018/06/how-to-implement-a-basic-activitypub-server/" rel="noopener" target="_blank">https://blog.joinmastodon.org/2018/06/how-to-implement-a-basic-activitypub-server/</a></li>
<li><a href="https://s3lph.me/activitypub-static-site.html" rel="noopener" target="_blank">https://s3lph.me/activitypub-static-site.html</a></li>
<li><a href="https://paul.kinlan.me/adding-activity-pub-to-your-static-site/" rel="noopener" target="_blank">https://paul.kinlan.me/adding-activity-pub-to-your-static-site/</a></li>
</ul><p><a href="mailto:hi@lawrenceli.me">来信不拒?</a> <a href="https://qr.alipay.com/tsx12829sov5evioeg5j5fc">激赏随意!</a></p>