Files
webmail-client/backend/app/dav/routes.py
T

596 lines
23 KiB
Python
Raw Normal View History

"""The CalDAV / CardDAV endpoints (SPEC Appendix E).
/.well-known/caldav, /.well-known/carddav → 301 /dav/
/dav/ root: points at the principal
/dav/principals/{user}/ the signed-in user
/dav/calendars/{user}/[{calendar}/[{name}]] calendar home, calendars, events
/dav/addressbooks/{user}/[{book}/[{name}]] address-book home, books, contacts
The data comes from the plugins' providers (app/dav/provider.py): the calendar
plugin's for ``calendars``, the contacts plugin's for ``addressbooks``. Signed
in with HTTP Basic and an app password only (app/dav/auth.py); session cookies
are never looked at, so a web page cannot drive these endpoints.
"""
from __future__ import annotations
import logging
import uuid
import xml.etree.ElementTree as ET
from typing import Any
from urllib.parse import quote, unquote, urlsplit
from fastapi import APIRouter, Request, Response
from fastapi.responses import RedirectResponse
from sqlalchemy import select
from app.dav import auth
from app.dav.provider import Collection, DavError, DavProvider, Resource
from app.dav.xml import (
CR,
CS,
A,
BadRequest,
C,
D,
Multistatus,
PropValue,
el,
error_body,
href,
mkcol_props,
parse,
propfind_request,
proppatch_request,
q,
report_request,
)
from app.db import session_scope
from app.models import Account, User
logger = logging.getLogger(__name__)
router = APIRouter()
METHODS = [
"OPTIONS",
"PROPFIND",
"PROPPATCH",
"REPORT",
"GET",
"HEAD",
"PUT",
"DELETE",
"MKCALENDAR",
"MKCOL",
]
DAV_HEADER = "1, 3, calendar-access, addressbook, extended-mkcol"
XML_TYPE = "application/xml; charset=utf-8"
HOMES = {"calendars": "calendar", "addressbooks": "addressbook"}
SYNC_PREFIX = "http://ultimo.email/ns/sync/"
def _providers() -> dict[str, DavProvider]:
from app.plugins import loader
out: dict[str, DavProvider] = {}
for plugin in loader.get_plugins():
if plugin.dav is not None:
out[plugin.dav.kind] = plugin.dav
return out
# ---- entry points ------------------------------------------------------------------
@router.api_route("/.well-known/caldav", methods=["GET", "HEAD", "PROPFIND", "OPTIONS"])
@router.api_route("/.well-known/carddav", methods=["GET", "HEAD", "PROPFIND", "OPTIONS"])
async def well_known() -> Response:
return RedirectResponse("/dav/", status_code=301)
@router.api_route("/dav", methods=METHODS)
@router.api_route("/dav/{path:path}", methods=METHODS)
async def dav(request: Request, path: str = "") -> Response:
method = request.method.upper()
if method == "OPTIONS":
return Response(
status_code=200,
headers={"DAV": DAV_HEADER, "Allow": ", ".join(METHODS), "Content-Length": "0"},
)
ip = request.client.host if request.client else ""
try:
user = await auth.authenticate(request.headers.get("authorization"), ip)
except auth.Throttled:
return Response(status_code=429, headers={"Retry-After": "900"})
if user is None:
return Response(
status_code=401,
headers={"WWW-Authenticate": 'Basic realm="Ultimo", charset="UTF-8"'},
)
parts = [unquote(p) for p in path.split("/") if p]
ctx = _Ctx(request, user, parts)
try:
return await ctx.handle(method)
except BadRequest as err:
return Response(status_code=400, content=str(err))
except DavError as err:
body = error_body(err.condition) if err.condition else b""
return Response(
status_code=err.status,
content=body,
media_type=XML_TYPE if body else None,
)
class _Ctx:
def __init__(self, request: Request, user: User, parts: list[str]) -> None:
self.request = request
self.user = user
self.uid = str(user.id)
self.parts = parts
self.providers = _providers()
# -- paths -----------------------------------------------------------------------
def principal(self) -> str:
return f"/dav/principals/{self.uid}/"
def home(self, segment: str) -> str:
return f"/dav/{segment}/{self.uid}/"
def coll_href(self, segment: str, coll_id: str) -> str:
return f"{self.home(segment)}{quote(coll_id, safe='')}/"
def res_href(self, segment: str, coll_id: str, name: str) -> str:
return f"{self.coll_href(segment, coll_id)}{quote(name, safe='')}"
# -- dispatch --------------------------------------------------------------------
async def handle(self, method: str) -> Response:
p = self.parts
if not p:
return await self.root(method)
if p[0] == "principals":
if len(p) != 2 or p[1] != self.uid:
raise DavError(404)
return await self.principal_req(method)
if p[0] in HOMES and len(p) >= 2:
if p[1] != self.uid:
raise DavError(403)
provider = self.providers.get(HOMES[p[0]])
if provider is None:
raise DavError(404)
if len(p) == 2:
return await self.home_req(method, p[0], provider)
if len(p) == 3:
return await self.collection_req(method, p[0], provider, p[2])
if len(p) == 4:
return await self.resource_req(method, p[0], provider, p[2], p[3])
raise DavError(404)
async def body(self) -> ET.Element | None:
return parse(await self.request.body())
def depth(self) -> int:
value = (self.request.headers.get("depth") or "1").strip().lower()
return 0 if value == "0" else 1
@staticmethod
def multistatus(ms: Multistatus) -> Response:
return Response(status_code=207, content=ms.to_bytes(), media_type=XML_TYPE)
async def respond_props(
self, items: list[tuple[str, dict[str, PropValue]]], method_root: ET.Element | None
) -> Response:
req = propfind_request(method_root)
ms = Multistatus()
for path, props in items:
if req.names_only:
ms.response(path, dict.fromkeys(props))
elif req.names is None:
ms.response(path, props)
else:
found = {n: props[n] for n in req.names if n in props}
missing = [n for n in req.names if n not in props]
ms.response(path, found, missing)
return self.multistatus(ms)
# -- root and principal ----------------------------------------------------------
def common(self) -> dict[str, PropValue]:
return {
q(D, "current-user-principal"): [href(self.principal())],
q(D, "principal-URL"): [href(self.principal())],
q(D, "owner"): [href(self.principal())],
}
async def root(self, method: str) -> Response:
if method != "PROPFIND":
return Response(status_code=200 if method in ("GET", "HEAD") else 405)
props = {**self.common(), q(D, "resourcetype"): [el(q(D, "collection"))]}
return await self.respond_props([("/dav/", props)], await self.body())
async def addresses(self) -> list[str]:
async with session_scope() as db:
emails = list(
await db.scalars(select(Account.email).where(Account.user_id == self.user.id))
)
out = [self.user.handle, *emails]
return list(dict.fromkeys(e.lower() for e in out))
async def principal_props(self) -> dict[str, PropValue]:
props = {
**self.common(),
q(D, "resourcetype"): [el(q(D, "principal")), el(q(D, "collection"))],
q(D, "displayname"): self.user.handle,
q(C, "calendar-user-address-set"): [
href(f"mailto:{a}") for a in await self.addresses()
],
q(C, "calendar-user-type"): "INDIVIDUAL",
q(D, "principal-collection-set"): [href("/dav/principals/")],
q(D, "supported-report-set"): [],
}
if "calendar" in self.providers:
props[q(C, "calendar-home-set")] = [href(self.home("calendars"))]
if "addressbook" in self.providers:
props[q(CR, "addressbook-home-set")] = [href(self.home("addressbooks"))]
return props
async def principal_req(self, method: str) -> Response:
if method == "PROPFIND":
return await self.respond_props(
[(self.principal(), await self.principal_props())], await self.body()
)
if method == "REPORT": # principal-property-search, expand-property: nothing to add
return self.multistatus(Multistatus())
return Response(status_code=405)
# -- homes -------------------------------------------------------------------------
def privileges(self, read_only: bool) -> list[ET.Element]:
names = ["read", "read-current-user-privilege-set"]
if not read_only:
names += ["write", "write-content", "write-properties", "bind", "unbind"]
return [el(q(D, "privilege"), children=[el(q(D, n))]) for n in names]
def reports(self, kind: str) -> list[ET.Element]:
ns, reports = (
(C, ["calendar-query", "calendar-multiget"])
if kind == "calendar"
else (CR, ["addressbook-query", "addressbook-multiget"])
)
items = [(ns, r) for r in reports] + [(D, "sync-collection")]
return [
el(q(D, "supported-report"), children=[el(q(D, "report"), children=[el(q(n, r))])])
for n, r in items
]
async def home_req(self, method: str, segment: str, provider: DavProvider) -> Response:
if method == "PROPFIND":
home_props: dict[str, PropValue] = {
**self.common(),
q(D, "resourcetype"): [el(q(D, "collection"))],
q(D, "displayname"): "Calendars" if provider.kind == "calendar" else "Contacts",
q(D, "current-user-privilege-set"): self.privileges(False),
}
items = [(self.home(segment), home_props)]
if self.depth():
for coll in await provider.collections(self.user.id):
items.append(
(self.coll_href(segment, coll.id), self.collection_props(provider, coll))
)
return await self.respond_props(items, await self.body())
if method == "REPORT":
return self.multistatus(Multistatus())
if method in ("GET", "HEAD"):
return Response(status_code=200)
return Response(status_code=405)
# -- collections -------------------------------------------------------------------
def collection_props(self, provider: DavProvider, coll: Collection) -> dict[str, PropValue]:
token = f"{SYNC_PREFIX}{coll.sync_token}"
props: dict[str, PropValue] = {
**self.common(),
q(D, "displayname"): coll.displayname,
q(D, "current-user-privilege-set"): self.privileges(coll.read_only),
q(D, "supported-report-set"): self.reports(provider.kind),
q(D, "sync-token"): token,
q(CS, "getctag"): str(coll.sync_token),
}
if provider.kind == "calendar":
props[q(D, "resourcetype")] = [el(q(D, "collection")), el(q(C, "calendar"))]
props[q(C, "supported-calendar-component-set")] = [
el(q(C, "comp"), attrs={"name": comp}) for comp in coll.components
]
props[q(C, "supported-calendar-data")] = [
el(q(C, "calendar-data"), attrs={"content-type": "text/calendar", "version": "2.0"})
]
props[q(C, "calendar-description")] = coll.description
if coll.color:
props[q(A, "calendar-color")] = coll.color
else:
props[q(D, "resourcetype")] = [el(q(D, "collection")), el(q(CR, "addressbook"))]
props[q(CR, "supported-address-data")] = [
el(
q(CR, "address-data-type"),
attrs={"content-type": "text/vcard", "version": "3.0"},
),
el(
q(CR, "address-data-type"),
attrs={"content-type": "text/vcard", "version": "4.0"},
),
]
props[q(CR, "addressbook-description")] = coll.description
return props
async def find(self, provider: DavProvider, coll_id: str) -> Collection:
for coll in await provider.collections(self.user.id):
if coll.id == coll_id:
return coll
raise DavError(404)
def resource_props(
self, provider: DavProvider, res: Resource, with_data: bool
) -> dict[str, PropValue]:
props: dict[str, PropValue] = {
q(D, "getetag"): f'"{res.etag}"',
q(D, "getcontenttype"): provider.content_type,
q(D, "resourcetype"): [],
}
if with_data and res.data is not None:
data_prop = (
q(C, "calendar-data") if provider.kind == "calendar" else q(CR, "address-data")
)
props[data_prop] = res.data
return props
async def collection_req(
self, method: str, segment: str, provider: DavProvider, coll_id: str
) -> Response:
if method == "MKCALENDAR" or method == "MKCOL":
return await self.make_collection(segment, provider, coll_id)
coll = await self.find(provider, coll_id)
if method == "PROPFIND":
items = [(self.coll_href(segment, coll.id), self.collection_props(provider, coll))]
if self.depth():
for res in await provider.resources(self.user.id, coll.id):
items.append(
(
self.res_href(segment, coll.id, res.name),
self.resource_props(provider, res, False),
)
)
return await self.respond_props(items, await self.body())
if method == "REPORT":
return await self.report(segment, provider, coll)
if method == "PROPPATCH":
return await self.proppatch(segment, provider, coll)
if method == "DELETE":
if coll.read_only:
raise DavError(403)
await provider.delete_collection(self.user.id, coll.id)
return Response(status_code=204)
if method in ("GET", "HEAD"):
return Response(status_code=200)
return Response(status_code=405)
async def make_collection(self, segment: str, provider: DavProvider, coll_id: str) -> Response:
if any(c.id == coll_id for c in await provider.collections(self.user.id)):
raise DavError(405) # exists already
root = await self.body()
props = mkcol_props(root)
rtype = props.get(q(D, "resourcetype"))
if self.request.method.upper() == "MKCOL" and (
rtype is None
or rtype.find(q(CR, "addressbook")) is None
or provider.kind != "addressbook"
):
raise DavError(403)
if self.request.method.upper() == "MKCALENDAR" and provider.kind != "calendar":
raise DavError(403)
name_el = props.get(q(D, "displayname"))
color_el = props.get(q(A, "calendar-color"))
await provider.create_collection(
self.user.id,
coll_id,
(name_el.text or "").strip() if name_el is not None else "",
(color_el.text or "").strip() if color_el is not None else None,
)
return Response(status_code=201)
async def proppatch(self, segment: str, provider: DavProvider, coll: Collection) -> Response:
patch = proppatch_request(await self.body())
if coll.read_only:
raise DavError(403)
values: dict[str, Any] = {}
for name, value in patch.set.items():
text = (value.text or "").strip()
if name == q(D, "displayname"):
values["displayname"] = text
elif name == q(A, "calendar-color"):
values["color"] = text
elif name in (q(C, "calendar-description"), q(CR, "addressbook-description")):
values["description"] = text
if values:
await provider.update_collection(self.user.id, coll.id, **values)
# Properties not kept (calendar order, …) are accepted and ignored:
# clients treat a refusal as a failed sync.
ms = Multistatus()
ms.response(self.coll_href(segment, coll.id), dict.fromkeys([*patch.set, *patch.remove]))
return self.multistatus(ms)
async def report(self, segment: str, provider: DavProvider, coll: Collection) -> Response:
root = await self.body()
if root is None:
raise BadRequest("empty report")
rep = report_request(root)
wants_data = rep.props is None or any(
p in (q(C, "calendar-data"), q(CR, "address-data")) for p in rep.props
)
ms = Multistatus()
def add(res: Resource) -> None:
props = self.resource_props(provider, res, wants_data)
if rep.props is None:
ms.response(self.res_href(segment, coll.id, res.name), props)
else:
found = {n: props[n] for n in rep.props if n in props}
missing = [n for n in rep.props if n not in props]
ms.response(self.res_href(segment, coll.id, res.name), found, missing)
if rep.kind in ("calendar-multiget", "addressbook-multiget"):
names = [_last_segment(h) for h in rep.hrefs]
got = {r.name: r for r in await provider.get(self.user.id, coll.id, names)}
for h, name in zip(rep.hrefs, names, strict=True):
if name in got:
add(got[name])
else:
ms.response(h, {}, status=404)
return self.multistatus(ms)
if rep.kind in ("calendar-query", "addressbook-query"):
names_q = (
await provider.query(self.user.id, coll.id, rep.start, rep.end)
if rep.kind == "calendar-query"
else None
)
for res in (
await provider.get(self.user.id, coll.id, names_q)
if wants_data
else (
await provider.resources(self.user.id, coll.id)
if names_q is None
else [
r
for r in await provider.resources(self.user.id, coll.id)
if r.name in set(names_q)
]
)
):
add(res)
return self.multistatus(ms)
# sync-collection (RFC 6578)
since = _token_number(rep.sync_token)
if rep.sync_token and since is None:
raise DavError(403, condition=q(D, "valid-sync-token"))
if since is None:
changed = None
deleted: list[str] = []
token = coll.sync_token
else:
changes = await provider.changes(self.user.id, coll.id, since)
if changes is None:
raise DavError(403, condition=q(D, "valid-sync-token"))
changed, deleted, token = changes.changed, changes.deleted, changes.sync_token
if changed is None:
resources = (
await provider.get(self.user.id, coll.id, None)
if wants_data
else await provider.resources(self.user.id, coll.id)
)
elif changed:
resources = (
await provider.get(self.user.id, coll.id, changed)
if wants_data
else [
r
for r in await provider.resources(self.user.id, coll.id)
if r.name in set(changed)
]
)
else:
resources = []
present = {r.name for r in resources}
for res in resources:
add(res)
for name in deleted:
if name not in present:
ms.response(self.res_href(segment, coll.id, name), {}, status=404)
ms.sync_token(f"{SYNC_PREFIX}{token}")
return self.multistatus(ms)
# -- resources ---------------------------------------------------------------------
async def resource_req(
self, method: str, segment: str, provider: DavProvider, coll_id: str, name: str
) -> Response:
coll = await self.find(provider, coll_id)
if method in ("GET", "HEAD"):
got = await provider.get(self.user.id, coll.id, [name])
if not got:
raise DavError(404)
res = got[0]
content = (res.data or "").encode() if method == "GET" else b""
return Response(
status_code=200,
content=content,
media_type=provider.content_type,
headers={"ETag": f'"{res.etag}"'},
)
if method == "PROPFIND":
got = await provider.get(self.user.id, coll.id, [name])
if not got:
raise DavError(404)
return await self.respond_props(
[
(
self.res_href(segment, coll.id, name),
self.resource_props(provider, got[0], True),
)
],
await self.body(),
)
if method == "PUT":
if coll.read_only:
raise DavError(403, condition=q(D, "need-privileges"))
data = (await self.request.body()).decode("utf-8", "replace")
etag, created = await provider.put(
self.user.id,
coll.id,
name,
data,
if_match=_etag_header(self.request.headers.get("if-match")),
if_none_match=(self.request.headers.get("if-none-match") or "").strip() == "*",
)
return Response(status_code=201 if created else 204, headers={"ETag": f'"{etag}"'})
if method == "DELETE":
if coll.read_only:
raise DavError(403)
await provider.delete(
self.user.id,
coll.id,
name,
if_match=_etag_header(self.request.headers.get("if-match")),
)
return Response(status_code=204)
return Response(status_code=405)
def _last_segment(h: str) -> str:
path = urlsplit(h).path
return unquote(path.rstrip("/").rsplit("/", 1)[-1])
def _token_number(token: str) -> int | None:
if not token:
return None
tail = token.rsplit("/", 1)[-1]
return int(tail) if tail.isdigit() else None
def _etag_header(value: str | None) -> str | None:
if not value:
return None
value = value.strip()
if value == "*":
return "*"
if value.startswith("W/"):
value = value[2:]
return value.strip('"')
__all__ = ["router", "uuid"]