Current section
Files
Jump to
Current section
Files
lib/ex_dav/storage/postgres.ex
defmodule ExDav.Storage.Postgres do
@moduledoc """
Postgres-backed implementation of `ExDav.Storage`. Owns its own users,
collections, resources, and tombstones tables; the Repo module is
configured via `config :ex_dav, repo: MyApp.Repo`.
Returns plain maps to keep the DAV plugs agnostic of Ecto schemas.
Each collection is shaped as `%{name, displayname, description, kind,
properties, ctag, resources}` and each resource as
`%{name, body, content_type, etag, uid}`.
"""
@behaviour ExDav.Storage
import Ecto.Query
alias ExDav.Storage.Postgres.Schemas.{Collection, Resource, Tombstone, User}
defp repo, do: Application.fetch_env!(:ex_dav, :repo)
# ---- users ----------------------------------------------------------------
@doc "Verify a username/password pair against the users table. Postgres-only."
def authenticate(username, password) do
case repo().get_by(User, username: username) do
nil -> false
%User{password: ^password} -> true
_ -> false
end
end
@impl true
def user_exists?(username) do
repo().exists?(from u in User, where: u.username == ^username)
end
@doc "Postgres-only admin helper."
def get_user(username), do: repo().get_by(User, username: username)
@doc "Postgres-only admin helper."
def list_users do
User |> order_by(asc: :username) |> repo().all()
end
@doc "Postgres-only admin helper."
def delete_user(username) do
case repo().get_by(User, username: username) do
nil -> {:error, :not_found}
user ->
repo().delete(user)
:ok
end
end
@doc "Postgres-only admin helper."
def count_resources(collection_name, username, kind \\ :calendar) do
case collection_id(username, kind, collection_name) do
nil -> 0
col_id -> repo().aggregate(from(r in Resource, where: r.collection_id == ^col_id), :count, :id)
end
end
@doc "Postgres-only admin helper."
def upsert_user(username, password, opts \\ []) do
attrs = %{
username: username,
password: password,
display_name: Keyword.get(opts, :display_name, username)
}
case repo().get_by(User, username: username) do
nil -> %User{} |> User.changeset(attrs) |> repo().insert()
user -> user |> User.changeset(attrs) |> repo().update()
end
end
# ---- collections ----------------------------------------------------------
@impl true
def list_collections(username, kind) do
case get_user(username) do
nil ->
[]
%User{id: id} ->
Collection
|> where([c], c.user_id == ^id and c.kind == ^kind)
|> order_by([c], asc: c.name)
|> repo().all()
|> Enum.map(&to_collection_map(&1, false))
end
end
@impl true
def get_collection(username, kind, name) do
case get_user(username) do
nil ->
nil
%User{id: id} ->
case repo().get_by(Collection, user_id: id, name: name, kind: kind) do
nil -> nil
col -> to_collection_map(col, false)
end
end
end
@impl true
def get_collection_with_resources(username, kind, name) do
case get_user(username) do
nil ->
nil
%User{id: id} ->
Collection
|> where([c], c.user_id == ^id and c.name == ^name and c.kind == ^kind)
|> preload(:resources)
|> repo().one()
|> case do
nil -> nil
col -> to_collection_map(col, true)
end
end
end
@impl true
def create_collection(username, kind, name, opts) do
case get_user(username) do
nil ->
{:error, :no_user}
%User{id: id} ->
attrs = %{
user_id: id,
name: name,
kind: kind,
display_name: Keyword.get(opts, :displayname, name),
description: Keyword.get(opts, :description),
properties: Keyword.get(opts, :properties, default_properties(kind))
}
case %Collection{} |> Collection.changeset(attrs) |> repo().insert() do
{:ok, col} ->
{:ok, to_collection_map(col, false)}
{:error, %Ecto.Changeset{errors: errors}} ->
if Enum.any?(errors, fn {_, {_msg, eopts}} -> eopts[:constraint] == :unique end) do
{:error, :already_exists}
else
{:error, :invalid}
end
end
end
end
@impl true
def update_collection(username, kind, name, props) do
with %User{id: id} <- get_user(username),
%Collection{} = col <- repo().get_by(Collection, user_id: id, name: name, kind: kind) do
attrs =
Enum.reduce(props, %{}, fn
{:displayname, v}, acc -> Map.put(acc, :display_name, v)
{:description, v}, acc -> Map.put(acc, :description, v)
_, acc -> acc
end)
col
|> Collection.update_props_changeset(attrs)
|> Ecto.Changeset.put_change(:ctag, col.ctag + 1)
|> repo().update()
|> case do
{:ok, updated} -> {:ok, to_collection_map(updated, false)}
{:error, _} -> {:error, :invalid}
end
else
_ -> {:error, :not_found}
end
end
@impl true
def delete_collection(username, kind, name) do
with %User{id: id} <- get_user(username),
%Collection{} = col <- repo().get_by(Collection, user_id: id, name: name, kind: kind) do
repo().delete(col)
:ok
else
_ -> {:error, :not_found}
end
end
# ---- resources ------------------------------------------------------------
@doc "Postgres-only helper. The plug uses `get_collection_with_resources/3` instead."
def list_resources(username, kind, col_name) do
case collection_id(username, kind, col_name) do
nil ->
[]
col_id ->
Resource
|> where([r], r.collection_id == ^col_id)
|> repo().all()
|> Enum.map(&to_resource_map/1)
end
end
@impl true
def get_resource(username, kind, col_name, res_name) do
case collection_id(username, kind, col_name) do
nil ->
nil
col_id ->
case repo().get_by(Resource, collection_id: col_id, name: res_name) do
nil -> nil
res -> to_resource_map(res)
end
end
end
@impl true
def put_resource(username, kind, col_name, res_name, body) do
with %User{id: uid} <- get_user(username),
%Collection{} = col <- repo().get_by(Collection, user_id: uid, name: col_name, kind: kind) do
{res_uid, component} = summarize_body(kind, body)
etag = ExDav.ICal.etag(body)
new_version = col.ctag + 1
attrs = %{
collection_id: col.id,
name: res_name,
uid: res_uid,
component: component,
body: body,
content_type: content_type_for(kind),
etag: etag,
version: new_version
}
changeset =
case repo().get_by(Resource, collection_id: col.id, name: res_name) do
nil -> Resource.changeset(%Resource{}, attrs)
existing -> Resource.changeset(existing, attrs)
end
repo().transaction(fn ->
case repo().insert_or_update(changeset) do
{:ok, res} ->
col
|> Ecto.Changeset.change(ctag: new_version)
|> repo().update!()
repo().delete_all(
from t in Tombstone,
where: t.collection_id == ^col.id and t.name == ^res_name
)
to_resource_map(res)
{:error, _} ->
repo().rollback(:invalid)
end
end)
|> case do
{:ok, res} -> {:ok, res}
{:error, reason} -> {:error, reason}
end
else
_ -> {:error, :not_found}
end
end
@impl true
def delete_resource(username, kind, col_name, res_name) do
with %User{id: uid} <- get_user(username),
%Collection{} = col <- repo().get_by(Collection, user_id: uid, name: col_name, kind: kind),
%Resource{} = res <- repo().get_by(Resource, collection_id: col.id, name: res_name) do
new_version = col.ctag + 1
repo().transaction(fn ->
repo().delete!(res)
col
|> Ecto.Changeset.change(ctag: new_version)
|> repo().update!()
%Tombstone{}
|> Tombstone.changeset(%{
collection_id: col.id,
name: res_name,
version: new_version
})
|> repo().insert(
on_conflict: [set: [version: new_version]],
conflict_target: [:collection_id, :name]
)
:ok
end)
|> case do
{:ok, :ok} -> :ok
other -> other
end
else
_ -> {:error, :not_found}
end
end
@impl true
def sync_changes(username, kind, col_name, since_token) do
with %User{id: uid} <- get_user(username),
%Collection{id: col_id, ctag: current} <-
repo().get_by(Collection, user_id: uid, name: col_name, kind: kind) do
changed =
case since_token do
nil ->
repo().all(from r in Resource, where: r.collection_id == ^col_id)
v when is_integer(v) ->
repo().all(
from r in Resource,
where: r.collection_id == ^col_id and r.version > ^v
)
end
deleted =
case since_token do
nil ->
[]
v when is_integer(v) ->
repo().all(
from t in Tombstone,
where: t.collection_id == ^col_id and t.version > ^v,
select: t.name
)
end
{Enum.map(changed, &to_resource_map/1), deleted, current}
else
_ -> {:error, :not_found}
end
end
# ---- internals ------------------------------------------------------------
defp collection_id(username, kind, col_name) do
case get_user(username) do
nil ->
nil
%User{id: id} ->
Collection
|> where([c], c.user_id == ^id and c.name == ^col_name and c.kind == ^kind)
|> select([c], c.id)
|> repo().one()
end
end
defp to_collection_map(%Collection{} = c, with_resources?) do
%{
name: c.name,
displayname: c.display_name || c.name,
description: c.description,
kind: c.kind,
properties: c.properties || %{},
ctag: c.ctag,
resources:
if with_resources? do
for r <- c.resources, into: %{}, do: {r.name, to_resource_map(r)}
else
%{}
end
}
end
defp to_resource_map(%Resource{} = r) do
%{
name: r.name,
body: r.body,
content_type: r.content_type || "text/calendar; charset=utf-8",
etag: r.etag,
uid: r.uid,
component: r.component
}
end
defp default_properties(:calendar), do: %{"components" => ["VEVENT", "VTODO"]}
defp default_properties(:addressbook), do: %{}
defp content_type_for(:calendar), do: "text/calendar; charset=utf-8"
defp content_type_for(:addressbook), do: "text/vcard; charset=utf-8"
defp summarize_body(:calendar, body), do: ExDav.ICal.summarize(body)
defp summarize_body(:addressbook, body), do: {ExDav.VCard.extract_uid(body), nil}
end