fix: CalDAV Autodiscovery - XML war doppelt verschachtelt

Property-Elemente wurden unter einem Container mit demselben Tag
erzeugt, z.B.:
  <current-user-principal>
    <current-user-principal>    <!-- falsch, doppelt -->
      <href>/dav/adam/</href>
    </current-user-principal>
  </current-user-principal>

Clients wie DAVx5 und Thunderbird erkennen dadurch den Principal
nicht und melden "Kein CalDAV-Dienst gefunden". XML-Generierung
umgebaut - Response-Helfer bekommen jetzt eine populate_prop-
Callback, die die tatsaechlichen Property-Children direkt ins
<prop>-Element setzt.

Zusaetzlich:
* /.well-known/caldav und /carddav akzeptieren jetzt auch PROPFIND,
  OPTIONS, HEAD (einige Clients halten die Methode beim ersten
  Aufruf bei).
* Kalender-Response enthaelt current-user-privilege-set (leer, als
  Signal dass der Client nicht ACL-abhaengig pruefen muss).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker 2026-04-12 13:44:44 +02:00
parent e85338761d
commit c4b381c5e9
2 changed files with 53 additions and 70 deletions

View File

@ -92,15 +92,14 @@ def create_app(config_class=Config):
app.register_blueprint(dav_bp) app.register_blueprint(dav_bp)
# Well-known URLs for CalDAV/CardDAV auto-discovery (iOS, DAVx5, etc.). # Well-known URLs for CalDAV/CardDAV auto-discovery (iOS, DAVx5, etc.).
# RFC 6764 says we should do a 301 to the DAV root; clients then PROPFIND # RFC 6764 requires 301; PROPFIND auf /.well-known/caldav gibt manche
# to walk principal/home-set/calendars. The path intentionally does NOT # Clients direkt ab (Method-Preservation) - also auch dort akzeptieren
# include a username - the user authenticates via HTTP Basic and the # und auf /dav/ weiterleiten. Alle HTTP-Methoden werden registriert.
# server routes them to their own principal. @app.route('/.well-known/caldav', methods=['GET', 'PROPFIND', 'OPTIONS', 'HEAD'])
@app.route('/.well-known/caldav')
def wellknown_caldav(): def wellknown_caldav():
return redirect('/dav/', code=301) return redirect('/dav/', code=301)
@app.route('/.well-known/carddav') @app.route('/.well-known/carddav', methods=['GET', 'PROPFIND', 'OPTIONS', 'HEAD'])
def wellknown_carddav(): def wellknown_carddav():
return redirect('/dav/', code=301) return redirect('/dav/', code=301)

View File

@ -139,79 +139,62 @@ def options(subpath=''):
# PROPFIND # PROPFIND
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _propstat_ok(href: str, props: dict) -> ET.Element: def _make_response(href: str, populate_prop) -> ET.Element:
"""Build a <response> element with one 200 propstat containing the """Build a <response><href/><propstat><prop>...</prop><status>200</status>
given (qname -> element or string or None) properties.""" </propstat></response> element. `populate_prop` is a callable that gets
the <prop> element and appends the actual property sub-elements to it."""
resp = ET.Element(_qn('d', 'response')) resp = ET.Element(_qn('d', 'response'))
ET.SubElement(resp, _qn('d', 'href')).text = href ET.SubElement(resp, _qn('d', 'href')).text = href
propstat = ET.SubElement(resp, _qn('d', 'propstat')) propstat = ET.SubElement(resp, _qn('d', 'propstat'))
prop = ET.SubElement(propstat, _qn('d', 'prop')) prop = ET.SubElement(propstat, _qn('d', 'prop'))
for qname, value in props.items(): populate_prop(prop)
el = ET.SubElement(prop, qname)
if isinstance(value, ET.Element):
el.append(value)
elif isinstance(value, list):
for child in value:
el.append(child)
elif value is not None:
el.text = str(value)
ET.SubElement(propstat, _qn('d', 'status')).text = 'HTTP/1.1 200 OK' ET.SubElement(propstat, _qn('d', 'status')).text = 'HTTP/1.1 200 OK'
return resp return resp
def _resource_type(*kinds: str) -> ET.Element:
el = ET.Element(_qn('d', 'resourcetype'))
for k in kinds:
if k == 'collection':
ET.SubElement(el, _qn('d', 'collection'))
elif k == 'calendar':
ET.SubElement(el, _qn('c', 'calendar'))
elif k == 'principal':
ET.SubElement(el, _qn('d', 'principal'))
return el
def _root_response(href: str, user: User) -> ET.Element: def _root_response(href: str, user: User) -> ET.Element:
principal = ET.Element(_qn('d', 'current-user-principal')) def populate(prop):
ET.SubElement(principal, _qn('d', 'href')).text = f'/dav/{user.username}/' rt = ET.SubElement(prop, _qn('d', 'resourcetype'))
return _propstat_ok(href, { ET.SubElement(rt, _qn('d', 'collection'))
_qn('d', 'resourcetype'): _resource_type('collection'), ET.SubElement(prop, _qn('d', 'displayname')).text = 'Mini-Cloud DAV'
_qn('d', 'displayname'): 'Mini-Cloud DAV', cup = ET.SubElement(prop, _qn('d', 'current-user-principal'))
_qn('d', 'current-user-principal'): principal, ET.SubElement(cup, _qn('d', 'href')).text = f'/dav/{user.username}/'
}) return _make_response(href, populate)
def _principal_response(user: User) -> ET.Element: def _principal_response(user: User) -> ET.Element:
href = f'/dav/{user.username}/' href = f'/dav/{user.username}/'
home = ET.Element(_qn('c', 'calendar-home-set'))
def populate(prop):
rt = ET.SubElement(prop, _qn('d', 'resourcetype'))
ET.SubElement(rt, _qn('d', 'collection'))
ET.SubElement(rt, _qn('d', 'principal'))
ET.SubElement(prop, _qn('d', 'displayname')).text = user.username
cup = ET.SubElement(prop, _qn('d', 'current-user-principal'))
ET.SubElement(cup, _qn('d', 'href')).text = href
pu = ET.SubElement(prop, _qn('d', 'principal-URL'))
ET.SubElement(pu, _qn('d', 'href')).text = href
home = ET.SubElement(prop, _qn('c', 'calendar-home-set'))
ET.SubElement(home, _qn('d', 'href')).text = href ET.SubElement(home, _qn('d', 'href')).text = href
principal_url = ET.Element(_qn('d', 'principal-URL')) return _make_response(href, populate)
ET.SubElement(principal_url, _qn('d', 'href')).text = href
return _propstat_ok(href, {
_qn('d', 'resourcetype'): _resource_type('principal', 'collection'),
_qn('d', 'displayname'): user.username,
_qn('c', 'calendar-home-set'): home,
_qn('d', 'principal-URL'): principal_url,
})
def _calendar_response(user: User, cal: Calendar) -> ET.Element: def _calendar_response(user: User, cal: Calendar) -> ET.Element:
href = _href_calendar(user.username, cal.id) href = _href_calendar(user.username, cal.id)
supported = ET.Element(_qn('c', 'supported-calendar-component-set'))
def populate(prop):
rt = ET.SubElement(prop, _qn('d', 'resourcetype'))
ET.SubElement(rt, _qn('d', 'collection'))
ET.SubElement(rt, _qn('c', 'calendar'))
ET.SubElement(prop, _qn('d', 'displayname')).text = cal.name
ET.SubElement(prop, _qn('c', 'calendar-description')).text = cal.description or ''
supported = ET.SubElement(prop, _qn('c', 'supported-calendar-component-set'))
comp = ET.SubElement(supported, _qn('c', 'comp')) comp = ET.SubElement(supported, _qn('c', 'comp'))
comp.set('name', 'VEVENT') comp.set('name', 'VEVENT')
color_el = ET.Element(_qn('ic', 'calendar-color')) ET.SubElement(prop, _qn('ic', 'calendar-color')).text = cal.color or '#3788d8'
color_el.text = cal.color or '#3788d8' ET.SubElement(prop, _qn('cs', 'getctag')).text = _calendar_ctag(cal)
getctag = ET.Element(_qn('cs', 'getctag')) ET.SubElement(prop, _qn('d', 'current-user-privilege-set'))
getctag.text = _calendar_ctag(cal) return _make_response(href, populate)
return _propstat_ok(href, {
_qn('d', 'resourcetype'): _resource_type('collection', 'calendar'),
_qn('d', 'displayname'): cal.name,
_qn('c', 'calendar-description'): cal.description or '',
_qn('c', 'supported-calendar-component-set'): supported,
_qn('ic', 'calendar-color'): color_el.text,
_qn('cs', 'getctag'): getctag.text,
})
def _calendar_ctag(cal: Calendar) -> str: def _calendar_ctag(cal: Calendar) -> str:
@ -223,14 +206,15 @@ def _calendar_ctag(cal: Calendar) -> str:
def _event_response(user: User, cal: Calendar, event: CalendarEvent, include_data: bool = False) -> ET.Element: def _event_response(user: User, cal: Calendar, event: CalendarEvent, include_data: bool = False) -> ET.Element:
href = _href_event(user.username, cal.id, event.uid) href = _href_event(user.username, cal.id, event.uid)
props = {
_qn('d', 'getetag'): _etag_for_event(event), def populate(prop):
_qn('d', 'getcontenttype'): 'text/calendar; charset=utf-8; component=VEVENT', ET.SubElement(prop, _qn('d', 'getetag')).text = _etag_for_event(event)
_qn('d', 'resourcetype'): ET.Element(_qn('d', 'resourcetype')), ET.SubElement(prop, _qn('d', 'getcontenttype')).text = \
} 'text/calendar; charset=utf-8; component=VEVENT'
ET.SubElement(prop, _qn('d', 'resourcetype')) # empty -> regular resource
if include_data: if include_data:
props[_qn('c', 'calendar-data')] = _wrap_vcalendar(cal, event) ET.SubElement(prop, _qn('c', 'calendar-data')).text = _wrap_vcalendar(cal, event)
return _propstat_ok(href, props) return _make_response(href, populate)
def _wrap_vcalendar(cal: Calendar, event: CalendarEvent) -> str: def _wrap_vcalendar(cal: Calendar, event: CalendarEvent) -> str: