Current section
Files
Jump to
Current section
Files
lib/ex_dav/carddav/plug.ex
defmodule ExDav.CardDav.Plug do
@moduledoc """
Top-level Plug that handles CardDAV requests under the `/dav` prefix.
Resource layout (relative to mount point):
/principals/{user}/ — principal resource
/addressbooks/{user}/ — addressbook-home-set
/addressbooks/{user}/{book}/ — addressbook collection
/addressbooks/{user}/{book}/{r} — vCard resource (.vcf)
Can be mounted at any path via `forward` in a Phoenix router. All
`<href>` elements in responses are prefixed with `conn.script_name` so
they remain valid behind a prefix-routing proxy.
Required opts:
* `:storage` — module implementing `ExDav.Storage`
* `:authenticator` — module implementing `ExDav.Authenticator`, or
`{module, opts}` to pass adapter-specific opts
Optional opts:
* `:prefix` — first path segment the plug claims (default `"dav"`).
Use `"carddav"` if you want autodiscovery and request URLs to live
under `/carddav/...` rather than the historical `/dav/...`.
Example:
plug ExDav.CardDav.Plug,
storage: ExDav.Storage.Postgres,
authenticator: {ExDav.Authenticator.Basic,
verify: {ExDav.Storage.Postgres, :authenticate}}
"""
@behaviour Plug
import Plug.Conn
alias ExDav.CardDav.XML
@default_prefix "dav"
# Reject PUT bodies above this size — the protocol layer keeps the whole
# body in RAM and re-emits it in REPORT responses, so unbounded sizes turn
# a single authenticated user into a memory-amplification vector.
@max_resource_size 1_048_576
@impl true
def init(opts) do
storage = Keyword.fetch!(opts, :storage)
authenticator = normalize_authenticator(Keyword.fetch!(opts, :authenticator))
prefix = Keyword.get(opts, :prefix, @default_prefix)
[storage: storage, authenticator: authenticator, prefix: prefix]
end
defp normalize_authenticator({mod, opts}) when is_atom(mod) and is_list(opts), do: {mod, opts}
defp normalize_authenticator(mod) when is_atom(mod), do: {mod, []}
@impl true
def call(%Plug.Conn{path_info: [".well-known", "carddav"]} = conn, opts) do
conn
|> put_resp_header("location", "/" <> opts[:prefix] <> "/")
|> send_resp(301, "")
|> halt()
end
def call(%Plug.Conn{path_info: [head | rest]} = conn, opts) do
if head == opts[:prefix] do
handle_authenticated(conn, opts, rest)
else
conn
end
end
def call(conn, _opts), do: conn
defp handle_authenticated(conn, opts, rest) do
{auth_mod, auth_opts} = opts[:authenticator]
case auth_mod.authenticate(conn, auth_opts) do
{:ok, conn, user} ->
conn
|> assign(:carddav_storage, opts[:storage])
|> assign(:carddav_prefix, opts[:prefix])
|> read_full_body()
|> dispatch(user, rest)
:unauth ->
unauth(conn)
end
end
# ---- helpers -----------------------------------------------------------
defp storage(conn), do: conn.assigns.carddav_storage
defp script_prefix(conn) do
case conn.script_name do
[] -> ""
parts -> "/" <> Enum.join(parts, "/")
end
end
defp url_prefix(conn) do
prefix = conn.assigns[:carddav_prefix] || @default_prefix
script_prefix(conn) <> "/" <> prefix
end
defp unauth(conn) do
conn
|> put_resp_header("www-authenticate", ~s|Basic realm="ExDav"|)
|> put_resp_header("dav", "1, 2, 3, addressbook")
|> put_resp_content_type("text/plain")
|> send_resp(401, "Unauthorized")
|> halt()
end
defp read_full_body(conn) do
case read_body(conn, length: 8_000_000, read_length: 1_000_000) do
{:ok, body, conn} ->
if dangerous_xml?(body) do
Plug.Conn.assign(conn, :raw_body, {:error, :dangerous_xml})
else
Plug.Conn.assign(conn, :raw_body, body)
end
{:more, _partial, conn} ->
Plug.Conn.assign(conn, :raw_body, {:error, :too_large})
{:error, _} ->
Plug.Conn.assign(conn, :raw_body, {:error, :body_read_failed})
end
end
# CardDAV bodies never legitimately contain a DOCTYPE or ENTITY declaration.
# Rejecting them at the door prevents XXE / billion-laughs against the
# downstream xmerl- and SweetXml-based parsers, regardless of any
# underlying parser configuration.
defp dangerous_xml?(body) when is_binary(body) do
Regex.match?(~r/<![ \t\r\n]*(?:DOCTYPE|ENTITY)/i, body)
end
defp fetch_body(conn) do
case conn.assigns[:raw_body] do
body when is_binary(body) ->
{:ok, body}
{:error, :too_large} ->
{:error, conn |> send_resp(413, "Payload Too Large") |> halt()}
{:error, :dangerous_xml} ->
{:error,
conn
|> send_resp(400, "Bad Request: DOCTYPE/ENTITY declarations are not allowed")
|> halt()}
{:error, _} ->
{:error, conn |> send_resp(400, "Bad Request") |> halt()}
_ ->
{:ok, ""}
end
end
defp authorize(conn, user, target) do
if target == user do
:ok
else
{:error, forbidden(conn)}
end
end
# ---- dispatch -------------------------------------------------------------
defp dispatch(conn, user, segments) do
case {conn.method, segments} do
{"OPTIONS", _} ->
handle_options(conn)
{"PROPFIND", []} ->
handle_propfind_root(conn, user)
{"PROPFIND", ["principals"]} ->
handle_propfind_principals_root(conn, user)
{"PROPFIND", ["principals", target]} ->
handle_propfind_principal(conn, user, target)
{"PROPFIND", ["addressbooks", target]} ->
handle_propfind_addressbook_home(conn, user, target)
{"PROPFIND", ["addressbooks", target, book]} ->
handle_propfind_addressbook(conn, user, target, book)
{"REPORT", ["addressbooks", target, book]} ->
handle_report(conn, user, target, book)
{"MKCOL", ["addressbooks", target, book]} ->
handle_mkcol(conn, user, target, book)
{"PROPPATCH", ["addressbooks", target, book]} ->
handle_proppatch(conn, user, target, book)
{"DELETE", ["addressbooks", target, book]} ->
handle_delete_addressbook(conn, user, target, book)
{"PROPFIND", ["addressbooks", target, book, res]} ->
handle_propfind_resource(conn, user, target, book, res)
{"GET", ["addressbooks", target, book, res]} ->
handle_get_resource(conn, user, target, book, res)
{"HEAD", ["addressbooks", target, book, res]} ->
handle_get_resource(conn, user, target, book, res)
{"PUT", ["addressbooks", target, book, res]} ->
handle_put_resource(conn, user, target, book, res)
{"DELETE", ["addressbooks", target, book, res]} ->
handle_delete_resource(conn, user, target, book, res)
_ ->
conn |> send_resp(405, "Method Not Allowed") |> halt()
end
end
# ---- OPTIONS --------------------------------------------------------------
defp handle_options(conn) do
conn
|> put_resp_header("dav", "1, 2, 3, addressbook")
|> put_resp_header(
"allow",
"OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, PROPPATCH, REPORT, MKCOL"
)
|> send_resp(200, "")
|> halt()
end
# ---- PROPFIND helpers -----------------------------------------------------
defp handle_propfind_root(conn, user) do
pfx = url_prefix(conn)
href = "#{pfx}/"
response = build_root_response(conn, href, user)
multistatus(conn, [response])
end
defp handle_propfind_principals_root(conn, user) do
pfx = url_prefix(conn)
href = "#{pfx}/principals/"
user_href = "#{pfx}/principals/#{user}/"
self_resp =
XML.response(href, [
{"200 OK",
[
{"d:resourcetype", nil, [{"d:collection", nil, nil}]},
{"d:displayname", nil, "Principals"}
]}
])
user_resp = XML.response(user_href, principal_props(conn, user))
case depth(conn) do
0 -> multistatus(conn, [self_resp])
_ -> multistatus(conn, [self_resp, user_resp])
end
end
defp handle_propfind_principal(conn, user, target) do
case authorize(conn, user, target) do
:ok ->
if storage(conn).user_exists?(target) do
pfx = url_prefix(conn)
response = XML.response("#{pfx}/principals/#{target}/", principal_props(conn, target))
multistatus(conn, [response])
else
not_found(conn)
end
{:error, conn} ->
conn
end
end
defp handle_propfind_addressbook_home(conn, user, target) do
if target != user or not storage(conn).user_exists?(target) do
not_found(conn)
else
pfx = url_prefix(conn)
home_href = "#{pfx}/addressbooks/#{target}/"
home_response =
XML.response(home_href, [
{"200 OK",
[
{"d:resourcetype", nil, [{"d:collection", nil, nil}]},
{"d:displayname", nil, "#{target} addressbooks"},
{"d:current-user-principal", nil,
[{"d:href", nil, "#{pfx}/principals/#{target}/"}]},
{"d:owner", nil, [{"d:href", nil, "#{pfx}/principals/#{target}/"}]}
]}
])
book_responses =
case depth(conn) do
0 ->
[]
_ ->
for book <- storage(conn).list_collections(target, :addressbook) do
XML.response(
"#{pfx}/addressbooks/#{target}/#{book.name}/",
addressbook_props(conn, target, book)
)
end
end
multistatus(conn, [home_response | book_responses])
end
end
defp handle_propfind_addressbook(conn, user, target, book_name) do
case authorize(conn, user, target) do
:ok -> handle_propfind_addressbook_authorized(conn, target, book_name)
{:error, conn} -> conn
end
end
defp handle_propfind_addressbook_authorized(conn, target, book_name) do
pfx = url_prefix(conn)
case depth(conn) do
0 ->
case storage(conn).get_collection(target, :addressbook, book_name) do
nil ->
not_found(conn)
book ->
book_href = "#{pfx}/addressbooks/#{target}/#{book.name}/"
multistatus(conn, [XML.response(book_href, addressbook_props(conn, target, book))])
end
_ ->
case storage(conn).get_collection_with_resources(target, :addressbook, book_name) do
nil ->
not_found(conn)
book ->
book_href = "#{pfx}/addressbooks/#{target}/#{book.name}/"
book_response = XML.response(book_href, addressbook_props(conn, target, book))
res_responses =
for {_, resource} <- book.resources do
res_href = "#{pfx}/addressbooks/#{target}/#{book.name}/#{resource.name}"
XML.response(res_href, resource_propfind_props(resource))
end
multistatus(conn, [book_response | res_responses])
end
end
end
defp handle_propfind_resource(conn, user, target, book_name, res_name) do
case authorize(conn, user, target) do
:ok ->
pfx = url_prefix(conn)
case storage(conn).get_resource(target, :addressbook, book_name, res_name) do
nil ->
not_found(conn)
resource ->
href = "#{pfx}/addressbooks/#{target}/#{book_name}/#{resource.name}"
multistatus(conn, [XML.response(href, resource_propfind_props(resource))])
end
{:error, conn} ->
conn
end
end
# ---- REPORT ---------------------------------------------------------------
defp handle_report(conn, user, target, book_name) do
with :ok <- authorize(conn, user, target),
{:ok, body} <- fetch_body(conn) do
pfx = url_prefix(conn)
prefix = "#{pfx}/addressbooks/#{target}/#{book_name}/"
cond do
String.contains?(body, "sync-collection") ->
handle_sync_collection(conn, target, book_name, prefix, body)
String.contains?(body, "addressbook-multiget") ->
handle_multiget(conn, target, book_name, prefix, body)
String.contains?(body, "addressbook-query") ->
handle_query(conn, target, book_name, prefix, body)
true ->
conn |> send_resp(422, "Unknown report type") |> halt()
end
else
{:error, conn} -> conn
end
end
defp handle_multiget(conn, target, book_name, prefix, body) do
case storage(conn).get_collection_with_resources(target, :addressbook, book_name) do
nil ->
not_found(conn)
book ->
responses =
for href <- XML.parse_multiget_hrefs(body) do
res_name = String.replace_prefix(href, prefix, "")
case Map.get(book.resources, res_name) do
nil -> XML.response(href, [{"404 Not Found", [{"d:getetag", nil, nil}]}])
resource -> XML.response("#{prefix}#{resource.name}", resource_report_props(resource))
end
end
multistatus(conn, responses)
end
end
defp handle_query(conn, target, book_name, prefix, body) do
case storage(conn).get_collection_with_resources(target, :addressbook, book_name) do
nil ->
not_found(conn)
book ->
filter = XML.parse_addressbook_query(body)
responses =
book.resources
|> Map.values()
|> Enum.filter(&matches_filter?(&1, filter))
|> Enum.map(fn resource ->
XML.response("#{prefix}#{resource.name}", resource_report_props(resource))
end)
multistatus(conn, responses)
end
end
defp handle_sync_collection(conn, target, book_name, prefix, body) do
%{since: since, include_data: include_data?} = XML.parse_sync_collection(body)
case storage(conn).sync_changes(target, :addressbook, book_name, since) do
{:error, :not_found} ->
not_found(conn)
{changed, deleted, current_token} ->
changed_resps =
for resource <- changed do
XML.response(
"#{prefix}#{resource.name}",
[
{"200 OK",
if include_data? do
[
{"d:getetag", nil, resource.etag},
{"card:address-data", nil, resource.body}
]
else
[{"d:getetag", nil, resource.etag}]
end}
]
)
end
deleted_resps =
for name <- deleted do
XML.response_status("#{prefix}#{name}", "404 Not Found")
end
body_xml =
XML.multistatus(
changed_resps ++ deleted_resps,
[{"d:sync-token", nil, sync_token_value(current_token)}]
)
conn
|> put_resp_content_type("application/xml; charset=utf-8")
|> send_resp(207, body_xml)
|> halt()
end
end
defp matches_filter?(resource, %{fn_match: fn_match, email_match: email_match}) do
fn_ok =
case fn_match do
nil -> true
pat -> String.contains?(String.downcase(resource.body), String.downcase(pat))
end
email_ok =
case email_match do
nil -> true
pat -> String.contains?(String.downcase(resource.body), String.downcase(pat))
end
fn_ok and email_ok
end
# ---- GET / PUT / DELETE on resources -------------------------------------
defp handle_get_resource(conn, user, target, book_name, res_name) do
case authorize(conn, user, target) do
:ok ->
case storage(conn).get_resource(target, :addressbook, book_name, res_name) do
nil ->
not_found(conn)
resource ->
conn
|> put_resp_header("etag", resource.etag)
|> put_resp_content_type(resource.content_type)
|> send_resp(200, resource.body)
|> halt()
end
{:error, conn} ->
conn
end
end
defp handle_put_resource(conn, user, target, book_name, res_name) do
with :ok <- authorize(conn, user, target),
{:ok, body} <- fetch_body(conn) do
if_none_match = first_header(conn, "if-none-match")
if_match = first_header(conn, "if-match")
storage = storage(conn)
existing = storage.get_resource(target, :addressbook, book_name, res_name)
cond do
byte_size(body) > @max_resource_size ->
conn |> send_resp(413, "Resource too large") |> halt()
storage.get_collection(target, :addressbook, book_name) == nil ->
conn |> send_resp(404, "Addressbook not found") |> halt()
ExDav.VCard.validate(body) != :ok ->
conn |> send_resp(415, "Unsupported Media Type") |> halt()
if_none_match == "*" and existing != nil ->
conn |> send_resp(412, "Precondition Failed") |> halt()
is_binary(if_match) and (existing == nil or existing.etag != if_match) ->
conn |> send_resp(412, "Precondition Failed") |> halt()
true ->
case storage.put_resource(target, :addressbook, book_name, res_name, body) do
{:ok, resource} ->
status = if existing, do: 204, else: 201
conn
|> put_resp_header("etag", resource.etag)
|> send_resp(status, "")
|> halt()
{:error, _} ->
conn |> send_resp(409, "") |> halt()
end
end
else
{:error, conn} -> conn
end
end
defp handle_delete_resource(conn, user, target, book_name, res_name) do
case authorize(conn, user, target) do
:ok ->
if_match = first_header(conn, "if-match")
storage = storage(conn)
existing = storage.get_resource(target, :addressbook, book_name, res_name)
cond do
existing == nil ->
not_found(conn)
is_binary(if_match) and existing.etag != if_match ->
conn |> send_resp(412, "Precondition Failed") |> halt()
true ->
:ok = storage.delete_resource(target, :addressbook, book_name, res_name)
conn |> send_resp(204, "") |> halt()
end
{:error, conn} ->
conn
end
end
# ---- MKCOL / PROPPATCH / DELETE addressbook ------------------------------
defp handle_mkcol(conn, user, target, book_name) do
with :ok <- authorize(conn, user, target),
{:ok, body} <- fetch_body(conn) do
%{displayname: dn, description: desc} = XML.parse_mkcol(body)
case storage(conn).create_collection(target, :addressbook, book_name,
displayname: dn || book_name,
description: desc
) do
{:ok, _book} ->
conn |> send_resp(201, "") |> halt()
{:error, :already_exists} ->
conn |> send_resp(405, "Addressbook already exists") |> halt()
{:error, :no_user} ->
conn |> send_resp(404, "") |> halt()
end
else
{:error, conn} -> conn
end
end
defp handle_proppatch(conn, user, target, book_name) do
with :ok <- authorize(conn, user, target),
{:ok, body} <- fetch_body(conn) do
handle_proppatch_authorized(conn, target, book_name, body)
else
{:error, conn} -> conn
end
end
defp handle_proppatch_authorized(conn, target, book_name, body) do
pfx = url_prefix(conn)
%{displayname: dn, description: desc} = XML.parse_proppatch(body)
props =
[{:displayname, dn}, {:description, desc}]
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
case storage(conn).update_collection(target, :addressbook, book_name, props) do
{:ok, _book} ->
href = "#{pfx}/addressbooks/#{target}/#{book_name}/"
success_props =
Enum.map(props, fn
{:displayname, _} -> {"d:displayname", nil, nil}
{:description, _} -> {"card:addressbook-description", nil, nil}
end)
response = XML.response(href, [{"200 OK", success_props}])
multistatus(conn, [response])
{:error, :not_found} ->
not_found(conn)
end
end
defp handle_delete_addressbook(conn, user, target, book_name) do
case authorize(conn, user, target) do
:ok ->
storage = storage(conn)
case storage.get_collection(target, :addressbook, book_name) do
nil ->
not_found(conn)
_book ->
storage.delete_collection(target, :addressbook, book_name)
conn |> send_resp(204, "") |> halt()
end
{:error, conn} ->
conn
end
end
# ---- prop builders --------------------------------------------------------
defp build_root_response(conn, href, user) do
pfx = url_prefix(conn)
XML.response(href, [
{"200 OK",
[
{"d:resourcetype", nil, [{"d:collection", nil, nil}]},
{"d:displayname", nil, "ExDav"},
{"d:current-user-principal", nil,
[{"d:href", nil, "#{pfx}/principals/#{user}/"}]}
]}
])
end
defp principal_props(conn, target) do
pfx = url_prefix(conn)
[
{"200 OK",
[
{"d:resourcetype", nil,
[{"d:collection", nil, nil}, {"d:principal", nil, nil}]},
{"d:displayname", nil, target},
{"d:current-user-principal", nil,
[{"d:href", nil, "#{pfx}/principals/#{target}/"}]},
{"d:principal-URL", nil, [{"d:href", nil, "#{pfx}/principals/#{target}/"}]},
{"card:addressbook-home-set", nil,
[{"d:href", nil, "#{pfx}/addressbooks/#{target}/"}]},
current_user_privilege_set()
]}
]
end
defp current_user_privilege_set do
{"d:current-user-privilege-set", nil,
[
{"d:privilege", nil, [{"d:read", nil, nil}]},
{"d:privilege", nil, [{"d:write", nil, nil}]},
{"d:privilege", nil, [{"d:write-properties", nil, nil}]},
{"d:privilege", nil, [{"d:write-content", nil, nil}]},
{"d:privilege", nil, [{"d:bind", nil, nil}]},
{"d:privilege", nil, [{"d:unbind", nil, nil}]},
{"d:privilege", nil, [{"d:read-current-user-privilege-set", nil, nil}]},
{"d:privilege", nil, [{"d:read-acl", nil, nil}]},
{"d:privilege", nil, [{"d:all", nil, nil}]}
]}
end
defp sync_token_value(ctag), do: "urn:ex_dav:sync:#{ctag}"
defp addressbook_props(conn, target, book) do
pfx = url_prefix(conn)
[
{"200 OK",
[
{"d:resourcetype", nil,
[{"d:collection", nil, nil}, {"card:addressbook", nil, nil}]},
{"d:displayname", nil, book.displayname},
{"card:addressbook-description", nil, book.description || ""},
{"cs:getctag", nil, "#{book.ctag}"},
{"d:sync-token", nil, sync_token_value(book.ctag)},
{"d:owner", nil, [{"d:href", nil, "#{pfx}/principals/#{target}/"}]},
{"d:current-user-principal", nil,
[{"d:href", nil, "#{pfx}/principals/#{target}/"}]},
current_user_privilege_set(),
{"d:supported-report-set", nil,
[
{"d:supported-report", nil,
[{"d:report", nil, [{"card:addressbook-query", nil, nil}]}]},
{"d:supported-report", nil,
[{"d:report", nil, [{"card:addressbook-multiget", nil, nil}]}]},
{"d:supported-report", nil,
[{"d:report", nil, [{"d:sync-collection", nil, nil}]}]}
]}
]}
]
end
defp resource_propfind_props(resource) do
[
{"200 OK",
[
{"d:resourcetype", nil, nil},
{"d:getetag", nil, resource.etag},
{"d:getcontenttype", nil, "text/vcard; charset=utf-8"}
]}
]
end
defp resource_report_props(resource) do
[
{"200 OK",
[
{"d:getetag", nil, resource.etag},
{"card:address-data", nil, resource.body}
]}
]
end
# ---- response helpers -----------------------------------------------------
defp multistatus(conn, responses) do
body = XML.multistatus(responses)
conn
|> put_resp_content_type("application/xml; charset=utf-8")
|> send_resp(207, body)
|> halt()
end
defp not_found(conn) do
conn |> send_resp(404, "Not Found") |> halt()
end
defp forbidden(conn) do
conn |> send_resp(403, "Forbidden") |> halt()
end
defp depth(conn) do
case first_header(conn, "depth") do
"0" -> 0
"1" -> 1
"infinity" -> :infinity
_ -> 0
end
end
defp first_header(conn, name) do
case get_req_header(conn, name) do
[v | _] -> v
_ -> nil
end
end
end