Files
verdande/internal/caldav/caldav.go
T

237 lines
8.1 KiB
Go
Raw Normal View History

// Package caldav implements the WebDAV and CalDAV verbs a task client needs
// (RFC 4791, over RFC 4918), so Apple Reminders and Thunderbird can read, create
// and complete tasks in verdande directly.
//
// Only the VTODO half is implemented, and only the parts a real client actually
// uses: PROPFIND to discover the collection, REPORT to fetch its contents, and
// GET/PUT/DELETE on individual items. Calendar scheduling, free/busy and the whole
// VEVENT side are not here, because nothing that reads tasks asks for them.
//
// XML is written as text rather than through encoding/xml. The namespace prefixing
// CalDAV requires — DAV:, caldav and calendarserver all interleaved in one
// document — is genuinely more awkward to express through struct tags than to write
// out, and the shape of these responses is fixed.
package caldav
import (
"encoding/xml"
"fmt"
"strings"
)
// Namespaces, with the prefixes clients expect to see.
const (
NSDav = "DAV:"
NSCalDAV = "urn:ietf:params:xml:ns:caldav"
NSCalSrv = "http://calendarserver.org/ns/"
NSApple = "http://apple.com/ns/ical/"
)
// PropfindRequest is the parsed body of a PROPFIND, which names the properties the
// client is asking about.
type PropfindRequest struct {
// AllProp is set when the client asked for everything rather than a list.
AllProp bool
Props []string
}
// ParsePropfind reads a PROPFIND body. An empty body means allprop, which is what
// the spec says and what some clients rely on.
func ParsePropfind(body []byte) PropfindRequest {
if len(strings.TrimSpace(string(body))) == 0 {
return PropfindRequest{AllProp: true}
}
var doc struct {
XMLName xml.Name
AllProp *struct{} `xml:"DAV: allprop"`
Prop struct {
Any []xml.Name `xml:",any"`
} `xml:"DAV: prop"`
}
if err := xml.Unmarshal(body, &doc); err != nil {
// A body that will not parse is treated as allprop rather than as an
// error: clients send surprising things, and answering with everything is
// always a valid response to a PROPFIND.
return PropfindRequest{AllProp: true}
}
if doc.AllProp != nil {
return PropfindRequest{AllProp: true}
}
req := PropfindRequest{}
for _, name := range doc.Prop.Any {
req.Props = append(req.Props, name.Space+":"+name.Local)
}
return req
}
func (r PropfindRequest) Wants(space, local string) bool {
if r.AllProp {
return true
}
for _, p := range r.Props {
if p == space+":"+local {
return true
}
}
return false
}
// Multistatus builds a 207 response body.
type Multistatus struct {
responses []string
}
// AddCollection describes a calendar collection — the project itself.
func (m *Multistatus) AddCollection(href, displayName, ctag string, req PropfindRequest) {
var props []string
if req.Wants(NSDav, "resourcetype") {
props = append(props, `<d:resourcetype><d:collection/><cal:calendar/></d:resourcetype>`)
}
if req.Wants(NSDav, "displayname") {
props = append(props, `<d:displayname>`+escapeXML(displayName)+`</d:displayname>`)
}
if req.Wants(NSCalDAV, "supported-calendar-component-set") {
// VTODO only. Announcing VEVENT as well would make Apple Calendar offer to
// put appointments here, which it would then have nowhere to store.
props = append(props,
`<cal:supported-calendar-component-set><cal:comp name="VTODO"/></cal:supported-calendar-component-set>`)
}
if req.Wants(NSCalSrv, "getctag") {
// The collection tag: clients poll it and only re-sync when it changes.
// Getting this wrong means either constant full syncs or stale data.
props = append(props, `<cs:getctag>`+escapeXML(ctag)+`</cs:getctag>`)
}
if req.Wants(NSDav, "getetag") {
props = append(props, `<d:getetag>"`+escapeXML(ctag)+`"</d:getetag>`)
}
if req.Wants(NSApple, "calendar-color") {
props = append(props, `<ical:calendar-color>#5FBF8FFF</ical:calendar-color>`)
}
if req.Wants(NSCalDAV, "calendar-description") {
props = append(props, `<cal:calendar-description>`+escapeXML(displayName)+`</cal:calendar-description>`)
}
m.add(href, props)
}
// AddItem describes one VTODO resource.
func (m *Multistatus) AddItem(href, etag, calendarData string, req PropfindRequest) {
var props []string
if req.Wants(NSDav, "getetag") {
props = append(props, `<d:getetag>"`+escapeXML(etag)+`"</d:getetag>`)
}
if req.Wants(NSDav, "resourcetype") {
props = append(props, `<d:resourcetype/>`)
}
if req.Wants(NSDav, "getcontenttype") {
props = append(props, `<d:getcontenttype>text/calendar; component=vtodo</d:getcontenttype>`)
}
if calendarData != "" && req.Wants(NSCalDAV, "calendar-data") {
props = append(props, `<cal:calendar-data>`+escapeXML(calendarData)+`</cal:calendar-data>`)
}
m.add(href, props)
}
// AddPrincipal describes the user, which is what a client asks for first when it is
// working out where its calendars live.
func (m *Multistatus) AddPrincipal(href, calendarHome, displayName string, req PropfindRequest) {
var props []string
if req.Wants(NSDav, "resourcetype") {
props = append(props, `<d:resourcetype><d:principal/></d:resourcetype>`)
}
if req.Wants(NSDav, "displayname") {
props = append(props, `<d:displayname>`+escapeXML(displayName)+`</d:displayname>`)
}
if req.Wants(NSDav, "current-user-principal") || req.Wants(NSDav, "principal-URL") {
props = append(props,
`<d:current-user-principal><d:href>`+escapeXML(href)+`</d:href></d:current-user-principal>`,
`<d:principal-URL><d:href>`+escapeXML(href)+`</d:href></d:principal-URL>`)
}
if req.Wants(NSCalDAV, "calendar-home-set") {
props = append(props,
`<cal:calendar-home-set><d:href>`+escapeXML(calendarHome)+`</d:href></cal:calendar-home-set>`)
}
m.add(href, props)
}
func (m *Multistatus) add(href string, props []string) {
if len(props) == 0 {
// A response with no properties still has to exist, or the client sees the
// resource as absent rather than as having nothing it asked for.
props = append(props, `<d:resourcetype/>`)
}
m.responses = append(m.responses, fmt.Sprintf(
`<d:response><d:href>%s</d:href><d:propstat><d:prop>%s</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>`,
escapeXML(href), strings.Join(props, "")))
}
// AddNotFound records a resource the client asked about that does not exist. Needed
// by sync: it is how a client learns a task was deleted somewhere else.
func (m *Multistatus) AddNotFound(href string) {
m.responses = append(m.responses, fmt.Sprintf(
`<d:response><d:href>%s</d:href><d:status>HTTP/1.1 404 Not Found</d:status></d:response>`,
escapeXML(href)))
}
func (m *Multistatus) XML() string {
return `<?xml version="1.0" encoding="utf-8"?>` +
`<d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav" ` +
`xmlns:cs="http://calendarserver.org/ns/" xmlns:ical="http://apple.com/ns/ical/">` +
strings.Join(m.responses, "") +
`</d:multistatus>`
}
// --- REPORT ---------------------------------------------------------------------------
// ReportRequest is the parsed body of a calendar-query or calendar-multiget.
type ReportRequest struct {
// Multiget lists specific hrefs; a query asks for everything matching a filter.
Multiget bool
Hrefs []string
Props PropfindRequest
}
func ParseReport(body []byte) ReportRequest {
var doc struct {
XMLName xml.Name
Prop struct {
Any []xml.Name `xml:",any"`
} `xml:"DAV: prop"`
Hrefs []string `xml:"DAV: href"`
}
if err := xml.Unmarshal(body, &doc); err != nil {
return ReportRequest{Props: PropfindRequest{AllProp: true}}
}
req := ReportRequest{
Multiget: strings.Contains(strings.ToLower(doc.XMLName.Local), "multiget"),
Hrefs: doc.Hrefs,
}
for _, name := range doc.Prop.Any {
req.Props.Props = append(req.Props.Props, name.Space+":"+name.Local)
}
if len(req.Props.Props) == 0 {
req.Props.AllProp = true
}
return req
}
// escapeXML is deliberately its own function rather than xml.EscapeText: task titles
// arrive from users, and every one of them passes through here on its way into a
// document a client will parse. A missed escape is a client that fails to sync with
// a parse error nobody can see the cause of.
func escapeXML(s string) string {
var b strings.Builder
if err := xml.EscapeText(&b, []byte(s)); err != nil {
return ""
}
return b.String()
}