静态博客也能和Mastodon沟通

前段时间看到一篇名为《ActivityPub协议的简单实现》的博客,发现ActivityPub比想象中的简单,就动了自己也写一个的念头。

现在大家可以在Mastodon上搜索https://emptystack.top/actor@actor@emptystack.top找到我的博客。关注博客后,会收到嘟文形式的更新通知。点赞或评论嘟文,都会显示到对应博文的网页底部。

actor

和来自别的实例的嘟文一样,往期博文不会自动出现在你的实例上。如果想评论往期博文,也可以在Mastodon上用网址搜索——只要在域名和路径之间加上/note即可。比如这篇博文的地址是https://emptystack.top/activitypub-for-static-blog,那么就可以使用https://emptystack.top/note/activitypub-for-static-blog搜索出来。

note

这篇只写实现时想说的话,很少涉及代码。如果相比文字更喜欢看代码的话,可以去看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只管执行代码,我得另寻他处存放上述数据。

第一种选择是自建selfhost数据库。这样的好处是数据都在自己手里,但缺点就是:如果我自建数据库了,何必还要用别人的服务器?如果不用别人的服务器,何必还要用静态博客?最后一定会滑坡到手搓自己的博客系统——在绝望的深渊里永无天日地对着只有一个人用的博客生成器yak shaving。

那么,看来万万不能自建数据库了,第二种选择是什么呢?是继续使用托管商提供的免费数据库。很遗憾,我使用的Netlify没有数据库服务——CloudFlare倒是有,但貌似得把整个博客给他们托管才能用。有人用VercelServerless 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有些平台VercelHeader需要用Serverless Functions来设置,但我用的Netlify可以给静态文件设。所以我的Outbox、CreateNote和博文一样静态。

[[headers]]
for = "/note/*"
[headers.values]
Content-Type = "application/activity+json"

如何生成Outbox、CreateNote

我用的生成器是功能不多的Zola,没办法同时生成两种格式的输出。这倒也没关系,因为Zola可以生成RSS。等RSS生成了,再Grep一下每篇文章的日期、题目、网址,就可以拼出对应的Outbox、CreateNote。

另外提一嘴,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创造者Gargron18年就写了两篇浅显易懂的博客,介绍了如何自己写程序向某条嘟文发评论、如何验证Mastodon发来的信息、如何向别人发送关注请求等:

  1. How to implement a basic ActivityPub server
  2. How to make friends and verify requests

我搜到的其他教程大部分止步于Actor、WebFinger的简单介绍上。那些是Gargon第一篇里覆盖的内容,所以我就不推荐别的了。唯一需要注意的是,如今MastodonHTTP signatures新增了Digest要求,具体看下面Signing HTTP Messages那段

当然,如果你更倾向直接阅读规范的话,我推荐你先看一看关于如何阅读ActivityPub那一系列规范的建议:A highly opinionated guide to learning about ActivityPub。ActivityPub的规范很短,因为它谈到的内容很少,而且是建立在另外三篇规范的基础之上的。如果初看ActivityPub规范,只会发现哪哪都是洞。我看到那篇博客时已经读了ActivityPub、Activity Vocabulary的规范、Signing HTTP Messages的草案、了解了JSON-LD。对于那篇建议的顺序以及可跳过的部分,我十分赞同并且相见恨晚。

获取来自MastodonActivity

实现的第一步就是获取来自Mastodon的消息。所以推荐先把Follow实现了,这样可以收到自己主账号发送的消息。

另一个简单方法是给嘟文链接末尾加上/activity就可以在浏览器里显示Activity;加上.json会显示Object。类似这样:https://实例域名/users/账号名/statuses/一串id/activity

后来我得知Mastodon可以导出自己的ActivityPub文件。也许这才是最简单的方法。

2023-08-02更新:最简单的方法是去https://activitypub.academy注册一个临时账号(不需要填任何信息。那里集成了不能更方便的ActivityPub日志和阅读器(右边栏的最后两个选项

activitypub.academy的Activity Log界面

遇到的问题

Object和它的id是等价的

按照Activity Vocabulary的例子来看,ActivityStreamObject应当是JSON Object的形式——就是得有个大括号,而不仅仅是个字符串——就是只有傻瓜引号。MastodonCreate 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、TypeScriptGo。其中JavaScript、TypeScriptGo是我所讨厌的语言。这导致我没语言可用。

倒也不慌,因为我听说过一个名叫ReScript的语言可以编译到JavaScript。据说这个ReScriptOCaml的类型系统身经百战,编译速度飞快,和JS互操作简单,而且生成的JS代码可读性很高。我早想试试它了。这次可以说是天赐良机——ReScript,快给我露露身手吧!

ReScriptRecord、ObjectJs.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代码的话,大可以看完ActivityPubOverview部分,然后去读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没法告诉你嘟文哪里出了问题。

一个可能的原因是objectidActorid不在同一个域名下。具体代码在create.rb:8create.rb:209

2023-07注:奇怪的是,我Accept objectid一直就没加域名前缀,竟然可以被Mastodon成功处理。直到我发现Calckey/Misskey上的账号没法关注自己的博客,才发现这个问题

还有一个可能的原因是Actor id不合法。我最后发现我的Actor id写错网址了,可能因此没通过MastodonActor验证。

另外,如果你的嘟文完全正确,但Mastodon还是不显示。那可以换个id试试。Mastodon会记住之前不成功的id。根据ActivityPub规范,Server需要有去重功能。

如何搜索到自己的Note

前文提到ActivityPub期望你主动POST消息给别人,但其实它也支持别人主动GET你的消息。在Mastodon里,可以搜索Noteid来得到某条当前实例所没有的嘟文。

在实现时一定要给自己的Note添加attributedTo,否则Mastodon不会显示——因为不知道这条嘟文是谁发的。如果使用Create Activity把嘟文发给Mastodon的话,就不需要NoteattributedTo,因为Createactor。

个人博客 > Mastodon

本来这一段写了挺多,但有些离题所以删掉了。总之,希望有话可说的人建立自己的博客。

当然了,博客和Mastodon不是对立的,它们可以联通起来——什么?你问怎么联通?请看这篇文章:静态博客也能和Mastodon沟通


复制以下链接,并粘贴到你的Mastodon、MisskeyGoToSocial等应用的搜索栏中,即可搜到对应本文的嘟文。对嘟文进行的点赞、转发、评论,都会出现在本文底部。快去试试吧!

链接:https://emptystack.top/note/activitypub-for-static-blog


六人赞过:
  1. 千条江河千条东唯我只身西
  2. 𝓓𝔂𝓵𝓪𝓷 𝓦𝓾
  3. https://mastodon.social/users/maokwen
  4. 树底可以卧海獭 :pentyan_15:
  5. ci
  6. 缄默线

三人转发:
  1. 千条江河千条东唯我只身西
  2. 2024枚の渋沢栄一と2024枚の津田梅子、来い!
  3. 𝓓𝔂𝓵𝓪𝓷 𝓦𝓾