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/" @doc "Render a Multi-Status XML document from a list of `` 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 ], responses ++ extra} |> XmlBuilder.document() |> XmlBuilder.generate(format: :none) end @doc """ Build a deletion-style `` (no ``, just a top-level ``). 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 `` element. `props_with_status` is a list of `{status_string, [{prop_tag, attrs, children}]}` tuples — each group is wrapped in its own ``. """ 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 `` or ``. 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 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 def parse_propfind(_), do: {:allprop, []} defp scan_for(doc, name) do case :xmerl_scan.string(doc, quiet: true) do {root, _} -> if find_local(root, name), do: {:found, root}, else: :not_found _ -> :not_found end 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 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 `` values. """ def parse_multiget_hrefs(body) when is_binary(body) do body |> xpath(~x"//*[local-name()='href']/text()"sl) |> Enum.map(&to_string/1) 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[^>]*>([^<]+) 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