在 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 的大力支持和飞书团队给予我的惊喜~