ActivityPub 协议的简单实现
Aaron Swartz 于十年前的这个时候自杀了。他起草的 RSS (1.0) 协议、和 John Gruber 一起设计、创造的 Markdown 至今一直拥有大量互联网用户。这十年间互联网并没有因他的离世而产生 Open Web 原教旨主义者所期待的愿景。类似「剑桥分析公司」的事情你我都有耳闻。万维网的发明人 Tim Berners-Lee 博士后来提出了 SoLiD 项目 —— 通过将用户数据和应用彻底分离,来实现用户对自身数据的完全掌控。ActivityPub 协议与之类似,但仅面向社交网站。如今,ActivityPub 已经成为了 W3C 的推荐标准;Elon Musk 收购 Twitter 公司之后,由于 "Hardcore Software Engineering" 所展露出的负外部性,Mastodon (长毛象)成为了最火热的分布式/去中心化社交网络平台,而 Mastodon 正是 ActivityPub 的实现之一。这个 Implementation Report 页面展示了一些实现了 ActivityPub 协议的网站列表。
折腾了几天,终于在百忙之中将这个小小的网站基本实现了 ActivityPub 最主要的接口。下面简单梳理一下大致实现的 Server to Server 接口,这些接口对于一个静态博客足矣。注意,这不是一篇严肃教程,仅仅是一些基于个人实现时的简单概括。
本站点实现 ActivityPub 的所有 REST API 均系由 ▲ Vercel Serverless Function (JavaScript) 驱动。
WebFinger
此 API 的定义参考 RFC 7033。这个 WebFinger 协议目的是提供一种针对单个域名的用户发现方式。考虑到此 API 必须使用 Content-Type: application/jrd+json
作为 HTTP 的报文响应类型,因此不推荐直接使用静态文件托管 JSON,请使用 REST API 来构建此实现。
https://example.com/.well-known/webfinger?resource=acct:lawrence@example.com
subject
中的 URI 内容后半段和电子邮件非常像 —— ActivityPub 最终的实现效果也和电子邮件类似!
{
"subject": "acct:lawrence@lawrenceli.me",
"aliases": [],
"links": [
{
"rel": "http://webfinger.net/rel/profile-page",
"type": "text/html",
"href": "https://lawrenceli.me/about"
},
{
"rel": "self",
"type": "application/activity+json",
"href": "https://lawrenceli.me/api/activitypub/actor"
}
]
}
links
中会添加上我们即将要实现的 Actor API。
除此 WebFinger 之外,以下所有 API 都必须设置 Content-Type: application/activity+json
作为响应头。ActivityPub 服务端(比如一个 Mastodon 实例)都会在请求头使用 Accept: application/activity+json
类似的形式来要求我们的实例返回对应的报文格式。
Actor
Actor 就是 Activity 的参与者。WebFinger 会暴露此用户信息 (Profile) 接口。通过此 API,可以告知 ActivityPub 所有关于此用户的其他 API Endpoint,比如用户的 Outbox、Inbox、Followers 等等。所以这些 API 的具体 URL 都可以由自己去定义,而非一成不变。
除此之外,需要提供用户的 PublicKey 来验明身份。我们只需要在自己本地生成一对密钥就可以了。服务端通信中,发往不同 ActivityPub 的实例 HTTPS 请求都需要经过密钥加密。
openssl genrsa -out private.pem 2048
openssl rsa -in private.pem -outform PEM -pubout -out public.pem
{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1"
],
"id": "https://lawrenceli.me/api/activitypub/actor",
"type": "Person",
"name": "Lawrence Li",
"preferredUsername": "lawrence",
"summary": "Blog",
"inbox": "https://lawrenceli.me/api/activitypub/inbox",
"outbox": "https://lawrenceli.me/api/activitypub/outbox",
"followers": "https://lawrenceli.me/api/activitypub/followers",
"icon": ["https://lawrenceli.me/images/author/Lawrence.png"],
"publicKey": {
"id": "https://lawrenceli.me/api/activitypub/actor#main-key",
"owner": "https://lawrenceli.me/api/activitypub/actor",
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0RHqCKo3Zl+ZmwsyJUFe\niUBYdiWQe6C3W+d89DEzAEtigH8bI5lDWW0Q7rT60eppaSnoN3ykaWFFOrtUiVJT\nNqyMBz3aPbs6BpAE5lId9aPu6s9MFyZrK5QtuWfAGwv9VZPwUHrEJCFiY1G5IgK/\n+ZErSKYUTUYw2xSAZnLkalMFTRmLbmj8SlWp/5fryQd4jyRX/tBlsyFs/qvuwBtw\nuGSkWgTIMAYV71Wny9ns+Nwr4HYfF5eo2zInpwIYTCEbil79HcikUUTTO/vMMoqx\n46IiHcMj0SPlzDXxelZgqm0ojK2Z7BGudjvwSbWq/GtLoaXHeMUVpcOCtpyvtLr2\nYwIDAQAB\n-----END PUBLIC KEY-----"
}
}
Outbox
类似 RSS/JSON Feed, 类型为 OrderedCollection
,必须按照时间顺序将最新内容放在 orderedItems
的最前。
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://lawrenceli.me/api/activitypub/outbox",
"summary": "Blog",
"type": "OrderedCollection",
"totalItems": 1,
"orderedItems": []
}
OrderedItems 数组中的单个 Item (一般为 Note) 可以是如下形式:
{
"@context": ["https://www.w3.org/ns/activitystreams"],
"id": "https://lawrenceli.me/blog/ssg-ssr",
"type": "Note",
"published": "Thu, 20 Feb 2020 00:00:00 GMT",
"attributedTo": "https://lawrenceli.me/api/activitypub/actor",
"content": "<a href=\"https://lawrenceli.me/blog/ssg-ssr\">When to Use Static Generation v.s. Server-side Rendering</a><br>SSG & SSR",
"url": "https://lawrenceli.me/blog/ssg-ssr",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"cc": ["https://lawrenceli.me/api/activitypub/followers"]
}
在 ActivityPub 中,所有的对象都必须要提供一个 id
来作为唯一的全局标识符。而且,这个 id 必须是公开可访问的 URI,即可以通过此 id 来访问到此资源对象本身。 例上述如 Outbox 中的一项 Note 可以通过如下 curl 请求得到:
curl https://lawrenceli.me/blog/ssg-ssr -H "Accept: application/activity+json"
而如果你用浏览器直接打开这个 URL,你将会看到的是一个网页。原因就在于 Accept
这个请求头。
Inbox
本质是一个必须支持 POST 请求的 WebHook。当联邦宇宙中其他用户对你的内容作出了一些交互(比如关注、回复、收藏、转发、删除等操作),会触发此 WebHook。你需要根据 Activity 的类型去处理这些 Payload。一般来说,我们会使用自己的数据库来配合 Inbox Message 做 CRUD。
数据存在自己的数据库之后,你就可以直接在自己的站点上去展示它们。要保持数据于联邦宇宙中的一致性,你需要处理好所有消息类型,并做到接口的幂等 —— 因为 Mastodon 实例会有重试机制。
Followers
关注者列表 API。当 Inbox 接收到来自其他用户的关注请求时,可以获取用户账户后保存到数据库然后通过此 API 展示出来。类型为 OrderedCollection
。也是最简单的一个接口。
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://lawrenceli.me/api/activitypub/followers",
"type": "OrderedCollection",
"totalItems": 1,
"orderedItems": ["https://mstdn.social/users/lawrence"]
}
Note / Article
需要针对实现 Outbox 中的每一个 orderedItem
的 id
中的 URI 实现一个 JSON 输出。形式可以和 Outbox 中单个 Item 保持一致。
除了 Note
之外,ActivityPub 可以有其他类型的资源,比如长文章的 Article
、视频资源 Video
。不同 ActivityPub 的实现平台对不同资源的展示方式不尽相同。
我的博客页面地址和对应 Activity ID 的 URI 在 URL 形式上保持了一致。因此在实现此 API 后,用户可以在任何 Mastodon 实例的搜索栏中通过搜索我的博客文章页地址来发现它对应的 Mastodon 贴文(由 Outbox 生成);在完全实现 Inbox 后,对贴文的交互数据就能够展示在我的网站上。比如文章页面最下方的 Replies
。
To Do
我的站点没有完全实现所有 ActivityPub 协议,比如 Inbox 消息目前仅处理了 Create Note 和 Accept Follower,还有许多消息类型亟待实现;大部分接受 GET 请求的接口也应当适当配置缓存;Inbox 要严格验证发送者的密钥。
社区实现
很巧合地发现 Cloudflare 也在同一时间段开发了兼容 Mastodon 的 ActivityPub 实现:WildeBeest,有兴趣可以直接用他们的商业化技术栈来部署一个小型实例,或者直接参考他们的代码,用自己擅长的服务端语言实现自己的 ActivityPub。