Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
269 changes: 269 additions & 0 deletions src/backend/core/api/viewsets/calendar.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
"""API ViewSet for calendar operations (RSVP, conflict detection, calendar listing)."""

import logging
from datetime import datetime

from django.shortcuts import get_object_or_404
from django.utils.functional import cached_property

from drf_spectacular.utils import (
extend_schema,
inline_serializer,
)
from rest_framework import permissions, status
from rest_framework import serializers as drf_serializers
from rest_framework.response import Response
from rest_framework.views import APIView

from core import models
from core.api.viewsets.task import register_task_owner
from core.services.calendar.tasks import calendar_add_event_task, calendar_rsvp_task

logger = logging.getLogger(__name__)


class CalDAVChannelMixin:
"""Mixin to get the CalDAV channel for a mailbox."""

@cached_property
def mailbox(self):
return get_object_or_404(models.Mailbox, id=self.kwargs["mailbox_id"])

def get_caldav_channel(self):
"""Get the CalDAV channel for the mailbox, or None."""
return (
models.Channel.objects.filter(
mailbox=self.mailbox, type="caldav"
).first()
)

def require_caldav_channel(self):
"""Get the CalDAV channel or raise 404."""
channel = self.get_caldav_channel()
if not channel:
from rest_framework.exceptions import NotFound
raise NotFound("No CalDAV calendar is configured for this mailbox.")
return channel
Comment on lines +25 to +46
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for existing mailbox authorization patterns in the codebase
rg -n -A5 "MailboxAccess" --type py src/backend/core/api/

Repository: suitenumerique/messages

Length of output: 17620


🏁 Script executed:

cat -n src/backend/core/api/viewsets/calendar.py

Repository: suitenumerique/messages

Length of output: 10817


Critical: Missing mailbox authorization check allows unauthorized access.

The CalDAVChannelMixin retrieves the mailbox by ID without verifying that the authenticated user has permission to access it. Any authenticated user can operate on any mailbox's CalDAV channel by simply knowing or guessing the mailbox_id UUID.

All four calendar views inherit this mixin with only IsAuthenticated permission checks:

  • CalendarRsvpView: Can RSVP on behalf of any mailbox
  • CalendarAddEventView: Can add events to any mailbox's calendar
  • CalendarConflictsView: Can view calendar conflicts for any mailbox
  • CalendarListView: Can list calendars for any mailbox

Apply the mailbox access check pattern established elsewhere in the codebase (e.g., blob.py, contacts.py) to the mixin's mailbox property:

Proposed fix
 class CalDAVChannelMixin:
     """Mixin to get the CalDAV channel for a mailbox."""

     `@cached_property`
     def mailbox(self):
-        return get_object_or_404(models.Mailbox, id=self.kwargs["mailbox_id"])
+        mailbox = get_object_or_404(models.Mailbox, id=self.kwargs["mailbox_id"])
+        # Verify user has access to this mailbox
+        if not models.MailboxAccess.objects.filter(
+            mailbox=mailbox, user=self.request.user
+        ).exists():
+            from rest_framework.exceptions import PermissionDenied
+            raise PermissionDenied("You do not have access to this mailbox.")
+        return mailbox
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
class CalDAVChannelMixin:
"""Mixin to get the CalDAV channel for a mailbox."""
@cached_property
def mailbox(self):
return get_object_or_404(models.Mailbox, id=self.kwargs["mailbox_id"])
def get_caldav_channel(self):
"""Get the CalDAV channel for the mailbox, or None."""
return (
models.Channel.objects.filter(
mailbox=self.mailbox, type="caldav"
).first()
)
def require_caldav_channel(self):
"""Get the CalDAV channel or raise 404."""
channel = self.get_caldav_channel()
if not channel:
from rest_framework.exceptions import NotFound
raise NotFound("No CalDAV calendar is configured for this mailbox.")
return channel
class CalDAVChannelMixin:
"""Mixin to get the CalDAV channel for a mailbox."""
`@cached_property`
def mailbox(self):
mailbox = get_object_or_404(models.Mailbox, id=self.kwargs["mailbox_id"])
# Verify user has access to this mailbox
if not models.MailboxAccess.objects.filter(
mailbox=mailbox, user=self.request.user
).exists():
from rest_framework.exceptions import PermissionDenied
raise PermissionDenied("You do not have access to this mailbox.")
return mailbox
def get_caldav_channel(self):
"""Get the CalDAV channel for the mailbox, or None."""
return (
models.Channel.objects.filter(
mailbox=self.mailbox, type="caldav"
).first()
)
def require_caldav_channel(self):
"""Get the CalDAV channel or raise 404."""
channel = self.get_caldav_channel()
if not channel:
from rest_framework.exceptions import NotFound
raise NotFound("No CalDAV calendar is configured for this mailbox.")
return channel
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/backend/core/api/viewsets/calendar.py` around lines 25 - 46, The mailbox
lookup in CalDAVChannelMixin is missing an authorization check allowing any
authenticated user to access arbitrary mailboxes; update the cached_property
mailbox to resolve the Mailbox through the same authorized/queryset pattern used
in blob.py and contacts.py (i.e., restrict the queryset to mailboxes the current
request.user may access or call the shared helper that returns an authorized
mailbox) so that get_object_or_404 only returns a mailbox the requester is
permitted to use; leave get_caldav_channel and require_caldav_channel unchanged
except that they should rely on the now-authorized mailbox property.



@extend_schema(tags=["calendar"])
class CalendarRsvpView(CalDAVChannelMixin, APIView):
"""Submit an RSVP response to a calendar event."""

permission_classes = [permissions.IsAuthenticated]

@extend_schema(
request=inline_serializer(
name="CalendarRsvpRequest",
fields={
"ics_data": drf_serializers.CharField(
help_text="Raw ICS content of the event"
),
"response": drf_serializers.ChoiceField(
choices=["ACCEPTED", "DECLINED", "TENTATIVE"],
help_text="RSVP response",
),
"calendar_id": drf_serializers.CharField(
required=False,
allow_null=True,
help_text="Optional specific calendar URL",
),
},
),
responses={
200: inline_serializer(
name="CalendarRsvpResponse",
fields={
"task_id": drf_serializers.CharField(),
},
),
},
)
def post(self, request, mailbox_id):
ics_data = request.data.get("ics_data")
response_type = request.data.get("response")
calendar_id = request.data.get("calendar_id")

if not ics_data or not response_type:
return Response(
{"detail": "ics_data and response are required."},
status=status.HTTP_400_BAD_REQUEST,
)

if response_type not in ("ACCEPTED", "DECLINED", "TENTATIVE"):
return Response(
{"detail": "response must be ACCEPTED, DECLINED, or TENTATIVE."},
status=status.HTTP_400_BAD_REQUEST,
)

channel = self.require_caldav_channel()

# Use the mailbox email as the attendee email
attendee_email = str(self.mailbox)

task = calendar_rsvp_task.delay(
channel_id=str(channel.id),
ics_data=ics_data,
response=response_type,
attendee_email=attendee_email,
calendar_id=calendar_id,
)
register_task_owner(task.id, request.user.id)

return Response({"task_id": task.id}, status=status.HTTP_200_OK)


@extend_schema(tags=["calendar"])
class CalendarAddEventView(CalDAVChannelMixin, APIView):
"""Add an event to a CalDAV calendar."""

permission_classes = [permissions.IsAuthenticated]

@extend_schema(
request=inline_serializer(
name="CalendarAddEventRequest",
fields={
"ics_data": drf_serializers.CharField(
help_text="Raw ICS content of the event"
),
"calendar_id": drf_serializers.CharField(
required=False,
allow_null=True,
help_text="Optional specific calendar URL",
),
},
),
responses={
200: inline_serializer(
name="CalendarAddEventResponse",
fields={
"task_id": drf_serializers.CharField(),
},
),
},
)
def post(self, request, mailbox_id):
ics_data = request.data.get("ics_data")
calendar_id = request.data.get("calendar_id")

if not ics_data:
return Response(
{"detail": "ics_data is required."},
status=status.HTTP_400_BAD_REQUEST,
)

channel = self.require_caldav_channel()

task = calendar_add_event_task.delay(
channel_id=str(channel.id),
ics_data=ics_data,
calendar_id=calendar_id,
)
register_task_owner(task.id, request.user.id)

return Response({"task_id": task.id}, status=status.HTTP_200_OK)


@extend_schema(tags=["calendar"])
class CalendarConflictsView(CalDAVChannelMixin, APIView):
"""Check for conflicting events in a given time range."""

permission_classes = [permissions.IsAuthenticated]

@extend_schema(
request=inline_serializer(
name="CalendarConflictsRequest",
fields={
"start": drf_serializers.DateTimeField(
help_text="Start of the time range (ISO 8601)"
),
"end": drf_serializers.DateTimeField(
help_text="End of the time range (ISO 8601)"
),
},
),
responses={
200: inline_serializer(
name="CalendarConflictsResponse",
fields={
"conflicts": drf_serializers.ListField(
child=drf_serializers.DictField()
),
},
),
},
)
def post(self, request, mailbox_id):
start = request.data.get("start")
end = request.data.get("end")

if not start or not end:
return Response(
{"detail": "start and end are required."},
status=status.HTTP_400_BAD_REQUEST,
)

try:
if isinstance(start, str):
start = datetime.fromisoformat(start)
if isinstance(end, str):
end = datetime.fromisoformat(end)
except (ValueError, TypeError):
return Response(
{"detail": "start and end must be valid ISO 8601 datetimes."},
status=status.HTTP_400_BAD_REQUEST,
)

channel = self.require_caldav_channel()

from core.services.calendar.service import CalDAVService

try:
service = CalDAVService.from_channel(channel)
conflicts = service.check_conflicts(start=start, end=end)
except Exception as e:
logger.exception("Error checking calendar conflicts: %s", e)
return Response(
{"detail": "Failed to check for conflicts."},
status=status.HTTP_502_BAD_GATEWAY,
)
Comment on lines +219 to +229
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Blocking I/O call to external CalDAV server on request thread.

CalendarConflictsView makes a synchronous call to the external CalDAV server within the request-response cycle. If the CalDAV server is slow or unresponsive, this will block the request thread and degrade overall API responsiveness.

Consider either:

  1. Making this an async view using Django's async support
  2. Moving the conflict check to a Celery task (like RSVP and add-event)
  3. Adding a strict timeout to the CalDAV client (see earlier comment on service.py)

As per coding guidelines: "Use asynchronous views and Celery tasks for I/O-bound or long-running operations"

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/backend/core/api/viewsets/calendar.py` around lines 219 - 229,
CalendarConflictsView is performing a blocking call to the external CalDAV
server via CalDAVService.from_channel(...) and service.check_conflicts(...);
change this to avoid blocking the request thread by either converting the view
to an async Django view and awaiting an async CalDAVService.check_conflicts
(ensure CalDAVService implements an async API and enforces a network timeout),
or move the conflict check into a background Celery task (enqueue a task from
CalendarConflictsView that calls CalDAVService.check_conflicts and return a 202
with a task id), and in either case ensure CalDAVService has a strict
configurable timeout for network calls to prevent long waits.


return Response({"conflicts": conflicts}, status=status.HTTP_200_OK)


@extend_schema(tags=["calendar"])
class CalendarListView(CalDAVChannelMixin, APIView):
"""List available calendars on the CalDAV server."""

permission_classes = [permissions.IsAuthenticated]

@extend_schema(
responses={
200: inline_serializer(
name="CalendarListResponse",
fields={
"calendars": drf_serializers.ListField(
child=drf_serializers.DictField()
),
},
),
},
)
def get(self, request, mailbox_id):
channel = self.get_caldav_channel()
if not channel:
return Response({"calendars": []}, status=status.HTTP_200_OK)

from core.services.calendar.service import CalDAVService

try:
service = CalDAVService.from_channel(channel)
calendars = service.list_calendars()
except Exception as e:
logger.exception("Error listing calendars: %s", e)
return Response(
{"detail": "Failed to list calendars."},
status=status.HTTP_502_BAD_GATEWAY,
)
Comment on lines +259 to +267
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Same blocking I/O concern as CalendarConflictsView.

CalendarListView.get also makes synchronous calls to the external CalDAV server. The same recommendation applies: consider async handling or a task-based approach for consistency with other calendar operations.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/backend/core/api/viewsets/calendar.py` around lines 259 - 267,
CalendarListView.get is making blocking I/O via CalDAVService.from_channel and
service.list_calendars (synchronous calls) which can block the event loop;
refactor this handler to offload the blocking calls to an async-friendly
pattern—either convert CalDAVService methods to async or run the synchronous
calls in a thread/process executor (e.g., using asyncio.to_thread or a
background task) before returning the Response; specifically wrap
CalDAVService.from_channel(...) and service.list_calendars() so they execute off
the main thread and handle/propagate exceptions the same way as existing
logger.exception and Response logic.


return Response({"calendars": calendars}, status=status.HTTP_200_OK)
Empty file.
Loading
Loading