在 Databend Labs,我们主要通过飞书进行日常沟通和任务协调。对于需要语音交流的会议,我们通过日历功能来统一安排时间。我个人则更倾向于使用 Fastmail,用其服务管理邮件和日程。因此,我开始考虑能否将飞书的日历同步到 Fastmail,以便在一个平台上统一管理所有日程。

理论上,这并不复杂:

  • 飞书提供了 CalDAV 来支持日历同步
  • Fastmail 允许订阅 CalDAV 服务

看似只需在 Fastmail 上适当配置 CalDAV 即可。然而,现实是每次尝试配置飞书的 CalDAV 到 Fastmail 时都会遇到报错。以前我对此不以为意,但这个周末,我决定彻底解决这个问题。

TL; DR

飞书暴露的 CalDAV 实现并不标准,对客户端的 Auto Discovery 行为有依赖。解决方案是手动请求飞书 CalDAV 服务,获取到真实的地址再配置。

首先发送 PROPFIND 到 /<username> 获取飞书生成的随机日历 ID

curl -v -X PROPFIND -H "Depth: 1" -H "Content-Type: application/xml" -u "<username>:<password>" https://caldav.feishu.cn/calendars/

在响应中会有形如 <D:response><D:href>/calendars/<uuid>/</D:href></D:response> 的输出,这里的 /calendars/<uuid>/ 就是飞书为个人日历生成的随机路径。

然后在 CalDAV 客户端配置如下即可:

  • Username: <username>
  • Password: <password>
  • Server URL: https://caldav.feishu.cn/calendars/<uuid>/

背景介绍

在日历同步领域我们主要遇到以下标准:

CalDAV

CalDAV 是基于 WebDAV 的扩展,是 HTTP 扩展的一部分,允许用户或应用程序读取和写入存储在远程服务器上的日历数据。CalDAV 的主要应用场合是在多个设备或多个应用之间同步日历信息,包括事件、提醒和其他相关数据。常见的服务如 Google Calendar 和 Apple iCloud 都支持 CalDAV。

iCal

iCal 是一个文件格式标准,正式名为 iCalendar(.ics 文件),用于存储日历事件、提醒和其他时间相关信息。这种文件格式被广泛接受和支持,可以用于导入、导出和共享日历数据。在日历服务中我们最常用的订阅日历的方式就是通过 iCal

漫长的调试

根据飞书提供的文档 个人用户如何设置本地系统日历到飞书日历的单向同步,并根据一些最基础的信任,我们可以先假定飞书这个功能的实现是正确的,它没有在文档里面说瞎话,也就是飞书的 CalDAV 功能在 macOS 和 Windows 10 上是正常的,那 Fastmail 不正常只能是 CalDAV Client 具体实现上的一些差异。

通过查询文档和 ChatGPT,我们能够知道 CalDAV 最简单的使用方式是通过 PROPFIND 来查询指定条目的属性:

飞书的实际输出是压缩后的,这里进行了格式化

> curl -X PROPFIND -H "Depth: 1" -H "Content-Type: application/xml" -u "<username>:<password>" https://caldav.feishu.cn/
<?xml version="1.0" encoding="UTF-8"?>
<D:multistatus
    xmlns:D="DAV:"
    xmlns:C="urn:ietf:params:xml:ns:caldav"
    xmlns:CS="http://calendarserver.org/ns/"
    xmlns:ICAL="http://apple.com/ns/ical/"
    xmlns:ME="http://me.com/_namespace/">
    <D:response>
        <D:href>/</D:href>
    </D:response>
</D:multistatus>

我们预期这里输出的应该是根路径的属性,但是这里只有一个 href。虽然还不知道该怎么继续,但是我们已经能够知道请求是正常响应的,只不过内容不符合预期。根据已有的输出,我猜想飞书使用了 calendarserver 进行了一些魔改。再加上飞书这个 CalDAV 应该是多租户的,所以我问 ChatGPT,如果有多个用户的话,该如何请求?

> calendarserver 如果有多个用户的话,他们的用户路径是什么样的?

在多用户环境中使用CalendarServer,一种常用的开源日历服务器,通常涉及为每个用户设置单独的访问路径和账户。这通常通过一种叫做路径路由的机制来实现,该机制允许每个用户通过特定的URL路径来访问其各自的日历数据

假设CalendarServer的基本URL是 `https://calendar.example.com/`,多个用户(比如 `Alice`  `Bob`)可能会有如下的用户路径:

- Alice: `https://calendar.example.com/calendars/users/alice/`
- Bob: `https://calendar.example.com/calendars/users/bob/`

在这些路径下,各用户通常会有多个子路径,用于表示个人日历、工作日历、假期日历等。例如,Alice 的个人日历可能位于 `https://calendar.example.com/calendars/users/alice/personal/`

这样的路径结构有助于实现多用户环境下的访问控制、数据隔离和个性化设置。这通常通过与后端数据库或者身份验证服务(如LDAP或OAuth)的集成来进一步强化。

综上所述,多用户的CalendarServer通常会为每个用户提供一个专有的URL路径,用于访问他们各自的日历数据和设置。这样的设计有助于在多用户环境中实现安全和数据管理。

好,现在我们大胆猜想飞书沿用了同样的设计:

> curl -X PROPFIND -H "Depth: 1" -H "Content-Type: application/xml" -u "<username>:<password>" https://caldav.feishu.cn/calendars/users/<username>
<?xml version="1.0" encoding="UTF-8"?>
<D:multistatus
    xmlns:D="DAV:"
    xmlns:C="urn:ietf:params:xml:ns:caldav"
    xmlns:CS="http://calendarserver.org/ns/"
    xmlns:ICAL="http://apple.com/ns/ical/"
    xmlns:ME="http://me.com/_namespace/">
</D:multistatus>

很遗憾,没有猜对。访问 /calendars/users/<username> 是一个完全空的结果,那 /calendars/users/ 呢?

> curl -X PROPFIND -H "Depth: 1" -H "Content-Type: application/xml" -u "<username>:<password>" https://caldav.feishu.cn/calendars/users/
< HTTP/1.1 400 Bad Request
< Server: TLB
< Content-Length: 0
< Connection: keep-alive

有趣,直接报错了,那更进一步,访问 /calendars/ 会输出什么呢?

> curl -X PROPFIND -H "Depth: 1" -H "Content-Type: application/xml" -u "<username>:<password>" https://caldav.feishu.cn/calendars/
<?xml version="1.0" encoding="UTF-8"?>
<D:multistatus
    xmlns:D="DAV:"
    xmlns:C="urn:ietf:params:xml:ns:caldav"
    xmlns:CS="http://calendarserver.org/ns/"
    xmlns:ICAL="http://apple.com/ns/ical/"
    xmlns:ME="http://me.com/_namespace/">
    <D:response>
        <D:href>/calendars/</D:href>
    </D:response>
    <D:response>
        <D:href>/calendars/<uuid>/</D:href>
    </D:response>
</D:multistatus>

有变化了!我们拿到了一个新的 href,指向了一个 uuid,我们延续这个思路继续请求:

> curl -X PROPFIND -H "Depth: 1" -H "Content-Type: application/xml" -u "<username>:<password>" https://caldav.feishu.cn/calendars/<uuid>/
<?xml version="1.0" encoding="UTF-8"?>
<D:multistatus
    xmlns:D="DAV:"
    xmlns:C="urn:ietf:params:xml:ns:caldav"
    xmlns:CS="http://calendarserver.org/ns/"
    xmlns:ICAL="http://apple.com/ns/ical/"
    xmlns:ME="http://me.com/_namespace/">
    <D:response>
        <D:href>/calendars/<uuid>/d0f47ac4-a047-4589-b7c1-9af6e3cc471b.ics</D:href>
    </D:response>
    <D:response>
        <D:href>/calendars/<uuid>/bb010e10-cd76-4213-9d47-28a9bf48417e.ics</D:href>
    </D:response>
    <D:response>
        <D:href>/calendars/<uuid>/c0e28572-ed2e-447c-88ed-251dd437eb72.ics</D:href>
    </D:response>
    <D:response>
        <D:href>/calendars/<uuid>/2fb352a3-37d3-4487-aea2-dbc1b18ec371.ics</D:href>
    </D:response>
    <D:response>
        <D:href>/calendars/<uuid>/c987311c-3fee-4be5-881c-7d8e5113deea.ics</D:href>
    </D:response>
</D:multistatus>

好,我们现在拿到了一系列指向 ics 的路径,看起来每一个 ics 指向了一个具体的事件。我使用 https://caldav.feishu.cn/calendars/<uuid>/ 作为 Server URL 尝试连接,发现 Fastmail 成功连上了飞书的 CalDAV 并正确的获取到了事件!

万物皆草台班子

好,飞书已经成功连上了,但是我还是非常好奇,为什么刚刚好是 /calendars/<uuid> 呢?看着 PROPFIND 返回的结果,我尝试了一下访问 /

> curl -X PROPFIND -H "Depth: 1" -H "Content-Type: application/xml" -u "<username>:<password>" https://caldav.feishu.cn//
<?xml version="1.0" encoding="UTF-8"?>
<D:multistatus
    xmlns:D="DAV:"
    xmlns:C="urn:ietf:params:xml:ns:caldav"
    xmlns:CS="http://calendarserver.org/ns/"
    xmlns:ICAL="http://apple.com/ns/ical/"
    xmlns:ME="http://me.com/_namespace/">
    <D:response>
        <D:href>//</D:href>
    </D:response>
    <D:response>
        <D:href>//<uuid>/</D:href>
    </D:response>
</D:multistatus>

蛤?我简直不敢相信自己的眼睛,随后我尝试了一些其他的可能:

> curl -X PROPFIND -H "Depth: 1" -H "Content-Type: application/xml" -u "<username>:<password>" https://caldav.feishu.cn/feishu_is_really_cool/
<?xml version="1.0" encoding="UTF-8"?>
<D:multistatus
    xmlns:D="DAV:"
    xmlns:C="urn:ietf:params:xml:ns:caldav"
    xmlns:CS="http://calendarserver.org/ns/"
    xmlns:ICAL="http://apple.com/ns/ical/"
    xmlns:ME="http://me.com/_namespace/">
    <D:response>
        <D:href>/feishu_is_really_cool/</D:href>
    </D:response>
    <D:response>
        <D:href>/feishu_is_really_cool/61AEE45F-1E58-401C-61AE-E45F1E58401C/</D:href>
    </D:response>
</D:multistatus>

我懂了:飞书的 CalDAV 实现是如此的草台,以至于它只有在根路径下工作不正确。

总结

本文分享了我调试飞书 CalDAV 的全过程,感谢 ChatGPT 的大力支持和飞书团队给予我的惊喜~