静态博客也能和Mastodon沟通
前段时间看到一篇名为《ActivityPub协议的简单实现》的博客,发现ActivityPub比想象中的简单,就动了自己也写一个的念头。
现在大家可以在Mastodon上搜索https://emptystack.top/actor
或@actor@emptystack.top
找到我的博客。关注博客后,会收到嘟文形式的更新通知。点赞或评论嘟文,都会显示到对应博文的网页底部。
和来自别的实例的嘟文一样,往期博文不会自动出现在你的实例上。如果想评论往期博文,也可以在Mastodon上用网址搜索——只要在域名和路径之间加上/note
即可。比如这篇博文的地址是https://emptystack.top/activitypub-for-static-blog
,那么就可以使用https://emptystack.top
/note
/activitypub-for-static-blog
搜索出来。
这篇只写实现时想说的话,很少涉及代码。如果相比文字更喜欢看代码的话,可以去看GitHub:LessPub。
目录
怎么做
静态博客与ActivityPub八字不合
静态博客就是一些HTML文件。这些文件只能被动地被下载(响应HTTP GET请求),而无法主动地向别人发送通知(发送HTTP POST请求),也没法根据别人的请求而改变(响应HTTP POST请求)。
ActivityPub则期望你主动向别人POST自己的新信息,并且能够处理别人POST来的信息。这也是浏览别的实例用户时会碰到“不会显示来自其他服务器的更早的嘟文”的原因——你的实例没人关注对方,对方就不会主动把消息POST到你的实例。
静态,就意味着只能被GET;ActivityPub,就意味着要互相POST。可以说静态和ActivityPub是对立的。
Serverless Functions来救场
处理POST请求需要服务器,但我不想为静态博客维护服务器。因为我选择静态博客很大原因是图个省心:更新完就睡大觉,让Netlify来替我构建、分发。要是让我为博客配一台服务器,那这个博客本身也没必要静态了。
如果想要服务器运行程序,但又不想自己维护服务器,那可以直接用别人的服务器。Vercel、CloudFlare、Netlify都有各自的Serverless Functions服务:只要我写的程序里暴露一个特定签名的函数来处理各种HTTP请求,它们就会替我在服务器里运行这个程序。也就是说,我的静态博客可以借助Serverless Functions的力量向Mastodon实例POST更新通知、处理从Mastodon实例POST来的回复和点赞。
数据怎么存
很好,既然有免费且省心的机器执行我的代码了,那么接下来的问题是:谁来存我的数据?
如果想要实现ActivityPub的话,需要记录我发了什么、谁关注了我、谁向我的哪条信息回复了什么等等。而Serverless Functions只管执行代码,我得另寻他处存放上述数据。
第一种选择是自建数据库。这样的好处是数据都在自己手里,但缺点就是:如果我自建数据库了,何必还要用别人的服务器?如果不用别人的服务器,何必还要用静态博客?最后一定会滑坡到手搓自己的博客系统——在绝望的深渊里永无天日地对着只有一个人用的博客生成器yak shaving。
那么,看来万万不能自建数据库了,第二种选择是什么呢?是继续使用托管商提供的免费数据库。很遗憾,我使用的Netlify没有数据库服务——CloudFlare倒是有,但貌似得把整个博客给他们托管才能用。有人用Vercel的Serverless Functions实现了ActivityPub,他用的是Firebase,因为“it's pretty simple, has a good client and can store JSON directly”。
Firebase看起来确实是不错的选择,但是我有账号洁癖,需要花几年才能下定决心注册一个新的账号——Mastodon也类似,我大概一六/一七年左右就开始听说它,直到几个月前才注册。所以Firebase虽好,我也得等几年才会用它。
不自己建数据库、不用别人的数据库,我该怎么存数据?答案是:不用数据库,直接把JSON-LD以文本形式存在GitHub上。这样做的坏处太多了,写不下。但是挺有趣的,为什么不试试呢?
静态博客如何更新评论
有些“静态”博客在读者的浏览器里运行JavaScript来获取评论。我个人很讨厌JavaScript,所以想避开它,让博客在构建时把评论直接嵌进HTML里。这样做的问题是每次有新的评论就得把博客重新构建一遍——问题是:我怎么知道何时有新的评论?
每次有新的评论,Serverless Functions就会把评论存进托管在GitHub的仓库里。GitHub会通知Netlify仓库有变动,而Netlify在得知后就会自动重新构建博客。
啊,虽然很恶心,但是我感到舒服。
哪些功能不需要动用Serverless Functions
Serverless Functions的弊端是不好调试。而且虽然免费方案给了充足的资源,但还是有限。另外,使用Serverless Functions造成的延迟也是不可忽略的。所以我尽量不依赖它。
设置Content-Type
ActivityPub要求把相关文件的Content-Type Header设置成application/activity+json
。 有些平台
[[headers]]
for = "/note/*"
[headers.values]
Content-Type = "application/activity+json"
如何生成Outbox、Create和Note
我用的生成器是功能不多的Zola,没办法同时生成两种格式的输出。这倒也没关系,因为Zola可以生成RSS。等RSS生成了,再Grep一下每篇文章的日期、题目、网址,就可以拼出对应的Outbox、Create和Note。
另外提一嘴,Grep的-Po配合\K、(?=)有奇效。获取所有日期,只需要:
mapfile -t dates < <(grep -Po '<published>\K[^<]*(?=</published>)' $ATOM)
何时发送新信息
按照ActivityPub的规范来讲,发送新消息的动作是由客户端向服务器发通知,再由服务器发给接收人。但反正也是一个人用的服务器,就没必要再实现这套流程了。
和生成Note类似,在build command里加一行发送消息的命令即可。也可以用Git post-commit hook在每次commit后自动发送最新的博文。不用担心重复发嘟,ActivityPub要求服务器必须有去重能力。
另外只要嘟文签名是对的,你也可以在本地发送。
实现
好,计划完了,就该实现了。
教程推荐
我是有意按耐住从JSON-LD开始介绍ActivityPub以及逐行解释自己代码的心的。因为Mastodon创造者Gargron在18年就写了两篇浅显易懂的博客,介绍了如何自己写程序向某条嘟文发评论、如何验证Mastodon发来的信息、如何向别人发送关注请求等:
我搜到的其他教程大部分止步于Actor、WebFinger的简单介绍上。那些是Gargon第一篇里覆盖的内容,所以我就不推荐别的了。唯一需要注意的是,如今Mastodon的HTTP signatures新增了Digest要求,具体看下面Signing HTTP Messages那段。
当然,如果你更倾向直接阅读规范的话,我推荐你先看一看关于如何阅读ActivityPub那一系列规范的建议:《A highly opinionated guide to learning about ActivityPub》。ActivityPub的规范很短,因为它谈到的内容很少,而且是建立在另外三篇规范的基础之上的。如果初看ActivityPub规范,只会发现哪哪都是洞。我看到那篇博客时已经读了ActivityPub、Activity Vocabulary的规范、Signing HTTP Messages的草案、了解了JSON-LD。对于那篇建议的顺序以及可跳过的部分,我十分赞同并且相见恨晚。
获取来自Mastodon的Activity
实现的第一步就是获取来自Mastodon的消息。所以推荐先把Follow实现了,这样可以收到自己主账号发送的消息。
另一个简单方法是给嘟文链接末尾加上/activity就可以在浏览器里显示Activity;加上.json会显示Object。类似这样:https://实例域名/users/账号名/statuses/一串id/activity
。
后来我得知Mastodon可以导出自己的ActivityPub文件。也许这才是最简单的方法。
2023-08-02更新:最简单的方法是去https://activitypub.academy注册一个临时账号(不需要填任何信息)。那里集成了不能更方便的ActivityPub日志和阅读器(右边栏的最后两个选项):
遇到的问题
Object和它的id是等价的
按照Activity Vocabulary的例子来看,ActivityStream的Object应当是JSON Object的形式——就是得有个大括号,而不仅仅是个字符串——就是只有傻瓜引号。Mastodon的Create Activity会把Note Object以大括号的形式嵌套进来,但Follow Activity则用Actor Object的网址代替了大括号:
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://mona.do/aa2a5bfb-0bbf-4f49-ba04-098ae570e1bb",
"type": "Follow",
"actor": "https://mona.do/users/alvis",
"object": "https://emptystack.top/.netlify/functions/activitypub/users/test"
}
我最初以为是Mastodon有多霸道,要违反规范再创标准。后来才得知“Object和它的id等价”这是JSON-LD所规定的。ActivityPub是建立在ActivityStream之上的,而ActivityStream又是建立在JSON-LD上。所以嵌套Object时就是可以把大括号简写成字符串。
那个字符串一般是能获取到对应Object的网址。从id得到Object的过程叫dereference:activity.rb:168。
ReScript不似想象般美好
Netlify functions支持三种语言:JavaScript、TypeScript和Go。其中JavaScript、TypeScript和Go是我所讨厌的语言。这导致我没语言可用。
倒也不慌,因为我听说过一个名叫ReScript的语言可以编译到JavaScript。据说这个ReScript
ReScript有Record、Object和Js.Dict.t类型可以直接映射到JS Object。但是要表示一个类型可能是JS Object,也可能是字符串的话,ReScript就无能为力了。ReScript有可以表达“或类型”的Variant,但Variant映射到JavaScript Object时会附带额外的信息。
我最初确实用了Variant包裹Record,然后手写序列化反序列化。其实可以用,就是感觉难受——我用ReScript的原因是它互操作方便,结果现在要我写样板代码。
// 最初的Object.t类型
type rec t =
| Object({
id: string,
tpe: typeName,
actor?: string,
object?: t,
// 省略
})
| Id(string)
后来直接把object的类型给模糊掉了,从字符串到record都可以原封不动地转换过去。这有些Hack,但用起来让我舒服一些:现在可以像素级地表示ActivityStream Object了!
module StringOption = {
type t<'a>
external fromString: string => t<'a> = "%identity"
external wrap: 'a => t<'a> = "%identity"
external unwrap: t<'a> => 'a = "%identity"
// 省略
}
// 最后的Object.t类型
type rec t = {
id: string,
@as("type") type_: typeName,
actor?: string,
object?: objectOrId,
// 省略
}
and objectOrId = StringOption.t<t>
还有就是Activity Vocabulary定义各种类型时是用继承描述的,而ReScript不支持继承。ReScript倒是能“用编译期的复制粘贴”间接表达继承,但文档说不推荐使用。最后我只能把所有子类的property都以option的形式写进父类里。
写这个程序时大部分时间都在想如何用ReScript的类型系统完美表达Activity Vocabulary。最后放弃了,只是告诉ReScript我会用到哪些property以及哪些property是可选的。
语法方面,其实ReScript还比较舒服。要是它能用缩进替代大括号、用方括号表示泛型、用下划线代替匿名函数的参数……也许下次我应该用Scala.js。
另外ReScript官网的教程完全没提标准库的事。标准库文档也没多少解释,好多函数只告诉你类型是什么。可能这就是类型爱好者的语言吧。也有可能ReScript的用户都是从它的前身那里来的,所以不需要介绍。我是通过Danny Yang的《Introducing ReScript》入门的。那本书有很多例子,适合初学者。
不喜欢Ruby,但还是得读
如果能读Ruby代码的话,大可以看完ActivityPub的Overview部分,然后去读Mastodon源码——只是Ruby让我感到“非常に不安です”,所以我不想读Mastodon源码。
不过最后还是硬着头皮读了验证嘟文的代码。
Signing HTTP Messages
前面提到Gargron的教程稍有过时,它主要过时的点是现在Mastodon强制要求POST请求提供Digest。那么哪里提到Digest格式了呢?在RFC 3230的第4.3.2小节。
当然,最直接的方式是看Mastodon如何验证Digest的:signature_verification.rb:157。目前它只支持SHA256,所以我们也不用支持别的。
有些验证签名的实现手写了简单parser。我没这么做,只是用逗号和等号进行字符串分割。因为我的目标只是验证来自Mastodon的信息,不是写十全十美的库。毕竟《Signing HTTP Messages》还只是个草案。
另外来自Mastodon的信息会附带“RsaSignature2017”,这是过时的LD Signatures。Mastodon不建议你实现这个。
202 Accepted
有时向Mastodon发送自己制造的Activity会得到“202 Accepted”的反馈,但是嘟文就是显示不出来。
如果嘟文显示不出来,那肯定是哪里有问题。但Mastodon是异步处理嘟文的,所以只能回复代表“我接收到了,但我不保证你的操作能成功”的202。也就是说,只要签名验证成功了,Mastodon没法告诉你嘟文哪里出了问题。
一个可能的原因是object的id和Actor的id不在同一个域名下。具体代码在create.rb:8和create.rb:209。
2023-07注:奇怪的是,我Accept object的id一直就没加域名前缀,竟然可以被Mastodon成功处理。直到我发现Calckey/Misskey上的账号没法关注自己的博客,才发现这个问题。
还有一个可能的原因是Actor id不合法。我最后发现我的Actor id写错网址了,可能因此没通过Mastodon的Actor验证。
另外,如果你的嘟文完全正确,但Mastodon还是不显示。那可以换个id试试。Mastodon会记住之前不成功的id。根据ActivityPub规范,Server需要有去重功能。
如何搜索到自己的Note
前文提到ActivityPub期望你主动POST消息给别人,但其实它也支持别人主动GET你的消息。在Mastodon里,可以搜索Note的id来得到某条当前实例所没有的嘟文。
在实现时一定要给自己的Note添加attributedTo,否则Mastodon不会显示——因为不知道这条嘟文是谁发的。如果使用Create Activity把嘟文发给Mastodon的话,就不需要Note的attributedTo,因为Create有actor。
个人博客 > Mastodon
本来这一段写了挺多,但有些离题所以删掉了。总之,希望有话可说的人建立自己的博客。
当然了,博客和Mastodon不是对立的,它们可以联通起来——什么?你问怎么联通?请看这篇文章:《静态博客也能和Mastodon沟通》。