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)
# 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
# to walk principal/home-set/calendars. The path intentionally does NOT
# include a username - the user authenticates via HTTP Basic and the
# server routes them to their own principal.
@app.route('/.well-known/caldav')
# RFC 6764 requires 301; PROPFIND auf /.well-known/caldav gibt manche
# Clients direkt ab (Method-Preservation) - also auch dort akzeptieren
# und auf /dav/ weiterleiten. Alle HTTP-Methoden werden registriert.
@app.route('/.well-known/caldav', methods=['GET', 'PROPFIND', 'OPTIONS', 'HEAD'])
def wellknown_caldav():
return redirect('/dav/', code=301)
@app.route('/.well-known/carddav')
@app.route('/.well-known/carddav', methods=['GET', 'PROPFIND', 'OPTIONS', 'HEAD'])
def wellknown_carddav():
return redirect('/dav/', code=301)

View File

@ -139,79 +139,62 @@ def options(subpath=''):
# PROPFIND
# ---------------------------------------------------------------------------
def _propstat_ok(href: str, props: dict) -> ET.Element:
"""Build a <response> element with one 200 propstat containing the
given (qname -> element or string or None) properties."""
def _make_response(href: str, populate_prop) -> ET.Element:
"""Build a <response><href/><propstat><prop>...</prop><status>200</status>
</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'))
ET.SubElement(resp, _qn('d', 'href')).text = href
propstat = ET.SubElement(resp, _qn('d', 'propstat'))
prop = ET.SubElement(propstat, _qn('d', 'prop'))
for qname, value in props.items():
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)
populate_prop(prop)
ET.SubElement(propstat, _qn('d', 'status')).text = 'HTTP/1.1 200 OK'
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:
principal = ET.Element(_qn('d', 'current-user-principal'))
ET.SubElement(principal, _qn('d', 'href')).text = f'/dav/{user.username}/'
return _propstat_ok(href, {
_qn('d', 'resourcetype'): _resource_type('collection'),
_qn('d', 'displayname'): 'Mini-Cloud DAV',
_qn('d', 'current-user-principal'): principal,
})
def populate(prop):
rt = ET.SubElement(prop, _qn('d', 'resourcetype'))
ET.SubElement(rt, _qn('d', 'collection'))
ET.SubElement(prop, _qn('d', 'displayname')).text = 'Mini-Cloud DAV'
cup = ET.SubElement(prop, _qn('d', 'current-user-principal'))
ET.SubElement(cup, _qn('d', 'href')).text = f'/dav/{user.username}/'
return _make_response(href, populate)
def _principal_response(user: User) -> ET.Element:
href = f'/dav/{user.username}/'
home = ET.Element(_qn('c', 'calendar-home-set'))
ET.SubElement(home, _qn('d', 'href')).text = href
principal_url = ET.Element(_qn('d', 'principal-URL'))
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 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
return _make_response(href, populate)
def _calendar_response(user: User, cal: Calendar) -> ET.Element:
href = _href_calendar(user.username, cal.id)
supported = ET.Element(_qn('c', 'supported-calendar-component-set'))
comp = ET.SubElement(supported, _qn('c', 'comp'))
comp.set('name', 'VEVENT')
color_el = ET.Element(_qn('ic', 'calendar-color'))
color_el.text = cal.color or '#3788d8'
getctag = ET.Element(_qn('cs', 'getctag'))
getctag.text = _calendar_ctag(cal)
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 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.set('name', 'VEVENT')
ET.SubElement(prop, _qn('ic', 'calendar-color')).text = cal.color or '#3788d8'
ET.SubElement(prop, _qn('cs', 'getctag')).text = _calendar_ctag(cal)
ET.SubElement(prop, _qn('d', 'current-user-privilege-set'))
return _make_response(href, populate)
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:
href = _href_event(user.username, cal.id, event.uid)
props = {
_qn('d', 'getetag'): _etag_for_event(event),
_qn('d', 'getcontenttype'): 'text/calendar; charset=utf-8; component=VEVENT',
_qn('d', 'resourcetype'): ET.Element(_qn('d', 'resourcetype')),
}
if include_data:
props[_qn('c', 'calendar-data')] = _wrap_vcalendar(cal, event)
return _propstat_ok(href, props)
def populate(prop):
ET.SubElement(prop, _qn('d', 'getetag')).text = _etag_for_event(event)
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:
ET.SubElement(prop, _qn('c', 'calendar-data')).text = _wrap_vcalendar(cal, event)
return _make_response(href, populate)
def _wrap_vcalendar(cal: Calendar, event: CalendarEvent) -> str: