App passwords; CalDAV / CardDAV server for phones, Thunderbird, macOS

App passwords (SPEC App. D §D.5): one generated password per app, shown once,
revocable on its own, revoked on a password reset; never accepted by the web
sign-in. Settings → Security → App passwords (migration 0024).

CalDAV / CardDAV server (SPEC Appendix E), built in: /dav/ with
/.well-known/caldav|carddav, HTTP Basic with app passwords only (cached checks,
throttled failures). The calendar and contacts plugins serve their own tables as
collections (Plugin(dav=...)):
- calendars as iCalendar: series with overrides, time zones, all-day, attendees
  and RSVP, alarms <-> reminders; MKCALENDAR / PROPPATCH / DELETE of calendars;
  subscriptions read-only; writes to upstream-synced calendars flow on upstream;
- address books as vCard (3.0 served, 3.0/4.0 read), groups as CATEGORIES;
- what a client stores is served back verbatim until the web app edits it;
- sync tokens / CTags from change logs filled by triggers (calendar0008,
  contacts0002), so every write path counts; trimmed nightly after 90 days.
REPORTs: calendar-query (time range), calendar-/addressbook-multiget,
addressbook-query, sync-collection. nginx and the Vite proxy route /dav/.
Settings shows the server and user name to enter; install guide §5.4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 21:49:04 +02:00
co-authored by Claude Opus 5.5
parent fa552392c8
commit 0e3793a4d6
30 changed files with 3968 additions and 4 deletions
+1
View File
@@ -0,0 +1 @@
"""CalDAV / CardDAV server (SPEC Appendix E)."""
+93
View File
@@ -0,0 +1,93 @@
"""Signing in a DAV client: HTTP Basic with an app password (SPEC App. E.2).
Only app passwords: the account password (with or without 2FA) never works
here, so a lost phone is one revocation away and 2FA cannot be bypassed. A
sync makes many requests, each with the credentials; a successful check is
remembered for a few minutes under a hash of the password, and forgotten as
soon as the user's app passwords change (``app_passwords`` bumps a counter).
"""
from __future__ import annotations
import base64
import binascii
import datetime as dt
import hashlib
import uuid
from sqlalchemy import select, update
from app.db import session_scope
from app.identity import app_passwords
from app.identity.service import resolve_handle
from app.models import User, UserAppPassword
from app.redis_client import get_redis
CACHE_S = 300
FAIL_WINDOW_S = 900
MAX_FAILS_PER_ADDRESS = 10
MAX_FAILS_PER_IP = 30
_LAST_USED_EVERY = dt.timedelta(minutes=5)
class Throttled(Exception):
"""Too many failed sign-ins recently."""
def _credentials(header: str | None) -> tuple[str, str] | None:
if not header or not header.lower().startswith("basic "):
return None
try:
decoded = base64.b64decode(header[6:].strip(), validate=True).decode("utf-8")
except (binascii.Error, UnicodeDecodeError):
return None
login, sep, password = decoded.partition(":")
return (login.strip(), password) if sep and login.strip() else None
async def authenticate(header: str | None, ip: str) -> User | None:
"""The user these Basic credentials sign in, or None. Raises Throttled."""
creds = _credentials(header)
if creds is None:
return None
login, password = creds
redis = get_redis()
handle = resolve_handle(login)
fail_keys = (f"dav:fail:addr:{handle}", f"dav:fail:ip:{ip}")
fails = [int(await redis.get(k) or 0) for k in fail_keys]
if fails[0] >= MAX_FAILS_PER_ADDRESS or fails[1] >= MAX_FAILS_PER_IP:
raise Throttled
async with session_scope() as db:
user = await db.scalar(select(User).where(User.handle == handle))
if user is None or user.blocked_at is not None:
await _failed(fail_keys)
return None
gen = int(await redis.get(app_passwords.generation_key(user.id)) or 0)
digest = hashlib.sha256(f"{user.id}:{password}".encode()).hexdigest()
cache_key = f"dav:auth:{user.id}:{gen}:{digest}"
if await redis.get(cache_key):
return user
row = await app_passwords.verify(db, user.id, password)
if row is None:
await _failed(fail_keys)
return None
await redis.set(cache_key, str(row.id), ex=CACHE_S)
now = dt.datetime.now(dt.UTC)
if row.last_used_at is None or now - row.last_used_at > _LAST_USED_EVERY:
await db.execute(
update(UserAppPassword).where(UserAppPassword.id == row.id).values(last_used_at=now)
)
db.expunge(user)
return user
async def _failed(keys: tuple[str, ...]) -> None:
redis = get_redis()
for key in keys:
await redis.incr(key)
await redis.expire(key, FAIL_WINDOW_S)
def user_id_of(user: User) -> uuid.UUID:
return user.id
+118
View File
@@ -0,0 +1,118 @@
"""What a plugin implements to serve its collections over DAV (SPEC App. E.1).
The core (app/dav/routes.py) speaks the protocol; a provider only knows its
own data: collections (calendars, address books) and the resources in them
(one iCalendar object / one vCard each), named, with an ETag. Every method
gets the signed-in user's id; providers open the user's RLS session
(``sdk(name).session(user_id)``) themselves.
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol
class DavError(Exception):
"""A request the provider refuses: ``status`` is the HTTP answer
(403 read-only, 404 missing, 409 conflict, 412 precondition failed, 415
unsupported data); ``condition`` an optional DAV precondition element
(e.g. ``{urn:ietf:params:xml:ns:caldav}valid-calendar-data``)."""
def __init__(self, status: int, message: str = "", condition: str | None = None) -> None:
super().__init__(message or str(status))
self.status = status
self.condition = condition
@dataclass
class Collection:
id: str
displayname: str
sync_token: int # the latest change number; also the CTag
description: str = ""
color: str | None = None # "#rrggbb"
read_only: bool = False
components: list[str] = field(default_factory=lambda: ["VEVENT"]) # calendars only
@dataclass
class Resource:
name: str # last path segment, e.g. "abc.ics"
etag: str # without quotes
data: str | None = None # the iCalendar / vCard text, when asked for
@dataclass
class Changes:
sync_token: int
changed: list[str] # resource names changed or added since the old token
deleted: list[str] # resource names gone since the old token
class DavProvider(Protocol):
kind: Literal["calendar", "addressbook"]
content_type: str # "text/calendar; charset=utf-8" / "text/vcard; charset=utf-8"
extension: str # ".ics" / ".vcf"
async def collections(self, user_id: uuid.UUID) -> list[Collection]: ...
async def resources(self, user_id: uuid.UUID, collection_id: str) -> list[Resource]: ...
async def get(
self, user_id: uuid.UUID, collection_id: str, names: list[str] | None
) -> list[Resource]: ...
async def put(
self,
user_id: uuid.UUID,
collection_id: str,
name: str,
data: str,
*,
if_match: str | None,
if_none_match: bool,
) -> tuple[str, bool]:
"""Store ``data`` as ``name``; returns (new ETag, created)."""
...
async def delete(
self, user_id: uuid.UUID, collection_id: str, name: str, *, if_match: str | None
) -> None: ...
async def changes(self, user_id: uuid.UUID, collection_id: str, since: int) -> Changes | None:
"""What changed after change number ``since``; None when that token is
too old to answer (the client then resyncs from scratch)."""
...
async def create_collection(
self, user_id: uuid.UUID, collection_id: str, displayname: str, color: str | None
) -> None:
"""Create a collection at the id the client chose (MKCALENDAR / MKCOL);
DavError(403) when that id cannot be used."""
...
async def update_collection(
self,
user_id: uuid.UUID,
collection_id: str,
*,
displayname: str | None = None,
color: str | None = None,
description: str | None = None,
) -> None: ...
async def delete_collection(self, user_id: uuid.UUID, collection_id: str) -> None: ...
async def query(
self,
user_id: uuid.UUID,
collection_id: str,
start: datetime | None,
end: datetime | None,
) -> list[str] | None:
"""Calendars: names of resources with an occurrence overlapping the
range; None when the provider cannot filter (the core then returns all)."""
...
+595
View File
@@ -0,0 +1,595 @@
"""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"]
+236
View File
@@ -0,0 +1,236 @@
"""WebDAV XML: reading request bodies, writing multistatus answers (RFC 4918,
4791, 6352, 6578). Properties are ``{namespace}name`` strings, as ElementTree
names them."""
from __future__ import annotations
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from datetime import UTC, datetime
from defusedxml import ElementTree as SafeET
D = "DAV:"
C = "urn:ietf:params:xml:ns:caldav"
CR = "urn:ietf:params:xml:ns:carddav"
CS = "http://calendarserver.org/ns/"
A = "http://apple.com/ns/ical/"
for _prefix, _ns in (("d", D), ("cal", C), ("card", CR), ("cs", CS), ("ical", A)):
ET.register_namespace(_prefix, _ns)
def q(ns: str, name: str) -> str:
return f"{{{ns}}}{name}"
class BadRequest(Exception):
pass
def parse(body: bytes) -> ET.Element | None:
if not body.strip():
return None
try:
root = SafeET.fromstring(body)
except Exception as err: # noqa: BLE001 - any malformed XML is the client's error
raise BadRequest(str(err)) from err
return root # type: ignore[no-any-return]
@dataclass
class PropRequest:
"""PROPFIND: which properties (None = all)."""
names: list[str] | None = None
names_only: bool = False
def propfind_request(root: ET.Element | None) -> PropRequest:
if root is None or root.find(q(D, "allprop")) is not None:
return PropRequest()
if root.find(q(D, "propname")) is not None:
return PropRequest(names_only=True)
prop = root.find(q(D, "prop"))
if prop is None:
return PropRequest()
return PropRequest(names=[child.tag for child in prop])
def requested_props(root: ET.Element) -> list[str] | None:
prop = root.find(q(D, "prop"))
if prop is None:
return None
return [child.tag for child in prop]
@dataclass
class Report:
# calendar-query | calendar-multiget | addressbook-query | addressbook-multiget
# | sync-collection
kind: str
props: list[str] | None
hrefs: list[str] = field(default_factory=list)
start: datetime | None = None
end: datetime | None = None
sync_token: str = ""
limit: int | None = None
def _ical_time(value: str | None) -> datetime | None:
if not value:
return None
try:
return datetime.strptime(value.strip(), "%Y%m%dT%H%M%SZ").replace(tzinfo=UTC)
except ValueError:
return None
def report_request(root: ET.Element) -> Report:
tag = root.tag
names = {
q(C, "calendar-query"): "calendar-query",
q(C, "calendar-multiget"): "calendar-multiget",
q(CR, "addressbook-query"): "addressbook-query",
q(CR, "addressbook-multiget"): "addressbook-multiget",
q(D, "sync-collection"): "sync-collection",
}
kind = names.get(tag)
if kind is None:
raise BadRequest(f"unsupported report {tag}")
rep = Report(kind=kind, props=requested_props(root))
rep.hrefs = [(h.text or "").strip() for h in root.findall(q(D, "href"))]
if kind == "calendar-query":
tr = root.find(f".//{q(C, 'time-range')}")
if tr is not None:
rep.start = _ical_time(tr.get("start"))
rep.end = _ical_time(tr.get("end"))
if kind == "sync-collection":
rep.sync_token = (root.findtext(q(D, "sync-token")) or "").strip()
nresults = root.find(f"{q(D, 'limit')}/{q(D, 'nresults')}")
if nresults is not None and (nresults.text or "").strip().isdigit():
rep.limit = int((nresults.text or "").strip())
return rep
@dataclass
class PropPatch:
set: dict[str, ET.Element] = field(default_factory=dict)
remove: list[str] = field(default_factory=list)
def proppatch_request(root: ET.Element | None) -> PropPatch:
out = PropPatch()
if root is None:
return out
for action in root:
prop = action.find(q(D, "prop"))
if prop is None:
continue
for child in prop:
if action.tag == q(D, "set"):
out.set[child.tag] = child
elif action.tag == q(D, "remove"):
out.remove.append(child.tag)
return out
def mkcol_props(root: ET.Element | None) -> dict[str, ET.Element]:
"""MKCALENDAR / extended MKCOL: the properties to set on the new collection."""
if root is None:
return {}
out: dict[str, ET.Element] = {}
for sett in root.findall(q(D, "set")):
prop = sett.find(q(D, "prop"))
if prop is not None:
for child in prop:
out[child.tag] = child
return out
# ---- answers --------------------------------------------------------------------------
# A property's value: text, a list of child elements, or an element builder.
PropValue = str | list[ET.Element] | None
class Multistatus:
def __init__(self) -> None:
self.root = ET.Element(q(D, "multistatus"))
def response(
self,
href: str,
found: dict[str, PropValue],
missing: list[str] | None = None,
status: int | None = None,
) -> None:
resp = ET.SubElement(self.root, q(D, "response"))
ET.SubElement(resp, q(D, "href")).text = href
if status is not None:
ET.SubElement(resp, q(D, "status")).text = _status_line(status)
return
if found:
ps = ET.SubElement(resp, q(D, "propstat"))
prop = ET.SubElement(ps, q(D, "prop"))
for name, value in found.items():
el = ET.SubElement(prop, name)
if isinstance(value, str):
el.text = value
elif isinstance(value, list):
el.extend(value)
ET.SubElement(ps, q(D, "status")).text = _status_line(200)
if missing:
ps = ET.SubElement(resp, q(D, "propstat"))
prop = ET.SubElement(ps, q(D, "prop"))
for name in missing:
ET.SubElement(prop, name)
ET.SubElement(ps, q(D, "status")).text = _status_line(404)
def sync_token(self, token: str) -> None:
ET.SubElement(self.root, q(D, "sync-token")).text = token
def to_bytes(self) -> bytes:
body: bytes = ET.tostring(self.root, encoding="utf-8")
return b'<?xml version="1.0" encoding="utf-8"?>\n' + body
def el(
tag: str,
text: str | None = None,
children: list[ET.Element] | None = None,
attrs: dict[str, str] | None = None,
) -> ET.Element:
e = ET.Element(tag, attrs or {})
if text is not None:
e.text = text
if children:
e.extend(children)
return e
def href(path: str) -> ET.Element:
return el(q(D, "href"), path)
def error_body(condition: str) -> bytes:
root = ET.Element(q(D, "error"))
ET.SubElement(root, condition)
return b'<?xml version="1.0" encoding="utf-8"?>\n' + bytes(ET.tostring(root, encoding="utf-8"))
_REASONS = {
200: "OK",
201: "Created",
204: "No Content",
207: "Multi-Status",
403: "Forbidden",
404: "Not Found",
409: "Conflict",
412: "Precondition Failed",
507: "Insufficient Storage",
}
def _status_line(code: int) -> str:
return f"HTTP/1.1 {code} {_REASONS.get(code, '')}".rstrip()