Fix CalDAV/CardDAV discovery: use GET /.well-known + PROPFIND for principal

This commit is contained in:
Admin
2026-06-20 17:43:29 +00:00
parent 1c4a80adfe
commit 1336178abc
+137 -20
View File
@@ -739,17 +739,115 @@ def parse_dav_propfind(xml_text):
return results
# CalDAV/CardDAV helpers
DAV_NS = {"D": "DAV:"}
CAL_NS = {"CAL": "urn:ietf:params:xml:ns:caldav"}
CARD_NS = {"CARD": "urn:ietf:params:xml:ns:carddav"}
def dav_session():
if not config.DAV_BASE_URL or not config.DAV_USERNAME or not config.DAV_PASSWORD:
raise ValueError("DAV_BASE_URL, DAV_USERNAME, or DAV_PASSWORD not configured")
s = requests.Session()
s.auth = (config.DAV_USERNAME, config.DAV_PASSWORD)
s.verify = config.DAV_VERIFY_TLS
return s
def dav_request(s, method, url, data=None, headers=None, timeout=20):
try:
r = s.request(method, url, data=data, headers=headers or {}, timeout=timeout)
return r
except Exception as e:
logger.error(f"DAV request error: {method} {url}: {e}")
raise
def parse_dav_propfind(xml_text):
try:
root = ET.fromstring(xml_text)
except ET.ParseError:
return []
results = []
for response in root.findall(".//D:response", DAV_NS):
href_el = response.find("D:href", DAV_NS)
href = (href_el.text or "").strip() if href_el is not None else ""
props = {}
propfind = response.find("D:propstat/D:prop", DAV_NS)
if propfind is not None:
for child in propfind:
tag = re.sub(r".*\{.*\}", "", child.tag)
text = (child.text or "").strip()
props[tag] = text
if href:
results.append({"href": href, "props": props})
return results
def get_user_principal(service="caldav"):
"""
Discover the user's principal URL using /.well-known/{service} redirect
and then PROPFIND for current-user-principal.
"""
base = config.DAV_BASE_URL.rstrip("/")
well_known = base + f"/.well-known/{service}"
s = dav_session()
# First: GET /.well-known/caldav (or carddav) to get the redirect URL
r = dav_request(s, "GET", well_known)
r.raise_for_status()
dave_url = r.url # after redirects
# Then: PROPFIND on that URL to find current-user-principal
r = dav_request(s, "PROPFIND", dave_url,
headers={"Depth": "0", "Content-Type": "application/xml"},
data="""<?xml version="1.0"?>
<D:propfind xmlns:D="DAV:">
<D:prop><D:current-user-principal/></D:prop>
</D:propfind>""")
r.raise_for_status()
entries = parse_dav_propfind(r.text)
for e in entries:
principal = e.get("props", {}).get("current-user-principal", "")
if principal:
return principal
raise RuntimeError("Could not find user principal")
# CardDAV helpers
def carddav_base_url():
return config.DAV_BASE_URL.rstrip("/") + "/cards/"
def list_carddav_addressbooks():
"""
Discover addressbooks via the user principal and addressbook-home-set.
"""
s = dav_session()
r = dav_request(s, "PROPFIND", carddav_base_url(),
headers={"Depth": "1",
"Content-Type": "application/xml"},
principal = get_user_principal("carddav")
# PROPFIND on principal to get addressbook-home-set
r = dav_request(s, "PROPFIND", principal,
headers={"Depth": "0", "Content-Type": "application/xml"},
data="""<?xml version="1.0"?>
<D:propfind xmlns:D="DAV:">
<D:prop>
<CARD:addressbook-home-set xmlns:CARD="urn:ietf:params:xml:ns:carddav"/>
</D:prop>
</D:propfind>""")
r.raise_for_status()
entries = parse_dav_propfind(r.text)
home_set = None
for e in entries:
val = e.get("props", {}).get("addressbook-home-set", "")
if val:
home_set = val
break
if not home_set:
return []
# PROPFIND on addressbook-home-set to list addressbooks
r = dav_request(s, "PROPFIND", home_set,
headers={"Depth": "1", "Content-Type": "application/xml"},
data="""<?xml version="1.0"?>
<D:propfind xmlns:D="DAV:">
<D:prop>
@@ -764,7 +862,7 @@ def list_carddav_addressbooks():
href = e["href"]
props = e.get("props", {})
rt = props.get("resourcetype", "")
if "/cards/" in href and href.endswith("/") and ("addressbook" in rt.lower() or "addressbook" in href.lower()):
if href.endswith("/") and ("addressbook" in rt.lower() or "addressbook" in href.lower()):
addressbooks.append({
"href": href,
"name": props.get("displayname", href),
@@ -785,16 +883,14 @@ def search_carddav_contacts(addressbook_href: str, query: str):
s = dav_session()
safe_query = _xml_escape(query)
req_body = f"""<?xml version="1.0"?>
<v:carddav xmlns:v="urn:ietf:params:xml:ns:carddav"
<v:addressbook-query xmlns:v="urn:ietf:params:xml:ns:carddav"
xmlns:D="DAV:">
<v:addressbook-query>
<v:filter>
<v:prop-filter name="FN">
<v:text-match>{safe_query}</v:text-match>
</v:prop-filter>
</v:filter>
</v:addressbook-query>
</v:carddav>"""
</v:addressbook-query>"""
r = dav_request(s, "REPORT", addressbook_href,
headers={"Content-Type": "application/xml"},
data=req_body)
@@ -851,15 +947,36 @@ def delete_carddav_contact(href: str):
# CalDAV helpers
def caldav_base_url():
return config.DAV_BASE_URL.rstrip("/") + "/calendars/"
def list_caldav_calendars():
"""
Discover calendars via the user principal and calendar-home-set.
"""
s = dav_session()
r = dav_request(s, "PROPFIND", caldav_base_url(),
headers={"Depth": "1",
"Content-Type": "application/xml"},
principal = get_user_principal("caldav")
# PROPFIND on principal to get calendar-home-set
r = dav_request(s, "PROPFIND", principal,
headers={"Depth": "0", "Content-Type": "application/xml"},
data="""<?xml version="1.0"?>
<D:propfind xmlns:D="DAV:">
<D:prop>
<CAL:calendar-home-set xmlns:CAL="urn:ietf:params:xml:ns:caldav"/>
</D:prop>
</D:propfind>""")
r.raise_for_status()
entries = parse_dav_propfind(r.text)
home_set = None
for e in entries:
val = e.get("props", {}).get("calendar-home-set", "")
if val:
home_set = val
break
if not home_set:
return []
# PROPFIND on calendar-home-set to list calendars
r = dav_request(s, "PROPFIND", home_set,
headers={"Depth": "1", "Content-Type": "application/xml"},
data="""<?xml version="1.0"?>
<D:propfind xmlns:D="DAV:">
<D:prop>
@@ -874,7 +991,7 @@ def list_caldav_calendars():
href = e["href"]
props = e.get("props", {})
rt = props.get("resourcetype", "")
if "/calendars/" in href and href.endswith("/") and ("calendar" in rt.lower() or "calendar" in href.lower()):
if href.endswith("/") and ("calendar" in rt.lower() or "calendar" in href.lower()):
calendars.append({
"href": href,
"name": props.get("displayname", href.split("/")[-2]),