From 07c6579714a8615817e3bcecf5314a2ab13d53f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jelmer=20Vernoo=C4=B3?= Date: Sat, 12 Jul 2025 21:08:20 +0100 Subject: [PATCH] Add CalDAV managed attachments support (RFC 8607) This implements RFC 8607 CalDAV Managed Attachments, allowing calendar clients like macOS Calendar.app to add attachments to events. Key features: - POST method for attachment operations (add, update, remove) - GET method for attachment retrieval Attachments are stored in the .attachments directory. Fixes #166 --- xandikos/attachments.py | 232 ++++++++++++++++++ xandikos/caldav.py | 427 ++++++++++++++++++++++++++++++++++ xandikos/tests/test_caldav.py | 145 ++++++++++++ xandikos/web.py | 43 +++- xandikos/webdav.py | 17 +- 5 files changed, 861 insertions(+), 3 deletions(-) create mode 100644 xandikos/attachments.py diff --git a/xandikos/attachments.py b/xandikos/attachments.py new file mode 100644 index 00000000..91b98da8 --- /dev/null +++ b/xandikos/attachments.py @@ -0,0 +1,232 @@ +# Xandikos +# Copyright (C) 2025 Jelmer Vernooij , et al. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; version 3 +# of the License or (at your option) any later version of +# the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, +# MA 02110-1301, USA. + +"""CalDAV Managed Attachments support. + +This module implements RFC 8607 - CalDAV Managed Attachments. +https://datatracker.ietf.org/doc/html/rfc8607 +""" + +import json +import os +import uuid +from typing import Optional + +from icalendar import prop + + +class AttachmentStore: + """Simple file-based attachment storage.""" + + def __init__(self, base_path: str): + self.base_path = base_path + self.attachments_dir = os.path.join(base_path, ".attachments") + + def _ensure_dir(self): + """Ensure attachments directory exists.""" + os.makedirs(self.attachments_dir, exist_ok=True) + + def create( + self, data: bytes, content_type: str, filename: Optional[str] = None + ) -> str: + """Store a new attachment and return its managed ID.""" + self._ensure_dir() + + managed_id = str(uuid.uuid4()) + attachment_path = os.path.join(self.attachments_dir, managed_id) + + # Store attachment data + with open(attachment_path, "wb") as f: + f.write(data) + + # Store metadata + metadata = { + "content_type": content_type, + "filename": filename, + "size": len(data), + } + with open(attachment_path + ".meta", "w") as f: + json.dump(metadata, f) + + return managed_id + + def get(self, managed_id: str) -> tuple[bytes, str, Optional[str]]: + """Retrieve attachment data and metadata.""" + attachment_path = os.path.join(self.attachments_dir, managed_id) + metadata_path = attachment_path + ".meta" + + if not os.path.exists(attachment_path) or not os.path.exists(metadata_path): + raise KeyError(f"Attachment {managed_id} not found") + + with open(attachment_path, "rb") as f: + data = f.read() + + with open(metadata_path) as f: + metadata = json.load(f) + + return data, metadata["content_type"], metadata.get("filename") + + def delete(self, managed_id: str): + """Delete an attachment.""" + attachment_path = os.path.join(self.attachments_dir, managed_id) + metadata_path = attachment_path + ".meta" + + if not os.path.exists(attachment_path): + raise KeyError(f"Attachment {managed_id} not found") + + os.remove(attachment_path) + if os.path.exists(metadata_path): + os.remove(metadata_path) + + def update( + self, + managed_id: str, + data: bytes, + content_type: str, + filename: Optional[str] = None, + ): + """Update an existing attachment.""" + attachment_path = os.path.join(self.attachments_dir, managed_id) + metadata_path = attachment_path + ".meta" + + if not os.path.exists(attachment_path): + raise KeyError(f"Attachment {managed_id} not found") + + # Update data + with open(attachment_path, "wb") as f: + f.write(data) + + # Update metadata + metadata = { + "content_type": content_type, + "filename": filename, + "size": len(data), + } + with open(metadata_path, "w") as f: + json.dump(metadata, f) + + +def create_attach_property( + url: str, + managed_id: str, + size: int, + content_type: str, + filename: Optional[str] = None, +): + """Create an ATTACH property with RFC 8607 parameters.""" + attach_prop = prop.vUri(url) + attach_prop.params["MANAGED-ID"] = managed_id + attach_prop.params["SIZE"] = str(size) + attach_prop.params["FMTTYPE"] = content_type + if filename: + attach_prop.params["FILENAME"] = filename + return attach_prop + + +def find_attach_property(component, managed_id: str): + """Find an ATTACH property by managed ID in a component.""" + attach_props = component.get("ATTACH", []) + if not isinstance(attach_props, list): + attach_props = [attach_props] + + for attach_prop in attach_props: + if attach_prop.params.get("MANAGED-ID") == managed_id: + return attach_prop + return None + + +def add_attach_to_component(component, attach_prop): + """Add an ATTACH property to a component.""" + if "ATTACH" in component: + existing = component["ATTACH"] + if isinstance(existing, list): + existing.append(attach_prop) + else: + component["ATTACH"] = [existing, attach_prop] + else: + component.add("ATTACH", attach_prop) + + +def remove_attach_from_component(component, managed_id: str): + """Remove an ATTACH property by managed ID.""" + attach_props = component.get("ATTACH", []) + if not isinstance(attach_props, list): + attach_props = [attach_props] + + updated = [ap for ap in attach_props if ap.params.get("MANAGED-ID") != managed_id] + + if updated: + component["ATTACH"] = updated[0] if len(updated) == 1 else updated + elif "ATTACH" in component: + del component["ATTACH"] + + +def find_calendar_component(calendar, rid: Optional[str] = None): + """Find the target component in a calendar.""" + for component in calendar.subcomponents: + if component.name not in ("VEVENT", "VTODO", "VJOURNAL"): + continue + + if rid: + # Looking for specific recurrence + recurrence_id = component.get("RECURRENCE-ID") + if recurrence_id and str(recurrence_id) == rid: + return component + else: + # Return first matching component + return component + + return None + + +async def update_calendar_with_attachment( + resource, + calendar, + managed_id: str, + url: str, + content_type: str, + filename: Optional[str], + size: int, + rid: Optional[str] = None, +): + """Add or update an attachment in a calendar resource.""" + component = find_calendar_component(calendar, rid) + if not component: + raise ValueError("No suitable component found for attachment") + + # Create and add the ATTACH property + attach_prop = create_attach_property(url, managed_id, size, content_type, filename) + add_attach_to_component(component, attach_prop) + + # Save the calendar + await resource.set_body(calendar.to_ical(), replace_etag=True) + + +async def remove_attachment_from_calendar( + resource, calendar, managed_id: str, rid: Optional[str] = None +): + """Remove an attachment from a calendar resource.""" + component = find_calendar_component(calendar, rid) + if not component: + raise ValueError("No suitable component found") + + remove_attach_from_component(component, managed_id) + + # Save the calendar + await resource.set_body(calendar.to_ical(), replace_etag=True) diff --git a/xandikos/caldav.py b/xandikos/caldav.py index 51fd82b2..cc231539 100644 --- a/xandikos/caldav.py +++ b/xandikos/caldav.py @@ -57,6 +57,7 @@ # Feature to advertise to indicate CalDAV support. FEATURE = "calendar-access" +MANAGED_ATTACHMENTS_FEATURE = "calendar-managed-attachments" TRANSPARENCY_TRANSPARENT = "transparent" TRANSPARENCY_OPAQUE = "opaque" @@ -178,6 +179,55 @@ def get_created_by(self): def get_updated_by(self): raise NotImplementedError(self.get_updated_by) + def supports_managed_attachments(self): + """Return True if this calendar supports managed attachments.""" + return False + + def create_attachment(self, attachment_data, content_type, filename=None): + """Create a new attachment and return its managed ID and URL. + + Args: + attachment_data: The binary attachment data + content_type: MIME content type of the attachment + filename: Optional filename for the attachment + + Returns: + (managed_id, attachment_url): Tuple of managed ID and URL + """ + raise NotImplementedError(self.create_attachment) + + def get_attachment(self, managed_id): + """Get attachment data by managed ID. + + Args: + managed_id: The managed ID of the attachment + + Returns: + (attachment_data, content_type, filename): Attachment details + """ + raise NotImplementedError(self.get_attachment) + + def delete_attachment(self, managed_id): + """Delete an attachment by managed ID. + + Args: + managed_id: The managed ID of the attachment to delete + """ + raise NotImplementedError(self.delete_attachment) + + def update_attachment( + self, managed_id, attachment_data, content_type, filename=None + ): + """Update an existing attachment. + + Args: + managed_id: The managed ID of the attachment to update + attachment_data: The new binary attachment data + content_type: MIME content type of the attachment + filename: Optional filename for the attachment + """ + raise NotImplementedError(self.update_attachment) + class Subscription: resource_types = webdav.Collection.resource_types + [SUBSCRIPTION_RESOURCE_TYPE] @@ -1061,3 +1111,380 @@ async def handle(self, request, environ, app): ) else: return webdav.Response(status="201 Created") + + +class CalendarAttachmentPostMethod(webdav.Method): + """POST method for CalDAV managed attachments. + + Implements RFC 8607 - CalDAV Managed Attachments. + """ + + @property + def name(self): + return "POST" + + async def handle(self, request, environ, app): + import urllib.parse + + href, path, resource = app._get_resource_from_environ(request, environ) + if resource is None: + return webdav._send_not_found(request) + + # Parse query parameters to determine the action + query_params = urllib.parse.parse_qs(urllib.parse.urlparse(request.path).query) + action = query_params.get("action", [None])[0] + managed_id = query_params.get("managed-id", [None])[0] + rid = query_params.get("rid", [None])[0] # For recurring events + + # Check if this resource supports managed attachments + supports_attachments = getattr(resource, "supports_managed_attachments", None) + if supports_attachments is None or not supports_attachments(): + return webdav._send_simple_dav_error( + request, + "403 Forbidden", + error=ET.Element("{%s}supported-feature" % NAMESPACE), + description="Calendar does not support managed attachments", + ) + + # Get the calendar containing this resource + calendar = None + collection = getattr(resource, "collection", None) + if collection is not None: + collection_supports = getattr( + collection, "supports_managed_attachments", None + ) + if collection_supports is not None and collection_supports(): + calendar = collection + + resource_supports = getattr(resource, "supports_managed_attachments", None) + if calendar is None and resource_supports is not None and resource_supports(): + calendar = resource + + if calendar is None: + return webdav._send_simple_dav_error( + request, + "403 Forbidden", + error=ET.Element("{%s}supported-feature" % NAMESPACE), + description="Resource does not support managed attachments", + ) + + if action == "attachment-add": + return await self._handle_attachment_add(request, calendar, resource, rid) + elif action == "attachment-update": + return await self._handle_attachment_update( + request, calendar, resource, managed_id, rid + ) + elif action == "attachment-remove": + return await self._handle_attachment_remove( + request, calendar, resource, managed_id, rid + ) + else: + return webdav._send_simple_dav_error( + request, + "400 Bad Request", + error=ET.Element("{DAV:}bad-request"), + description="Missing or invalid 'action' parameter", + ) + + async def _handle_attachment_add(self, request, calendar, resource, rid=None): + """Handle attachment-add action.""" + # Read attachment data + attachment_data = await webdav._readBody(request) + if not attachment_data: + return webdav._send_simple_dav_error( + request, + "400 Bad Request", + error=ET.Element("{DAV:}bad-request"), + description="Empty attachment data", + ) + + # Check attachment size limits + try: + max_size = calendar.get_max_attachment_size() + if max_size and len(attachment_data) > max_size: + return webdav._send_simple_dav_error( + request, + "413 Request Entity Too Large", + error=ET.Element("{%s}max-attachment-size" % NAMESPACE), + description=f"Attachment size {len(attachment_data)} exceeds limit {max_size}", + ) + except NotImplementedError: + # No size limit defined + pass + + content_type = request.content_type or "application/octet-stream" + filename = request.headers.get("X-Filename") # Custom header for filename + + try: + # Create the attachment + managed_id, attachment_url = calendar.create_attachment( + attachment_data, content_type, filename + ) + except NotImplementedError: + return webdav._send_simple_dav_error( + request, + "501 Not Implemented", + error=ET.Element("{%s}supported-feature" % NAMESPACE), + description="Managed attachments not implemented", + ) + except webdav.OutOfSpaceError: + return webdav._send_simple_dav_error( + request, + "507 Insufficient Storage", + error=ET.Element("{DAV:}insufficient-storage"), + description="Insufficient storage for attachment", + ) + + try: + # Update the calendar resource to include the ATTACH property + from . import attachments + + cal = await calendar_from_resource(resource) + if not cal: + raise ValueError("Resource is not a valid calendar") + + await attachments.update_calendar_with_attachment( + resource, + cal, + managed_id, + attachment_url, + content_type, + filename, + len(attachment_data), + rid, + ) + except ValueError as e: + # Clean up the created attachment if we can't update the calendar + try: + calendar.delete_attachment(managed_id) + except NotImplementedError: + # Calendar doesn't support deleting attachments, continue + pass + except KeyError: + # Attachment already doesn't exist, continue + pass + return webdav._send_simple_dav_error( + request, + "400 Bad Request", + error=ET.Element("{DAV:}bad-request"), + description=str(e), + ) + + # Return success response with attachment URL + response_body = ET.Element("{%s}attachment-response" % NAMESPACE) + href_elem = ET.SubElement(response_body, "{DAV:}href") + href_elem.text = attachment_url + + return webdav._send_xml_response( + "201 Created", response_body, webdav.DEFAULT_ENCODING + ) + + async def _handle_attachment_update( + self, request, calendar, resource, managed_id, rid=None + ): + """Handle attachment-update action.""" + if not managed_id: + return webdav._send_simple_dav_error( + request, + "400 Bad Request", + error=ET.Element("{DAV:}bad-request"), + description="Missing 'managed-id' parameter for update", + ) + + # Read new attachment data + attachment_data = await webdav._readBody(request) + if not attachment_data: + return webdav._send_simple_dav_error( + request, + "400 Bad Request", + error=ET.Element("{DAV:}bad-request"), + description="Empty attachment data", + ) + + content_type = request.content_type or "application/octet-stream" + filename = request.headers.get("X-Filename") + + try: + # Update the attachment + calendar.update_attachment( + managed_id, attachment_data, content_type, filename + ) + except NotImplementedError: + return webdav._send_simple_dav_error( + request, + "501 Not Implemented", + error=ET.Element("{%s}supported-feature" % NAMESPACE), + description="Attachment updates not implemented", + ) + except KeyError: + return webdav._send_simple_dav_error( + request, + "404 Not Found", + error=ET.Element("{DAV:}not-found"), + description=f"Attachment with managed-id '{managed_id}' not found", + ) + except webdav.OutOfSpaceError: + return webdav._send_simple_dav_error( + request, + "507 Insufficient Storage", + error=ET.Element("{DAV:}insufficient-storage"), + description="Insufficient storage for attachment update", + ) + + try: + # Update the calendar resource ATTACH property + from . import attachments + + cal = await calendar_from_resource(resource) + if not cal: + raise ValueError("Resource is not a valid calendar") + + # First remove old attach property, then add new one + component = attachments.find_calendar_component(cal, rid) + if component: + attachments.remove_attach_from_component(component, managed_id) + attach_prop = attachments.create_attach_property( + f"{calendar.href}?action=attachment&managed-id={managed_id}", + managed_id, + len(attachment_data), + content_type, + filename, + ) + attachments.add_attach_to_component(component, attach_prop) + await resource.set_body(cal.to_ical(), replace_etag=True) + else: + raise ValueError("No suitable component found") + except ValueError as e: + return webdav._send_simple_dav_error( + request, + "400 Bad Request", + error=ET.Element("{DAV:}bad-request"), + description=str(e), + ) + + return webdav.Response(status="204 No Content") + + async def _handle_attachment_remove( + self, request, calendar, resource, managed_id, rid=None + ): + """Handle attachment-remove action.""" + if not managed_id: + return webdav._send_simple_dav_error( + request, + "400 Bad Request", + error=ET.Element("{DAV:}bad-request"), + description="Missing 'managed-id' parameter for removal", + ) + + try: + # Delete the attachment + calendar.delete_attachment(managed_id) + except NotImplementedError: + return webdav._send_simple_dav_error( + request, + "501 Not Implemented", + error=ET.Element("{%s}supported-feature" % NAMESPACE), + description="Attachment removal not implemented", + ) + except KeyError: + return webdav._send_simple_dav_error( + request, + "404 Not Found", + error=ET.Element("{DAV:}not-found"), + description=f"Attachment with managed-id '{managed_id}' not found", + ) + + try: + # Remove the ATTACH property from the calendar resource + from . import attachments + + cal = await calendar_from_resource(resource) + if not cal: + raise ValueError("Resource is not a valid calendar") + + await attachments.remove_attachment_from_calendar( + resource, cal, managed_id, rid + ) + except ValueError as e: + return webdav._send_simple_dav_error( + request, + "400 Bad Request", + error=ET.Element("{DAV:}bad-request"), + description=str(e), + ) + + return webdav.Response(status="204 No Content") + + +class CalendarAttachmentGetMethod(webdav.Method): + """GET method for CalDAV managed attachments. + + Handles retrieval of attachments via GET requests with managed-id parameter. + """ + + @property + def name(self): + return "GET" + + async def handle(self, request, environ, app): + import urllib.parse + + # Parse query parameters + query_params = urllib.parse.parse_qs(urllib.parse.urlparse(request.path).query) + action = query_params.get("action", [None])[0] + managed_id = query_params.get("managed-id", [None])[0] + + # Only handle attachment retrieval requests + if action != "attachment" or not managed_id: + # Let the default GET method handle this + return await webdav.GetMethod().handle(request, environ, app) + + href, path, resource = app._get_resource_from_environ(request, environ) + if resource is None: + return webdav._send_not_found(request) + + # Get the calendar containing this resource + calendar = None + collection = getattr(resource, "collection", None) + if collection is not None: + collection_supports = getattr( + collection, "supports_managed_attachments", None + ) + if collection_supports is not None and collection_supports(): + calendar = collection + + resource_supports = getattr(resource, "supports_managed_attachments", None) + if calendar is None and resource_supports is not None and resource_supports(): + calendar = resource + + if calendar is None: + return webdav._send_simple_dav_error( + request, + "403 Forbidden", + error=ET.Element("{%s}supported-feature" % NAMESPACE), + description="Resource does not support managed attachments", + ) + + try: + attachment_data, content_type, filename = calendar.get_attachment( + managed_id + ) + except KeyError: + return webdav._send_simple_dav_error( + request, + "404 Not Found", + error=ET.Element("{DAV:}not-found"), + description=f"Attachment with managed-id '{managed_id}' not found", + ) + + headers = [ + ("Content-Type", content_type), + ("Content-Length", str(len(attachment_data))), + ] + + if filename: + headers.append( + ("Content-Disposition", f'attachment; filename="{filename}"') + ) + + return webdav.Response(status="200 OK", headers=headers, body=[attachment_data]) diff --git a/xandikos/tests/test_caldav.py b/xandikos/tests/test_caldav.py index 39127f44..5e0e3fc2 100644 --- a/xandikos/tests/test_caldav.py +++ b/xandikos/tests/test_caldav.py @@ -622,3 +622,148 @@ def test_expand_invalid_time_range(self): # Should raise AssertionError for invalid time range with self.assertRaises(AssertionError): caldav.extract_from_calendar(incal, self.requested) + + +class CalendarAttachmentTests(unittest.TestCase): + """Tests for CalDAV managed attachments functionality.""" + + def setUp(self): + super().setUp() + import tempfile + from xandikos.web import CalendarCollection + from xandikos.store.vdir import VdirStore + + # Create a temporary directory for testing + self.temp_dir = tempfile.mkdtemp() + self.store = VdirStore(self.temp_dir) + + # Mock backend + class MockBackend: + pass + + backend = MockBackend() + self.calendar = CalendarCollection(backend, "/calendar", self.store) + self.calendar.href = "/calendar" + + def tearDown(self): + import shutil + + shutil.rmtree(self.temp_dir) + super().tearDown() + + def test_supports_managed_attachments(self): + """Test that CalendarCollection supports managed attachments.""" + self.assertTrue(self.calendar.supports_managed_attachments()) + + def test_get_managed_attachments_server_url(self): + """Test getting the managed attachments server URL.""" + url = self.calendar.get_managed_attachments_server_url() + self.assertEqual(url, "/calendar?action=attachment") + + def test_create_attachment(self): + """Test creating a new attachment.""" + attachment_data = b"test attachment data" + content_type = "text/plain" + filename = "test.txt" + + managed_id, attachment_url = self.calendar.create_attachment( + attachment_data, content_type, filename + ) + + self.assertIsNotNone(managed_id) + self.assertTrue(managed_id) # Should not be empty + self.assertEqual( + attachment_url, f"/calendar?action=attachment&managed-id={managed_id}" + ) + + def test_get_attachment(self): + """Test retrieving an attachment.""" + attachment_data = b"test attachment data" + content_type = "text/plain" + filename = "test.txt" + + # Create attachment + managed_id, _ = self.calendar.create_attachment( + attachment_data, content_type, filename + ) + + # Retrieve attachment + retrieved_data, retrieved_content_type, retrieved_filename = ( + self.calendar.get_attachment(managed_id) + ) + + self.assertEqual(retrieved_data, attachment_data) + self.assertEqual(retrieved_content_type, content_type) + self.assertEqual(retrieved_filename, filename) + + def test_get_nonexistent_attachment(self): + """Test retrieving a non-existent attachment raises KeyError.""" + with self.assertRaises(KeyError): + self.calendar.get_attachment("nonexistent-id") + + def test_update_attachment(self): + """Test updating an existing attachment.""" + # Create initial attachment + initial_data = b"initial data" + managed_id, _ = self.calendar.create_attachment( + initial_data, "text/plain", "initial.txt" + ) + + # Update attachment + updated_data = b"updated data" + updated_content_type = "text/plain" + updated_filename = "updated.txt" + + self.calendar.update_attachment( + managed_id, updated_data, updated_content_type, updated_filename + ) + + # Verify update + retrieved_data, retrieved_content_type, retrieved_filename = ( + self.calendar.get_attachment(managed_id) + ) + + self.assertEqual(retrieved_data, updated_data) + self.assertEqual(retrieved_content_type, updated_content_type) + self.assertEqual(retrieved_filename, updated_filename) + + def test_update_nonexistent_attachment(self): + """Test updating a non-existent attachment raises KeyError.""" + with self.assertRaises(KeyError): + self.calendar.update_attachment("nonexistent-id", b"data", "text/plain") + + def test_delete_attachment(self): + """Test deleting an attachment.""" + # Create attachment + managed_id, _ = self.calendar.create_attachment( + b"test data", "text/plain", "test.txt" + ) + + # Delete attachment + self.calendar.delete_attachment(managed_id) + + # Verify deletion + with self.assertRaises(KeyError): + self.calendar.get_attachment(managed_id) + + def test_delete_nonexistent_attachment(self): + """Test deleting a non-existent attachment raises KeyError.""" + with self.assertRaises(KeyError): + self.calendar.delete_attachment("nonexistent-id") + + def test_create_attachment_without_filename(self): + """Test creating an attachment without a filename.""" + attachment_data = b"test data" + content_type = "application/octet-stream" + + managed_id, _ = self.calendar.create_attachment( + attachment_data, content_type, None + ) + + retrieved_data, retrieved_content_type, retrieved_filename = ( + self.calendar.get_attachment(managed_id) + ) + + self.assertEqual(retrieved_data, attachment_data) + self.assertEqual(retrieved_content_type, content_type) + self.assertIsNone(retrieved_filename) diff --git a/xandikos/web.py b/xandikos/web.py index 28cd25f0..4b07d5d5 100644 --- a/xandikos/web.py +++ b/xandikos/web.py @@ -628,8 +628,45 @@ def get_schedule_calendar_transparency(self): return caldav.TRANSPARENCY_OPAQUE def get_managed_attachments_server_url(self): - # TODO(jelmer) - raise KeyError + # Return the attachment server URL for this calendar + # For simplicity, we'll use the same server as the calendar collection + return self.href + "?action=attachment" + + def supports_managed_attachments(self): + """Return True if this calendar supports managed attachments.""" + return True + + def _get_attachment_store(self): + """Get the attachment store for this calendar.""" + from . import attachments + + if not hasattr(self, "_attachment_store"): + self._attachment_store = attachments.AttachmentStore(self.store.path) + return self._attachment_store + + def create_attachment(self, attachment_data, content_type, filename=None): + """Create a new attachment and return its managed ID and URL.""" + store = self._get_attachment_store() + managed_id = store.create(attachment_data, content_type, filename) + attachment_url = f"{self.href}?action=attachment&managed-id={managed_id}" + return managed_id, attachment_url + + def get_attachment(self, managed_id): + """Get attachment data by managed ID.""" + store = self._get_attachment_store() + return store.get(managed_id) + + def delete_attachment(self, managed_id): + """Delete an attachment by managed ID.""" + store = self._get_attachment_store() + store.delete(managed_id) + + def update_attachment( + self, managed_id, attachment_data, content_type, filename=None + ): + """Update an existing attachment.""" + store = self._get_attachment_store() + store.update(managed_id, attachment_data, content_type, filename) def calendar_query(self, create_filter_fn): filter = create_filter_fn(CalendarFilter) @@ -1216,6 +1253,8 @@ def get_current_user_principal(env): self.register_methods( [ caldav.MkcalendarMethod(), + caldav.CalendarAttachmentPostMethod(), + caldav.CalendarAttachmentGetMethod(), ] ) diff --git a/xandikos/webdav.py b/xandikos/webdav.py index 033f14cf..2e23c324 100644 --- a/xandikos/webdav.py +++ b/xandikos/webdav.py @@ -2515,7 +2515,7 @@ def register_methods(self, methods): def _get_dav_features(self, resource): # TODO(jelmer): Support access-control - return [ + features = [ "1", "2", "3", @@ -2528,6 +2528,21 @@ def _get_dav_features(self, resource): "quota", ] + # Add managed attachments feature if supported by the resource + supports_attachments = getattr(resource, "supports_managed_attachments", None) + if supports_attachments is not None and supports_attachments(): + features.append("calendar-managed-attachments") + else: + collection = getattr(resource, "collection", None) + if collection is not None: + collection_supports = getattr( + collection, "supports_managed_attachments", None + ) + if collection_supports is not None and collection_supports(): + features.append("calendar-managed-attachments") + + return features + def _get_allowed_methods(self, request): """List of supported methods on this endpoint.""" ret = []