diff --git a/server.py b/server.py index 8f741f1..4f0e9e4 100644 --- a/server.py +++ b/server.py @@ -739,24 +739,122 @@ 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=""" + + + """) + 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=""" - - - - - - """) + + + + + """) + 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=""" + + + + + + """) r.raise_for_status() entries = parse_dav_propfind(r.text) addressbooks = [] @@ -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""" - - - - - {safe_query} - - - - """ + + + + {safe_query} + + + """ r = dav_request(s, "REPORT", addressbook_href, headers={"Content-Type": "application/xml"}, data=req_body) @@ -851,22 +947,43 @@ 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=""" - - - - - - """) + + + + + """) + 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=""" + + + + + + """) r.raise_for_status() entries = parse_dav_propfind(r.text) calendars = [] @@ -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]),