Fix CalDAV/CardDAV discovery: use GET /.well-known + PROPFIND for principal
This commit is contained in:
@@ -739,24 +739,122 @@ def parse_dav_propfind(xml_text):
|
|||||||
return results
|
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
|
# CardDAV helpers
|
||||||
|
|
||||||
def carddav_base_url():
|
|
||||||
return config.DAV_BASE_URL.rstrip("/") + "/cards/"
|
|
||||||
|
|
||||||
|
|
||||||
def list_carddav_addressbooks():
|
def list_carddav_addressbooks():
|
||||||
|
"""
|
||||||
|
Discover addressbooks via the user principal and addressbook-home-set.
|
||||||
|
"""
|
||||||
s = dav_session()
|
s = dav_session()
|
||||||
r = dav_request(s, "PROPFIND", carddav_base_url(),
|
principal = get_user_principal("carddav")
|
||||||
headers={"Depth": "1",
|
|
||||||
"Content-Type": "application/xml"},
|
# 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"?>
|
data="""<?xml version="1.0"?>
|
||||||
<D:propfind xmlns:D="DAV:">
|
<D:propfind xmlns:D="DAV:">
|
||||||
<D:prop>
|
<D:prop>
|
||||||
<D:resourcetype/>
|
<CARD:addressbook-home-set xmlns:CARD="urn:ietf:params:xml:ns:carddav"/>
|
||||||
<D:displayname/>
|
</D:prop>
|
||||||
</D:prop>
|
</D:propfind>""")
|
||||||
</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>
|
||||||
|
<D:resourcetype/>
|
||||||
|
<D:displayname/>
|
||||||
|
</D:prop>
|
||||||
|
</D:propfind>""")
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
entries = parse_dav_propfind(r.text)
|
entries = parse_dav_propfind(r.text)
|
||||||
addressbooks = []
|
addressbooks = []
|
||||||
@@ -764,7 +862,7 @@ def list_carddav_addressbooks():
|
|||||||
href = e["href"]
|
href = e["href"]
|
||||||
props = e.get("props", {})
|
props = e.get("props", {})
|
||||||
rt = props.get("resourcetype", "")
|
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({
|
addressbooks.append({
|
||||||
"href": href,
|
"href": href,
|
||||||
"name": props.get("displayname", href),
|
"name": props.get("displayname", href),
|
||||||
@@ -785,16 +883,14 @@ def search_carddav_contacts(addressbook_href: str, query: str):
|
|||||||
s = dav_session()
|
s = dav_session()
|
||||||
safe_query = _xml_escape(query)
|
safe_query = _xml_escape(query)
|
||||||
req_body = f"""<?xml version="1.0"?>
|
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:">
|
xmlns:D="DAV:">
|
||||||
<v:addressbook-query>
|
<v:filter>
|
||||||
<v:filter>
|
<v:prop-filter name="FN">
|
||||||
<v:prop-filter name="FN">
|
<v:text-match>{safe_query}</v:text-match>
|
||||||
<v:text-match>{safe_query}</v:text-match>
|
</v:prop-filter>
|
||||||
</v:prop-filter>
|
</v:filter>
|
||||||
</v:filter>
|
</v:addressbook-query>"""
|
||||||
</v:addressbook-query>
|
|
||||||
</v:carddav>"""
|
|
||||||
r = dav_request(s, "REPORT", addressbook_href,
|
r = dav_request(s, "REPORT", addressbook_href,
|
||||||
headers={"Content-Type": "application/xml"},
|
headers={"Content-Type": "application/xml"},
|
||||||
data=req_body)
|
data=req_body)
|
||||||
@@ -851,22 +947,43 @@ def delete_carddav_contact(href: str):
|
|||||||
|
|
||||||
# CalDAV helpers
|
# CalDAV helpers
|
||||||
|
|
||||||
def caldav_base_url():
|
|
||||||
return config.DAV_BASE_URL.rstrip("/") + "/calendars/"
|
|
||||||
|
|
||||||
|
|
||||||
def list_caldav_calendars():
|
def list_caldav_calendars():
|
||||||
|
"""
|
||||||
|
Discover calendars via the user principal and calendar-home-set.
|
||||||
|
"""
|
||||||
s = dav_session()
|
s = dav_session()
|
||||||
r = dav_request(s, "PROPFIND", caldav_base_url(),
|
principal = get_user_principal("caldav")
|
||||||
headers={"Depth": "1",
|
|
||||||
"Content-Type": "application/xml"},
|
# 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"?>
|
data="""<?xml version="1.0"?>
|
||||||
<D:propfind xmlns:D="DAV:">
|
<D:propfind xmlns:D="DAV:">
|
||||||
<D:prop>
|
<D:prop>
|
||||||
<D:resourcetype/>
|
<CAL:calendar-home-set xmlns:CAL="urn:ietf:params:xml:ns:caldav"/>
|
||||||
<D:displayname/>
|
</D:prop>
|
||||||
</D:prop>
|
</D:propfind>""")
|
||||||
</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>
|
||||||
|
<D:resourcetype/>
|
||||||
|
<D:displayname/>
|
||||||
|
</D:prop>
|
||||||
|
</D:propfind>""")
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
entries = parse_dav_propfind(r.text)
|
entries = parse_dav_propfind(r.text)
|
||||||
calendars = []
|
calendars = []
|
||||||
@@ -874,7 +991,7 @@ def list_caldav_calendars():
|
|||||||
href = e["href"]
|
href = e["href"]
|
||||||
props = e.get("props", {})
|
props = e.get("props", {})
|
||||||
rt = props.get("resourcetype", "")
|
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({
|
calendars.append({
|
||||||
"href": href,
|
"href": href,
|
||||||
"name": props.get("displayname", href.split("/")[-2]),
|
"name": props.get("displayname", href.split("/")[-2]),
|
||||||
|
|||||||
Reference in New Issue
Block a user