"""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"]