// 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, ``) } if req.Wants(NSDav, "displayname") { props = append(props, ``+escapeXML(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, ``) } 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, ``+escapeXML(ctag)+``) } if req.Wants(NSDav, "getetag") { props = append(props, `"`+escapeXML(ctag)+`"`) } if req.Wants(NSApple, "calendar-color") { props = append(props, `#5FBF8FFF`) } if req.Wants(NSCalDAV, "calendar-description") { props = append(props, ``+escapeXML(displayName)+``) } 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, `"`+escapeXML(etag)+`"`) } if req.Wants(NSDav, "resourcetype") { props = append(props, ``) } if req.Wants(NSDav, "getcontenttype") { props = append(props, `text/calendar; component=vtodo`) } if calendarData != "" && req.Wants(NSCalDAV, "calendar-data") { props = append(props, ``+escapeXML(calendarData)+``) } 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, ``) } if req.Wants(NSDav, "displayname") { props = append(props, ``+escapeXML(displayName)+``) } if req.Wants(NSDav, "current-user-principal") || req.Wants(NSDav, "principal-URL") { props = append(props, ``+escapeXML(href)+``, ``+escapeXML(href)+``) } if req.Wants(NSCalDAV, "calendar-home-set") { props = append(props, ``+escapeXML(calendarHome)+``) } 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, ``) } m.responses = append(m.responses, fmt.Sprintf( `%s%sHTTP/1.1 200 OK`, 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( `%sHTTP/1.1 404 Not Found`, escapeXML(href))) } func (m *Multistatus) XML() string { return `` + `` + strings.Join(m.responses, "") + `` } // --- 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() }