diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index 3976af91..62af2949 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -26,7 +26,8 @@ import xml.etree.ElementTree as ET from http import client from typing import Dict, Iterable, Iterator, List, Optional, Sequence, Tuple -from radicale import httputils, pathutils, rights, storage, types, xmlutils +from radicale import (httputils, pathutils, rights, storage, types, utils, + xmlutils) from radicale.app.base import Access, ApplicationBase from radicale.log import logger @@ -135,6 +136,10 @@ def xml_propfind_response( props.append(xmlutils.make_clark("CS:getctag")) props.append( xmlutils.make_clark("C:supported-calendar-component-set")) + if collection.tag == "VADDRESSBOOK": + props.append(xmlutils.make_clark("CS:getctag")) + props.append( + xmlutils.make_clark("CR:supported-address-data")) meta = collection.get_meta() for tag in meta: @@ -188,6 +193,21 @@ def xml_propfind_response( element.append(comp) else: is404 = True + elif tag == xmlutils.make_clark("CR:supported-address-data"): + if is_collection and is_leaf and collection.tag == "VADDRESSBOOK": + # Advertise supported vCard versions per RFC 6352 section 6.2.2 + # vCard 4.0 requires vobject >= 1.0.0 + versions: Sequence[str] = (("4.0", "3.0") + if utils.vobject_supports_vcard4() + else ("3.0",)) + for version in versions: + address_data_type = ET.Element( + xmlutils.make_clark("CR:address-data-type")) + address_data_type.set("content-type", "text/vcard") + address_data_type.set("version", version) + element.append(address_data_type) + else: + is404 = True elif tag == xmlutils.make_clark("D:current-user-principal"): if user: child_element = ET.Element(xmlutils.make_clark("D:href")) diff --git a/radicale/tests/static/contact1_v4.vcf b/radicale/tests/static/contact1_v4.vcf new file mode 100644 index 00000000..5ddb2312 --- /dev/null +++ b/radicale/tests/static/contact1_v4.vcf @@ -0,0 +1,7 @@ +BEGIN:VCARD +VERSION:4.0 +UID:contact1 +N:Contact;;;; +FN:Contact +NICKNAME:test +END:VCARD diff --git a/radicale/tests/static/contact_multiple_v4.vcf b/radicale/tests/static/contact_multiple_v4.vcf new file mode 100644 index 00000000..e153ba52 --- /dev/null +++ b/radicale/tests/static/contact_multiple_v4.vcf @@ -0,0 +1,12 @@ +BEGIN:VCARD +VERSION:4.0 +UID:contact1 +N:Contact1;;;; +FN:Contact1 +END:VCARD +BEGIN:VCARD +VERSION:4.0 +UID:contact2 +N:Contact2;;;; +FN:Contact2 +END:VCARD diff --git a/radicale/tests/static/contact_photo_with_data_uri_v4.vcf b/radicale/tests/static/contact_photo_with_data_uri_v4.vcf new file mode 100644 index 00000000..18a2dad3 --- /dev/null +++ b/radicale/tests/static/contact_photo_with_data_uri_v4.vcf @@ -0,0 +1,8 @@ +BEGIN:VCARD +VERSION:4.0 +UID:contact +N:Contact;;;; +FN:Contact +NICKNAME:test +PHOTO:data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAAD0lEQVQIHQEEAPv/AP///wX+Av4DfRnGAAAAAElFTkSuQmCC +END:VCARD diff --git a/radicale/tests/test_base.py b/radicale/tests/test_base.py index c1d4e5ab..8d8e0def 100644 --- a/radicale/tests/test_base.py +++ b/radicale/tests/test_base.py @@ -27,9 +27,10 @@ import posixpath from typing import Any, Callable, ClassVar, Iterable, List, Optional, Tuple import defusedxml.ElementTree as DefusedET +import pytest import vobject -from radicale import storage, xmlutils +from radicale import storage, utils, xmlutils from radicale.tests import RESPONSES, BaseTest from radicale.tests.helpers import get_file_content @@ -274,6 +275,48 @@ permissions: RrWw""") path = "/contacts.vcf/contact.vcf" self.put(path, contact, check=400) + def test_add_contact_v3(self) -> None: + """Add a vCard 3.0 contact.""" + self.create_addressbook("/contacts.vcf/") + contact = get_file_content("contact1.vcf") + path = "/contacts.vcf/contact.vcf" + self.put(path, contact) + _, headers, answer = self.request("GET", path, check=200) + assert "ETag" in headers + assert headers["Content-Type"] == "text/vcard; charset=utf-8" + assert "VCARD" in answer + assert "UID:contact1" in answer + assert "VERSION:3.0" in answer + + @pytest.mark.skipif(not utils.vobject_supports_vcard4(), + reason="vobject < 1.0.0 does not support vCard 4.0") + def test_add_contact_v4(self) -> None: + """Add a vCard 4.0 contact (requires vobject >= 1.0.0).""" + self.create_addressbook("/contacts.vcf/") + contact = get_file_content("contact1_v4.vcf") + path = "/contacts.vcf/contact.vcf" + self.put(path, contact) + _, headers, answer = self.request("GET", path, check=200) + assert "ETag" in headers + assert headers["Content-Type"] == "text/vcard; charset=utf-8" + assert "VCARD" in answer + assert "UID:contact1" in answer + assert "VERSION:4.0" in answer + + def test_add_contact_photo_with_data_uri_v3(self) -> None: + """Test vCard 3.0 PHOTO format""" + self.create_addressbook("/contacts.vcf/") + contact = get_file_content("contact_photo_with_data_uri.vcf") + self.put("/contacts.vcf/contact.vcf", contact) + + @pytest.mark.skipif(not utils.vobject_supports_vcard4(), + reason="vobject < 1.0.0 does not support vCard 4.0") + def test_add_contact_photo_with_data_uri_v4(self) -> None: + """Test vCard 4.0 PHOTO data URI format (requires vobject >= 1.0.0)""" + self.create_addressbook("/contacts.vcf/") + contact = get_file_content("contact_photo_with_data_uri_v4.vcf") + self.put("/contacts.vcf/contact.vcf", contact) + def test_update_event(self) -> None: """Update an event.""" self.mkcalendar("/calendar.ics/") @@ -744,6 +787,53 @@ permissions: RrWw""") status, prop = response["CS:getctag"] assert status == 200 and prop.text + def test_propfind_supported_address_data(self) -> None: + """Read property CR:supported-address-data on addressbook""" + self.create_addressbook("/addressbook.vcf/") + contact = get_file_content("contact1.vcf") + self.put("/addressbook.vcf/contact.vcf", contact) + _, responses = self.propfind("/addressbook.vcf/", """\ + + + + + +""") + response = responses["/addressbook.vcf/"] + assert not isinstance(response, int) + status, prop = response["CR:supported-address-data"] + assert status == 200 + # Should have at least one address-data-type element + address_data_types = prop.findall( + xmlutils.make_clark("CR:address-data-type")) + assert len(address_data_types) >= 1 + # Check that 3.0 is always supported + versions = [e.get("version") for e in address_data_types] + assert "3.0" in versions + # Check content-type is text/vcard for all + for e in address_data_types: + assert e.get("content-type") == "text/vcard" + # If vobject >= 1.0.0, should also support 4.0 + if utils.vobject_supports_vcard4(): + assert "4.0" in versions + # vCard 4.0 should be listed first (preferred) + assert versions[0] == "4.0" + + def test_propfind_supported_address_data_on_calendar(self) -> None: + """Read property CR:supported-address-data on calendar (should 404)""" + self.mkcalendar("/calendar.ics/") + _, responses = self.propfind("/calendar.ics/", """\ + + + + + +""") + response = responses["/calendar.ics/"] + assert not isinstance(response, int) + status, prop = response["CR:supported-address-data"] + assert status == 404 + def test_proppatch(self) -> None: """Set/Remove a property and read it back.""" self.mkcalendar("/calendar.ics/") diff --git a/radicale/utils.py b/radicale/utils.py index e2e01903..152f384e 100644 --- a/radicale/utils.py +++ b/radicale/utils.py @@ -88,6 +88,17 @@ def package_version(name): return metadata.version(name) +def vobject_supports_vcard4() -> bool: + """Check if vobject supports vCard 4.0 (requires version >= 1.0.0).""" + try: + version = package_version("vobject") + parts = version.split(".") + major = int(parts[0]) + return major >= 1 + except Exception: + return False + + def packages_version(): versions = [] versions.append("python=%s.%s.%s" % (sys.version_info[0], sys.version_info[1], sys.version_info[2]))