Packages

CalDAV server library — mount as a Plug, bring your own storage adapter. Heavily WIP / beta software; APIs may change between releases.

Current section

Files

Jump to
ex_dav lib ex_dav caldav xml.ex
Raw

lib/ex_dav/caldav/xml.ex

defmodule ExDav.CalDav.XML do
@moduledoc """
XML response builders and small request parsers for the CalDAV
endpoints. Generates Multi-Status (RFC 4918) responses with the
CalDAV (RFC 4791) and CalendarServer extensions.
"""
import SweetXml
@ns_d "DAV:"
@ns_c "urn:ietf:params:xml:ns:caldav"
@ns_cs "http://calendarserver.org/ns/"
@ns_apple "http://apple.com/ns/ical/"
@doc "Render a Multi-Status XML document from a list of `<response>` elements."
def multistatus(responses, extra \\ []) when is_list(responses) and is_list(extra) do
{"d:multistatus",
[
"xmlns:d": @ns_d,
"xmlns:c": @ns_c,
"xmlns:cs": @ns_cs,
"xmlns:apple": @ns_apple
], responses ++ extra}
|> XmlBuilder.document()
|> XmlBuilder.generate(format: :none)
end
@doc """
Build a deletion-style `<d:response>` (no `<d:propstat>`, just a
top-level `<d:status>`). Used by `sync-collection` to signal removed
resources.
"""
def response_status(href, status) do
{"d:response", nil,
[
{"d:href", nil, href},
{"d:status", nil, "HTTP/1.1 #{status}"}
]}
end
@doc """
Build a single `<d:response>` element. `props_with_status` is a list
of `{status_string, [{prop_tag, attrs, children}]}` tuples — each
group is wrapped in its own `<d:propstat>`.
"""
def response(href, props_with_status) do
propstats =
Enum.flat_map(props_with_status, fn {status, props} ->
if props == [] do
[]
else
[
{"d:propstat", nil,
[
{"d:prop", nil, props},
{"d:status", nil, "HTTP/1.1 #{status}"}
]}
]
end
end)
{"d:response", nil, [{"d:href", nil, href} | propstats]}
end
@doc """
Returns the list of requested property element names (as strings,
prefixed with `d:`/`c:`/`cs:` style tags) and a flag indicating whether
the request asked for `<allprop>` or `<propname>`. We keep parsing
minimal: anything not understood becomes a `404 Not Found` propstat.
"""
def parse_propfind(body) when is_binary(body) and byte_size(body) > 0 do
if dangerous_xml?(body) do
{:allprop, []}
else
body = String.trim(body)
if body == "" do
{:allprop, []}
else
doc = body |> :binary.bin_to_list()
cond do
match?({:found, _}, scan_for(doc, ~c"propname")) ->
{:propname, []}
match?({:found, _}, scan_for(doc, ~c"allprop")) ->
{:allprop, []}
true ->
props = extract_prop_names(body)
{:prop, props}
end
end
end
end
def parse_propfind(_), do: {:allprop, []}
# Reject XML bodies that declare a DOCTYPE or ENTITY — they would otherwise
# let `:xmerl_scan` and SweetXml's `xpath` resolve external entities
# (file:// / http:// SYSTEM IDs) or expand recursive entities. CalDAV
# request bodies never legitimately use either construct.
defp dangerous_xml?(body) when is_binary(body) do
Regex.match?(~r/<![ \t\r\n]*(?:DOCTYPE|ENTITY)/i, body)
end
defp scan_for(doc, name) do
{root, _} = :xmerl_scan.string(doc, quiet: true)
if find_local(root, name), do: {:found, root}, else: :not_found
rescue
_ -> :not_found
catch
:exit, _ -> :not_found
end
defp find_local({:xmlElement, n, _, _, _, _, _, _, children, _, _, _}, name) do
if to_string(n) == to_string(name) or local_part(n) == to_string(name) do
true
else
Enum.any?(children, &find_local(&1, name))
end
end
defp find_local(_, _), do: false
defp local_part(name) do
name |> to_string() |> String.split(":") |> List.last()
end
@doc """
Extract requested property element local names from a PROPFIND or
PROPPATCH body. Returns a list of `{namespace, local_name}` tuples.
"""
def extract_prop_names(body) do
# Capture the contents of the first <prop> element regardless of prefix.
# We use a permissive regex because requesters use varying prefixes.
case Regex.run(~r/<[^:>]*:?prop[^>]*>(.*?)<\/[^:>]*:?prop>/s, body) do
[_, inner] ->
Regex.scan(~r/<([\w-]+):([\w-]+)\b/, inner)
|> Enum.map(fn [_, prefix, name] ->
{prefix_to_ns(prefix, body), name}
end)
|> Enum.uniq()
_ ->
[]
end
end
defp prefix_to_ns(prefix, body) do
case Regex.run(~r/xmlns:#{prefix}\s*=\s*"([^"]+)"/, body) do
[_, uri] -> uri
_ -> nil
end
end
@doc """
Parse a calendar-multiget body to extract the list of `<href>` values.
"""
def parse_multiget_hrefs(body) when is_binary(body) do
if dangerous_xml?(body) do
[]
else
body
|> xpath(~x"//*[local-name()='href']/text()"sl)
|> Enum.map(&to_string/1)
end
end
@doc """
Parse a calendar-query body for a `prop-filter[name=UID]/text-match`
match. Returns `{:uid, value}` or `:no_uid_filter`. Also returns the
set of comp-filter component names found at the inner level
(e.g. `["VEVENT"]`).
"""
def parse_calendar_query(body) when is_binary(body) do
uid =
case Regex.run(
~r/<[^>]*prop-filter[^>]*name=["']UID["'][^>]*>\s*<[^>]*text-match[^>]*>([^<]+)</,
body
) do
[_, value] -> String.trim(value)
_ -> nil
end
comps =
Regex.scan(~r/<[^>]*comp-filter[^>]*name=["']([^"']+)["']/, body)
|> Enum.map(fn [_, name] -> name end)
|> Enum.reject(&(&1 == "VCALENDAR"))
{time_start, time_end} = parse_time_range(body)
%{uid: uid, components: comps, time_start: time_start, time_end: time_end}
end
defp parse_time_range(body) do
case Regex.run(
~r/<[^>]*time-range[^>]*\sstart=["']([^"']+)["'][^>]*\send=["']([^"']+)["']/,
body
) do
[_, s, e] ->
{ExDav.ICal.parse_ical_datetime(s), ExDav.ICal.parse_ical_datetime(e)}
_ ->
case Regex.run(
~r/<[^>]*time-range[^>]*\send=["']([^"']+)["'][^>]*\sstart=["']([^"']+)["']/,
body
) do
[_, e, s] ->
{ExDav.ICal.parse_ical_datetime(s), ExDav.ICal.parse_ical_datetime(e)}
_ ->
{nil, nil}
end
end
end
@doc """
Parse a `sync-collection` REPORT body. Returns the integer version
encoded in the sync-token (or `nil` for an empty/initial token) and
the requested prop names (currently only used to decide whether to
return calendar-data).
"""
def parse_sync_collection(body) when is_binary(body) do
token =
case Regex.run(~r/<[^>]*sync-token[^>]*>([^<]*)<\/[^>]*sync-token>/, body) do
[_, raw] ->
case Regex.run(~r/(\d+)\s*$/, String.trim(raw)) do
[_, n] -> String.to_integer(n)
_ -> nil
end
_ ->
nil
end
include_data? = String.contains?(body, "calendar-data")
%{since: token, include_data: include_data?}
end
@doc "Parse MKCALENDAR body for displayname / description (best-effort)."
def parse_mkcalendar(body) when is_binary(body) do
%{
displayname: extract_text(body, "displayname"),
description: extract_text(body, "calendar-description")
}
end
@doc "Parse PROPPATCH body — best-effort for displayname / description."
def parse_proppatch(body) when is_binary(body) do
%{
displayname: extract_text(body, "displayname"),
description: extract_text(body, "calendar-description")
}
end
defp extract_text(body, local_name) do
case Regex.run(
~r/<[^:>]*:?#{local_name}[^>]*>([^<]*)<\/[^:>]*:?#{local_name}>/,
body
) do
[_, value] -> value |> :erlang.iolist_to_binary() |> String.trim()
_ -> nil
end
end
end