(all) add organizations, resources, channels, and infra migration (#34)

Add multi-tenant organization model populated from OIDC claims with
org-scoped user discovery, CalDAV principal filtering, and cross-org
isolation at the SabreDAV layer.

Add bookable resource principals (rooms, equipment) with CalDAV
auto-scheduling that handles conflict detection, auto-accept/decline,
and org-scoped booking enforcement. Fixes #14.

Replace CalendarSubscriptionToken with a unified Channel model
supporting CalDAV integration tokens and iCal feed URLs, with
encrypted token storage and role-based access control. Fixes #16.

Migrate task queue from Celery to Dramatiq with async ICS import,
progress tracking, and task status polling endpoint.

Replace nginx with Caddy for both the reverse proxy and frontend
static serving. Switch frontend package manager from yarn/pnpm to
npm and upgrade Node to 24, Next.js to 16, TypeScript to 5.9.

Harden security with fail-closed entitlements, RSVP rate limiting
and token expiry, CalDAV proxy path validation blocking internal
API routes, channel path scope enforcement, and ETag-based
conflict prevention.

Add frontend pages for resource management and integration channel
CRUD, with resource booking in the event modal.

Restructure CalDAV paths to /calendars/users/ and
/calendars/resources/ with nested principal collections in SabreDAV.
This commit is contained in:
Sylvain Zimmer
2026-03-09 09:09:34 +01:00
committed by GitHub
parent cd2b15b3b5
commit 9c18f96090
176 changed files with 26903 additions and 12108 deletions

View File

@@ -94,9 +94,6 @@ WORKDIR /app
ENV PATH="/app/.venv/bin:$PATH"
# Generate compiled translation messages
RUN DJANGO_CONFIGURATION=Build \
python manage.py compilemessages --ignore=".venv/**/*"
# We wrap commands run in this container by the following entrypoint that

View File

@@ -1,5 +1 @@
"""Calendars package. Import the celery app early to load shared task form dependencies."""
from .celery_app import app as celery_app
__all__ = ["celery_app"]
"""Calendars Django project package."""

View File

@@ -1,26 +0,0 @@
"""Calendars celery configuration file."""
import os
from celery import Celery
from configurations.importer import install
# Set the default Django settings module for the 'celery' program.
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "calendars.settings")
os.environ.setdefault("DJANGO_CONFIGURATION", "Development")
install(check_options=True)
# Can be loaded only after install call.
from django.conf import settings # pylint: disable=wrong-import-position
app = Celery("calendars")
# Using a string here means the worker doesn't have to serialize
# the configuration object to child processes.
# - namespace='CELERY' means all celery-related configuration keys
# should have a `CELERY_` prefix.
app.config_from_object("django.conf:settings", namespace="CELERY")
# Load task modules from all registered Django apps.
app.autodiscover_tasks(lambda: settings.INSTALLED_APPS)

View File

@@ -14,8 +14,6 @@ import os
import tomllib
from socket import gethostbyname, gethostname
from django.utils.translation import gettext_lazy as _
import dj_database_url
import sentry_sdk
from configurations import Configuration, values
@@ -74,13 +72,24 @@ class Base(Configuration):
# CalDAV API keys for bidirectional authentication
# INBOUND: API key for authenticating requests FROM CalDAV server TO Django
CALDAV_INBOUND_API_KEY = values.Value(
CALDAV_INBOUND_API_KEY = SecretFileValue(
None, environ_name="CALDAV_INBOUND_API_KEY", environ_prefix=None
)
# OUTBOUND: API key for authenticating requests FROM Django TO CalDAV server
CALDAV_OUTBOUND_API_KEY = values.Value(
CALDAV_OUTBOUND_API_KEY = SecretFileValue(
None, environ_name="CALDAV_OUTBOUND_API_KEY", environ_prefix=None
)
# INTERNAL: API key for Django → CalDAV internal API (resource provisioning, import)
CALDAV_INTERNAL_API_KEY = SecretFileValue(
None, environ_name="CALDAV_INTERNAL_API_KEY", environ_prefix=None
)
# Salt for django-fernet-encrypted-fields (Channel tokens, etc.)
# Used with SECRET_KEY to derive Fernet encryption keys via PBKDF2
SALT_KEY = values.Value(
"calendars-default-salt-change-in-production",
environ_name="SALT_KEY",
environ_prefix=None,
)
# Base URL for CalDAV scheduling callbacks (must be accessible from CalDAV container)
# In Docker environments, use the internal Docker network URL (e.g., http://backend:8000)
CALDAV_CALLBACK_BASE_URL = values.Value(
@@ -117,7 +126,7 @@ class Base(Configuration):
CALENDAR_INVITATION_FROM_EMAIL = values.Value(
None, environ_name="CALENDAR_INVITATION_FROM_EMAIL", environ_prefix=None
)
APP_NAME = values.Value("Calendrier", environ_name="APP_NAME", environ_prefix=None)
APP_NAME = values.Value("Calendars", environ_name="APP_NAME", environ_prefix=None)
APP_URL = values.Value("", environ_name="APP_URL", environ_prefix=None)
CALENDAR_ITIP_ENABLED = values.BooleanValue(
False, environ_name="CALENDAR_ITIP_ENABLED", environ_prefix=None
@@ -133,6 +142,18 @@ class Base(Configuration):
environ_prefix=None,
)
# Organizations
OIDC_USERINFO_ORGANIZATION_CLAIM = values.Value(
"",
environ_name="OIDC_USERINFO_ORGANIZATION_CLAIM",
environ_prefix=None,
)
RESOURCE_EMAIL_DOMAIN = values.Value(
"",
environ_name="RESOURCE_EMAIL_DOMAIN",
environ_prefix=None,
)
# Entitlements
ENTITLEMENTS_BACKEND = values.Value(
"core.entitlements.backends.local.LocalEntitlementsBackend",
@@ -212,7 +233,7 @@ class Base(Configuration):
# This is used to limit the size of the request body in memory.
# This also limits the size of the file that can be uploaded to the server.
DATA_UPLOAD_MAX_MEMORY_SIZE = values.PositiveIntegerValue(
2 * (2**30), # 2GB
20 * (2**20), # 20MB
environ_name="DATA_UPLOAD_MAX_MEMORY_SIZE",
environ_prefix=None,
)
@@ -234,15 +255,13 @@ class Base(Configuration):
# fallback/default languages throughout the app.
LANGUAGES = values.SingleNestedTupleValue(
(
("en-us", _("English")),
("fr-fr", _("French")),
("de-de", _("German")),
("nl-nl", _("Dutch")),
("en-us", "English"),
("fr-fr", "French"),
("de-de", "German"),
("nl-nl", "Dutch"),
)
)
LOCALE_PATHS = (os.path.join(BASE_DIR, "locale"),)
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
@@ -275,7 +294,6 @@ class Base(Configuration):
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.locale.LocaleMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"corsheaders.middleware.CorsMiddleware",
"django.middleware.common.CommonMiddleware",
@@ -296,7 +314,7 @@ class Base(Configuration):
"drf_standardized_errors",
# Third party apps
"corsheaders",
"django_celery_beat",
"django_dramatiq",
"django_filters",
"rest_framework",
"rest_framework_api_key",
@@ -415,7 +433,11 @@ class Base(Configuration):
)
AUTH_USER_MODEL = "core.User"
INVITATION_VALIDITY_DURATION = 604800 # 7 days, in seconds
RSVP_TOKEN_MAX_AGE_RECURRING = values.PositiveIntegerValue(
7776000, # 90 days
environ_name="RSVP_TOKEN_MAX_AGE_RECURRING",
environ_prefix=None,
)
# CORS
CORS_ALLOW_CREDENTIALS = True
@@ -514,10 +536,40 @@ class Base(Configuration):
THUMBNAIL_DEFAULT_STORAGE_ALIAS = "default"
THUMBNAIL_ALIASES = {}
# Celery
CELERY_BROKER_URL = values.Value("redis://redis:6379/0")
CELERY_BROKER_TRANSPORT_OPTIONS = values.DictValue({})
CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"
# Dramatiq
DRAMATIQ_BROKER = {
"BROKER": "dramatiq.brokers.redis.RedisBroker",
"OPTIONS": {
"url": values.Value(
"redis://redis:6379/0",
environ_name="DRAMATIQ_BROKER_URL",
environ_prefix=None,
),
},
"MIDDLEWARE": [
"dramatiq.middleware.AgeLimit",
"dramatiq.middleware.TimeLimit",
"dramatiq.middleware.Callbacks",
"dramatiq.middleware.Retries",
"dramatiq.middleware.CurrentMessage",
"django_dramatiq.middleware.DbConnectionsMiddleware",
"django_dramatiq.middleware.AdminMiddleware",
],
}
DRAMATIQ_RESULT_BACKEND = {
"BACKEND": "dramatiq.results.backends.redis.RedisBackend",
"BACKEND_OPTIONS": {
"url": values.Value(
"redis://redis:6379/1",
environ_name="DRAMATIQ_RESULT_BACKEND_URL",
environ_prefix=None,
),
},
"MIDDLEWARE_OPTIONS": {
"result_ttl": 1000 * 60 * 60 * 24 * 30, # 30 days
},
}
DRAMATIQ_AUTODISCOVER_MODULES = ["tasks"]
# Session
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
@@ -635,12 +687,6 @@ class Base(Configuration):
environ_name="OIDC_USERINFO_FULLNAME_FIELDS",
environ_prefix=None,
)
OIDC_USERINFO_SHORTNAME_FIELD = values.Value(
default="first_name",
environ_name="OIDC_USERINFO_SHORTNAME_FIELD",
environ_prefix=None,
)
# OIDC Resource Server
OIDC_RESOURCE_SERVER_ENABLED = values.BooleanValue(
@@ -870,7 +916,7 @@ class Development(Base):
ALLOWED_HOSTS = ["*"]
CORS_ALLOW_ALL_ORIGINS = True
CSRF_TRUSTED_ORIGINS = [
"http://localhost:8920",
"http://localhost:8930",
"http://localhost:3000",
]
DEBUG = True
@@ -887,8 +933,8 @@ class Development(Base):
EMAIL_USE_SSL = False
DEFAULT_FROM_EMAIL = "calendars@calendars.world"
CALENDAR_INVITATION_FROM_EMAIL = "calendars@calendars.world"
APP_NAME = "Calendrier (Dev)"
APP_URL = "http://localhost:8921"
APP_NAME = "Calendars (dev)"
APP_URL = "http://localhost:8931"
DEBUG_TOOLBAR_CONFIG = {
"SHOW_TOOLBAR_CALLBACK": lambda request: True,
@@ -919,7 +965,18 @@ class Test(Base):
]
USE_SWAGGER = True
CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True)
DRAMATIQ_BROKER = {
"BROKER": "core.task_utils.EagerBroker",
"OPTIONS": {},
"MIDDLEWARE": [
"dramatiq.middleware.CurrentMessage",
],
}
DRAMATIQ_RESULT_BACKEND = {
"BACKEND": "dramatiq.results.backends.stub.StubBackend",
"BACKEND_OPTIONS": {},
"MIDDLEWARE_OPTIONS": {"result_ttl": 1000 * 60 * 10},
}
OIDC_STORE_ACCESS_TOKEN = False
OIDC_STORE_REFRESH_TOKEN = False
@@ -977,6 +1034,7 @@ class Production(Base):
"^__lbheartbeat__",
"^__heartbeat__",
r"^api/v1\.0/caldav-scheduling-callback/",
r"^caldav/",
]
# Modern browsers require to have the `secure` attribute on cookies with `Samesite=none`

View File

@@ -2,7 +2,6 @@
from django.contrib import admin
from django.contrib.auth import admin as auth_admin
from django.utils.translation import gettext_lazy as _
from . import models
@@ -23,20 +22,19 @@ class UserAdmin(auth_admin.UserAdmin):
},
),
(
_("Personal info"),
"Personal info",
{
"fields": (
"sub",
"email",
"full_name",
"short_name",
"language",
"timezone",
)
},
),
(
_("Permissions"),
"Permissions",
{
"fields": (
"is_active",
@@ -48,7 +46,7 @@ class UserAdmin(auth_admin.UserAdmin):
),
},
),
(_("Important dates"), {"fields": ("created_at", "updated_at")}),
("Important dates", {"fields": ("created_at", "updated_at")}),
)
add_fieldsets = (
(
@@ -86,27 +84,27 @@ class UserAdmin(auth_admin.UserAdmin):
"sub",
"email",
"full_name",
"short_name",
"created_at",
"updated_at",
)
search_fields = ("id", "sub", "admin_email", "email", "full_name")
@admin.register(models.CalendarSubscriptionToken)
class CalendarSubscriptionTokenAdmin(admin.ModelAdmin):
"""Admin class for CalendarSubscriptionToken model."""
@admin.register(models.Channel)
class ChannelAdmin(admin.ModelAdmin):
"""Admin class for Channel model."""
list_display = (
"calendar_name",
"owner",
"name",
"type",
"organization",
"user",
"caldav_path",
"token",
"is_active",
"last_accessed_at",
"last_used_at",
"created_at",
)
list_filter = ("is_active",)
search_fields = ("calendar_name", "owner__email", "caldav_path", "token")
readonly_fields = ("id", "token", "created_at", "last_accessed_at")
raw_id_fields = ("owner",)
list_filter = ("type", "is_active")
search_fields = ("name", "user__email", "caldav_path")
readonly_fields = ("id", "created_at", "updated_at", "last_used_at")
raw_id_fields = ("user", "organization")

View File

@@ -2,19 +2,12 @@
import logging
from django.core import exceptions
from rest_framework import permissions
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
logger = logging.getLogger(__name__)
ACTION_FOR_METHOD_TO_PERMISSION = {
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"},
"children": {"GET": "children_list", "POST": "children_create"},
}
class IsAuthenticated(permissions.BasePermission):
"""
@@ -26,15 +19,6 @@ class IsAuthenticated(permissions.BasePermission):
return bool(request.auth) or request.user.is_authenticated
class IsAuthenticatedOrSafe(IsAuthenticated):
"""Allows access to authenticated users (or anonymous users but only on safe methods)."""
def has_permission(self, request, view):
if request.method in permissions.SAFE_METHODS:
return True
return super().has_permission(request, view)
class IsSelf(IsAuthenticated):
"""
Allows access only to authenticated users. Alternative method checking the presence
@@ -46,27 +30,7 @@ class IsSelf(IsAuthenticated):
return obj == request.user
class IsOwnedOrPublic(IsAuthenticated):
"""
Allows access to authenticated users only for objects that are owned or not related
to any user via the "owner" field.
"""
def has_object_permission(self, request, view, obj):
"""Unsafe permissions are only allowed for the owner of the object."""
if obj.owner == request.user:
return True
if request.method in permissions.SAFE_METHODS and obj.owner is None:
return True
try:
return obj.user == request.user
except exceptions.ObjectDoesNotExist:
return False
class IsEntitled(IsAuthenticated):
class IsEntitledToAccess(IsAuthenticated):
"""Allows access only to users with can_access entitlement.
Fail-closed: denies access when the entitlements service is
@@ -78,25 +42,31 @@ class IsEntitled(IsAuthenticated):
return False
try:
entitlements = get_user_entitlements(request.user.sub, request.user.email)
return entitlements.get("can_access", True)
return entitlements.get("can_access", False)
except EntitlementsUnavailableError:
logger.warning(
"Entitlements unavailable, denying access for user %s",
request.user.pk,
)
return False
class AccessPermission(permissions.BasePermission):
"""Permission class for access objects."""
class IsOrgAdmin(IsAuthenticated):
"""Allows access only to users with can_admin entitlement.
Fail-closed: denies access when the entitlements service is
unavailable and no cached value exists.
"""
def has_permission(self, request, view):
return request.user.is_authenticated or view.action not in [
"create",
]
def has_object_permission(self, request, view, obj):
"""Check permission for a given object."""
abilities = obj.get_abilities(request.user)
action = view.action
if not super().has_permission(request, view):
return False
try:
action = ACTION_FOR_METHOD_TO_PERMISSION[view.action][request.method]
except KeyError:
pass
return abilities.get(action, False)
entitlements = get_user_entitlements(request.user.sub, request.user.email)
return entitlements.get("can_admin", False)
except EntitlementsUnavailableError:
logger.warning(
"Entitlements unavailable, denying admin for user %s",
request.user.pk,
)
return False

View File

@@ -1,13 +1,22 @@
"""Client serializers for the calendars core app."""
from django.conf import settings
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from django.utils.text import slugify
from rest_framework import exceptions, serializers
from rest_framework import serializers
from core import models
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
from core.models import uuid_to_urlsafe
class OrganizationSerializer(serializers.ModelSerializer):
"""Serialize organizations."""
class Meta:
model = models.Organization
fields = ["id", "name"]
read_only_fields = ["id", "name"]
class UserLiteSerializer(serializers.ModelSerializer):
@@ -15,171 +24,174 @@ class UserLiteSerializer(serializers.ModelSerializer):
class Meta:
model = models.User
fields = ["id", "full_name", "short_name"]
read_only_fields = ["id", "full_name", "short_name"]
class BaseAccessSerializer(serializers.ModelSerializer):
"""Serialize template accesses."""
abilities = serializers.SerializerMethodField(read_only=True)
def update(self, instance, validated_data):
"""Make "user" field is readonly but only on update."""
validated_data.pop("user", None)
return super().update(instance, validated_data)
def get_abilities(self, access) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
return access.get_abilities(request.user)
return {}
def validate(self, attrs):
"""
Check access rights specific to writing (create/update)
"""
request = self.context.get("request")
user = getattr(request, "user", None)
role = attrs.get("role")
# Update
if self.instance:
can_set_role_to = self.instance.get_abilities(user)["set_role_to"]
if role and role not in can_set_role_to:
message = (
f"You are only allowed to set role to {', '.join(can_set_role_to)}"
if can_set_role_to
else "You are not allowed to set this role for this template."
)
raise exceptions.PermissionDenied(message)
# Create
else:
try:
resource_id = self.context["resource_id"]
except KeyError as exc:
raise exceptions.ValidationError(
"You must set a resource ID in kwargs to create a new access."
) from exc
if not self.Meta.model.objects.filter( # pylint: disable=no-member
Q(user=user) | Q(team__in=user.teams),
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
**{self.Meta.resource_field_name: resource_id}, # pylint: disable=no-member
).exists():
raise exceptions.PermissionDenied(
"You are not allowed to manage accesses for this resource."
)
if (
role == models.RoleChoices.OWNER
and not self.Meta.model.objects.filter( # pylint: disable=no-member
Q(user=user) | Q(team__in=user.teams),
role=models.RoleChoices.OWNER,
**{self.Meta.resource_field_name: resource_id}, # pylint: disable=no-member
).exists()
):
raise exceptions.PermissionDenied(
"Only owners of a resource can assign other users as owners."
)
# pylint: disable=no-member
attrs[f"{self.Meta.resource_field_name}_id"] = self.context["resource_id"]
return attrs
fields = ["id", "full_name"]
read_only_fields = ["id", "full_name"]
class UserSerializer(serializers.ModelSerializer):
"""Serialize users."""
email = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.User
fields = [
"id",
"email",
"full_name",
"short_name",
"language",
]
read_only_fields = ["id", "email", "full_name", "short_name"]
read_only_fields = ["id", "email", "full_name"]
def get_email(self, user) -> str | None:
"""Return OIDC email, falling back to admin_email for staff users."""
return user.email or user.admin_email
class UserMeSerializer(UserSerializer):
"""Serialize users for me endpoint."""
can_access = serializers.SerializerMethodField(read_only=True)
can_admin = serializers.SerializerMethodField(read_only=True)
organization = OrganizationSerializer(read_only=True)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._entitlements_cache = {}
class Meta:
model = models.User
fields = [*UserSerializer.Meta.fields, "can_access"]
read_only_fields = [*UserSerializer.Meta.read_only_fields, "can_access"]
fields = [
*UserSerializer.Meta.fields,
"can_access",
"can_admin",
"organization",
]
read_only_fields = [
*UserSerializer.Meta.read_only_fields,
"can_access",
"can_admin",
"organization",
]
def _get_entitlements(self, user):
"""Get cached entitlements for the user, keyed by user.sub.
Cache is per-serializer-instance (request-scoped) to avoid
duplicate calls when both can_access and can_admin are serialized.
"""
if user.sub not in self._entitlements_cache:
try:
self._entitlements_cache[user.sub] = get_user_entitlements(
user.sub, user.email
)
except EntitlementsUnavailableError:
self._entitlements_cache[user.sub] = None
return self._entitlements_cache[user.sub]
def get_can_access(self, user) -> bool:
"""Check entitlements for the current user."""
try:
entitlements = get_user_entitlements(user.sub, user.email)
return entitlements.get("can_access", True)
except EntitlementsUnavailableError:
return True # fail-open
entitlements = self._get_entitlements(user)
if entitlements is None:
return False # fail-closed
return entitlements.get("can_access", False)
def get_can_admin(self, user) -> bool:
"""Check admin entitlement for the current user."""
entitlements = self._get_entitlements(user)
if entitlements is None:
return False # fail-closed
return entitlements.get("can_admin", False)
class CalendarSubscriptionTokenSerializer(serializers.ModelSerializer):
"""Serializer for CalendarSubscriptionToken model."""
class ChannelSerializer(serializers.ModelSerializer):
"""Read serializer for Channel model."""
role = serializers.SerializerMethodField()
url = serializers.SerializerMethodField()
class Meta:
model = models.CalendarSubscriptionToken
model = models.Channel
fields = [
"token",
"url",
"id",
"name",
"type",
"organization",
"user",
"caldav_path",
"calendar_name",
"role",
"is_active",
"last_accessed_at",
"created_at",
]
read_only_fields = [
"token",
"settings",
"url",
"caldav_path",
"calendar_name",
"is_active",
"last_accessed_at",
"last_used_at",
"created_at",
"updated_at",
]
read_only_fields = fields
def get_role(self, obj):
"""Get role from settings."""
return obj.role
def get_url(self, obj) -> str | None:
"""Build iCal subscription URL for ical-feed channels, None otherwise."""
if obj.type != "ical-feed":
return None
token = obj.encrypted_settings.get("token", "")
if not token:
return None
short_id = uuid_to_urlsafe(obj.pk)
calendar_name = obj.settings.get("calendar_name", "")
filename = slugify(calendar_name)[:50] or "feed"
ical_path = f"/ical/{short_id}/{token}/{filename}.ics"
def get_url(self, obj) -> str:
"""Build the full subscription URL, enforcing HTTPS in production."""
request = self.context.get("request")
if request:
url = request.build_absolute_uri(f"/ical/{obj.token}.ics")
url = request.build_absolute_uri(ical_path)
else:
# Fallback to APP_URL if no request context
app_url = getattr(settings, "APP_URL", "")
url = f"{app_url.rstrip('/')}/ical/{obj.token}.ics"
app_url = settings.APP_URL
url = f"{app_url.rstrip('/')}{ical_path}"
# Force HTTPS in production to protect the token in transit
if not settings.DEBUG and url.startswith("http://"):
url = url.replace("http://", "https://", 1)
return url
class CalendarSubscriptionTokenCreateSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""Serializer for creating a CalendarSubscriptionToken."""
class ChannelCreateSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""Write serializer for creating a Channel."""
caldav_path = serializers.CharField(max_length=512)
name = serializers.CharField(max_length=255)
type = serializers.CharField(max_length=255, default="caldav")
caldav_path = serializers.CharField(max_length=512, required=False, default="")
calendar_name = serializers.CharField(max_length=255, required=False, default="")
role = serializers.ChoiceField(
choices=[(r, r) for r in models.Channel.VALID_ROLES],
default=models.Channel.ROLE_READER,
)
def validate_caldav_path(self, value):
"""Validate and normalize the caldav_path."""
# Normalize path to always have trailing slash
if not value.endswith("/"):
value = value + "/"
# Normalize path to always start with /
if not value.startswith("/"):
value = "/" + value
"""Normalize caldav_path if provided."""
if value:
if not value.endswith("/"):
value = value + "/"
if not value.startswith("/"):
value = "/" + value
return value
def validate_type(self, value):
"""Validate channel type."""
if value == "ical-feed":
return value
return "caldav"
class ChannelWithTokenSerializer(ChannelSerializer):
"""Serializer that includes the plaintext token (used only on creation)."""
token = serializers.CharField(read_only=True)
class Meta(ChannelSerializer.Meta):
fields = [*ChannelSerializer.Meta.fields, "token"]

View File

@@ -1,5 +1,4 @@
"""API endpoints"""
# pylint: disable=too-many-lines
import json
import logging
@@ -8,9 +7,7 @@ from django.conf import settings
from django.core.cache import cache
from django.utils.text import slugify
import rest_framework as drf
from rest_framework import response as drf_response
from rest_framework import status, viewsets
from rest_framework import mixins, pagination, response, status, views, viewsets
from rest_framework.decorators import action
from rest_framework.parsers import MultiPartParser
from rest_framework.permissions import AllowAny, IsAuthenticated
@@ -21,7 +18,8 @@ from core.services.caldav_service import (
normalize_caldav_path,
verify_caldav_access,
)
from core.services.import_service import MAX_FILE_SIZE, ICSImportService
from core.services.import_service import MAX_FILE_SIZE
from core.services.resource_service import ResourceProvisioningError, ResourceService
from . import permissions, serializers
@@ -31,60 +29,6 @@ logger = logging.getLogger(__name__)
# pylint: disable=too-many-ancestors
class NestedGenericViewSet(viewsets.GenericViewSet):
"""
A generic Viewset aims to be used in a nested route context.
e.g: `/api/v1.0/resource_1/<resource_1_pk>/resource_2/<resource_2_pk>/`
It allows to define all url kwargs and lookup fields to perform the lookup.
"""
lookup_fields: list[str] = ["pk"]
lookup_url_kwargs: list[str] = []
def __getattribute__(self, item):
"""
This method is overridden to allow to get the last lookup field or lookup url kwarg
when accessing the `lookup_field` or `lookup_url_kwarg` attribute. This is useful
to keep compatibility with all methods used by the parent class `GenericViewSet`.
"""
if item in ["lookup_field", "lookup_url_kwarg"]:
return getattr(self, item + "s", [None])[-1]
return super().__getattribute__(item)
def get_queryset(self):
"""
Get the list of items for this view.
`lookup_fields` attribute is enumerated here to perform the nested lookup.
"""
queryset = super().get_queryset()
# The last lookup field is removed to perform the nested lookup as it corresponds
# to the object pk, it is used within get_object method.
lookup_url_kwargs = (
self.lookup_url_kwargs[:-1]
if self.lookup_url_kwargs
else self.lookup_fields[:-1]
)
filter_kwargs = {}
for index, lookup_url_kwarg in enumerate(lookup_url_kwargs):
if lookup_url_kwarg not in self.kwargs:
raise KeyError(
f"Expected view {self.__class__.__name__} to be called with a URL "
f'keyword argument named "{lookup_url_kwarg}". Fix your URL conf, or '
"set the `.lookup_fields` attribute on the view correctly."
)
filter_kwargs.update(
{self.lookup_fields[index]: self.kwargs[lookup_url_kwarg]}
)
return queryset.filter(**filter_kwargs)
class SerializerPerActionMixin:
"""
A mixin to allow to define serializer classes for each action.
@@ -110,10 +54,10 @@ class SerializerPerActionMixin:
return super().get_serializer_class()
class Pagination(drf.pagination.PageNumberPagination):
class Pagination(pagination.PageNumberPagination):
"""Pagination to display no more than 100 objects per page sorted by creation date."""
ordering = "-created_on"
ordering = "-created_at"
max_page_size = settings.MAX_PAGE_SIZE
page_size_query_param = "page_size"
@@ -132,9 +76,9 @@ class UserListThrottleSustained(UserRateThrottle):
class UserViewSet(
SerializerPerActionMixin,
drf.mixins.UpdateModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
drf.mixins.ListModelMixin,
mixins.ListModelMixin,
):
"""User ViewSet"""
@@ -142,7 +86,7 @@ class UserViewSet(
queryset = models.User.objects.all().filter(is_active=True)
serializer_class = serializers.UserSerializer
get_me_serializer_class = serializers.UserMeSerializer
pagination_class = None
pagination_class = Pagination
throttle_classes = []
def get_throttles(self):
@@ -155,6 +99,7 @@ class UserViewSet(
def get_queryset(self):
"""
Limit listed users by querying the email field.
Scoped to the requesting user's organization.
If query contains "@", search exactly. Otherwise return empty.
"""
queryset = self.queryset
@@ -162,19 +107,22 @@ class UserViewSet(
if self.action != "list":
return queryset
# Scope to same organization; users without an org see no results
if not self.request.user.organization_id:
return queryset.none()
queryset = queryset.filter(organization_id=self.request.user.organization_id)
if not (query := self.request.query_params.get("q", "")) or len(query) < 5:
return queryset.none()
# For emails, match exactly
if "@" in query:
return queryset.filter(email__iexact=query).order_by("email")[
: settings.API_USERS_LIST_LIMIT
]
return queryset.filter(email__iexact=query).order_by("email")
# For non-email queries, return empty (no fuzzy search)
return queryset.none()
@drf.decorators.action(
@action(
detail=False,
methods=["get"],
url_name="me",
@@ -185,12 +133,12 @@ class UserViewSet(
Return information on currently logged user
"""
context = {"request": request}
return drf.response.Response(
return response.Response(
self.get_serializer(request.user, context=context).data
)
class ConfigView(drf.views.APIView):
class ConfigView(views.APIView):
"""API ViewSet for sharing some public settings."""
permission_classes = [AllowAny]
@@ -224,7 +172,7 @@ class ConfigView(drf.views.APIView):
dict_settings["theme_customization"] = self._load_theme_customization()
return drf.response.Response(dict_settings)
return response.Response(dict_settings)
def _load_theme_customization(self):
if not settings.THEME_CUSTOMIZATION_FILE_PATH:
@@ -272,7 +220,7 @@ class CalendarViewSet(viewsets.GenericViewSet):
def get_permissions(self):
if self.action == "import_events":
return [permissions.IsEntitled()]
return [permissions.IsEntitledToAccess()]
return super().get_permissions()
@action(
@@ -286,12 +234,18 @@ class CalendarViewSet(viewsets.GenericViewSet):
"""Import events from an ICS file into a calendar.
POST /api/v1.0/calendars/import-events/
Body (multipart): file=<ics>, caldav_path=/calendars/user@.../uuid/
Body (multipart): file=<ics>, caldav_path=/calendars/users/user@.../uuid/
Returns a task_id that can be polled at GET /api/v1.0/tasks/{task_id}/
"""
from core.tasks import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
import_events_task,
)
caldav_path = request.data.get("caldav_path", "")
if not caldav_path:
return drf_response.Response(
{"error": "caldav_path is required"},
return response.Response(
{"detail": "caldav_path is required"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -299,15 +253,15 @@ class CalendarViewSet(viewsets.GenericViewSet):
# Verify user access
if not verify_caldav_access(request.user, caldav_path):
return drf_response.Response(
{"error": "You don't have access to this calendar"},
return response.Response(
{"detail": "You don't have access to this calendar"},
status=status.HTTP_403_FORBIDDEN,
)
# Validate file presence
if "file" not in request.FILES:
return drf_response.Response(
{"error": "No file provided"},
return response.Response(
{"detail": "No file provided"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -315,124 +269,81 @@ class CalendarViewSet(viewsets.GenericViewSet):
# Validate file size
if uploaded_file.size > MAX_FILE_SIZE:
return drf_response.Response(
{"error": "File too large. Maximum size is 10 MB."},
return response.Response(
{"detail": "File too large. Maximum size is 10 MB."},
status=status.HTTP_400_BAD_REQUEST,
)
ics_data = uploaded_file.read()
service = ICSImportService()
result = service.import_events(request.user, caldav_path, ics_data)
response_data = {
"total_events": result.total_events,
"imported_count": result.imported_count,
"duplicate_count": result.duplicate_count,
"skipped_count": result.skipped_count,
}
if result.errors:
response_data["errors"] = result.errors
# Queue the import task
task = import_events_task.delay(
str(request.user.id),
caldav_path,
ics_data.hex(),
)
task.track_owner(request.user.id)
return drf_response.Response(response_data, status=status.HTTP_200_OK)
return response.Response(
{"task_id": task.id},
status=status.HTTP_202_ACCEPTED,
)
class SubscriptionTokenViewSet(viewsets.GenericViewSet):
"""
ViewSet for managing subscription tokens independently of Django Calendar model.
class ResourceViewSet(viewsets.ViewSet):
"""ViewSet for resource provisioning (create/delete).
This viewset operates directly with CalDAV paths, without requiring a Django
Calendar record. The backend verifies that the user has access to the calendar
by checking that their email is in the CalDAV path.
Endpoints:
- POST /api/v1.0/subscription-tokens/ - Create or get existing token
- GET /api/v1.0/subscription-tokens/by-path/ - Get token by CalDAV path
- DELETE /api/v1.0/subscription-tokens/by-path/ - Delete token by CalDAV path
Resources are CalDAV principals — this endpoint only handles
provisioning. All metadata, sharing, and discovery goes through CalDAV.
"""
permission_classes = [IsAuthenticated]
serializer_class = serializers.CalendarSubscriptionTokenSerializer
permission_classes = [permissions.IsOrgAdmin]
def create(self, request):
"""Create a resource principal and its default calendar.
POST /api/v1.0/resources/
Body: {"name": "Room 101", "resource_type": "ROOM"}
"""
Create or get existing subscription token.
name = request.data.get("name", "").strip()
resource_type = request.data.get("resource_type", "ROOM").strip().upper()
POST body:
- caldav_path: The CalDAV path (e.g., /calendars/user@example.com/uuid/)
- calendar_name: Display name of the calendar (optional)
"""
create_serializer = serializers.CalendarSubscriptionTokenCreateSerializer(
data=request.data
)
create_serializer.is_valid(raise_exception=True)
caldav_path = create_serializer.validated_data["caldav_path"]
calendar_name = create_serializer.validated_data.get("calendar_name", "")
# Verify user has access to this calendar
if not verify_caldav_access(request.user, caldav_path):
return drf_response.Response(
{"error": "You don't have access to this calendar"},
status=status.HTTP_403_FORBIDDEN,
)
# Get or create token
token, created = models.CalendarSubscriptionToken.objects.get_or_create(
owner=request.user,
caldav_path=caldav_path,
defaults={"calendar_name": calendar_name},
)
# Update calendar_name if provided and different
if not created and calendar_name and token.calendar_name != calendar_name:
token.calendar_name = calendar_name
token.save(update_fields=["calendar_name"])
serializer = self.get_serializer(token, context={"request": request})
return drf_response.Response(
serializer.data,
status=status.HTTP_201_CREATED if created else status.HTTP_200_OK,
)
@action(detail=False, methods=["get", "delete"], url_path="by-path")
def by_path(self, request):
"""
Get or delete subscription token by CalDAV path.
Query parameter:
- caldav_path: The CalDAV path (e.g., /calendars/user@example.com/uuid/)
"""
caldav_path = request.query_params.get("caldav_path")
if not caldav_path:
return drf_response.Response(
{"error": "caldav_path query parameter is required"},
if not name:
return response.Response(
{"detail": "name is required."},
status=status.HTTP_400_BAD_REQUEST,
)
caldav_path = normalize_caldav_path(caldav_path)
# Verify user has access to this calendar
if not verify_caldav_access(request.user, caldav_path):
return drf_response.Response(
{"error": "You don't have access to this calendar"},
status=status.HTTP_403_FORBIDDEN,
)
service = ResourceService()
try:
token = models.CalendarSubscriptionToken.objects.get(
owner=request.user,
caldav_path=caldav_path,
)
except models.CalendarSubscriptionToken.DoesNotExist:
return drf_response.Response(
{"error": "No subscription token exists for this calendar"},
status=status.HTTP_404_NOT_FOUND,
result = service.create_resource(request.user, name, resource_type)
except ResourceProvisioningError as e:
return response.Response(
{"detail": str(e)},
status=status.HTTP_400_BAD_REQUEST,
)
if request.method == "GET":
serializer = self.get_serializer(token, context={"request": request})
return drf_response.Response(serializer.data)
return response.Response(result, status=status.HTTP_201_CREATED)
# DELETE
token.delete()
return drf_response.Response(status=status.HTTP_204_NO_CONTENT)
def destroy(self, request, pk=None):
"""Delete a resource principal and its calendar.
DELETE /api/v1.0/resources/{resource_id}/
"""
resource_id = pk
if not resource_id:
return response.Response(
{"detail": "Resource ID is required."},
status=status.HTTP_400_BAD_REQUEST,
)
service = ResourceService()
try:
service.delete_resource(request.user, resource_id)
except ResourceProvisioningError as e:
return response.Response(
{"detail": str(e)},
status=status.HTTP_400_BAD_REQUEST,
)
return response.Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -1,11 +1,14 @@
"""CalDAV proxy views for forwarding requests to CalDAV server."""
import logging
import re
import secrets
from django.conf import settings
from django.core.validators import validate_email
from django.http import HttpResponse
from django.urls import reverse
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt
@@ -13,6 +16,7 @@ from django.views.decorators.csrf import csrf_exempt
import requests
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
from core.models import Channel
from core.services.caldav_service import CalDAVHTTPClient, validate_caldav_proxy_path
from core.services.calendar_invitation_service import calendar_invitation_service
@@ -30,6 +34,73 @@ class CalDAVProxyView(View):
Authentication is handled via session cookies instead.
"""
# HTTP methods allowed per Channel role
READER_METHODS = frozenset({"GET", "PROPFIND", "REPORT", "OPTIONS"})
EDITOR_METHODS = READER_METHODS | frozenset({"PUT", "POST", "DELETE", "PROPPATCH"})
ADMIN_METHODS = EDITOR_METHODS | frozenset({"MKCALENDAR", "MKCOL"})
ROLE_METHODS = {
Channel.ROLE_READER: READER_METHODS,
Channel.ROLE_EDITOR: EDITOR_METHODS,
Channel.ROLE_ADMIN: ADMIN_METHODS,
}
@staticmethod
def _authenticate_channel_token(request):
"""Try to authenticate via X-Channel-Id + X-Channel-Token headers.
Returns (channel, user) on success, (None, None) on failure.
"""
channel_id = request.headers.get("X-Channel-Id", "").strip()
token = request.headers.get("X-Channel-Token", "").strip()
if not channel_id or not token:
return None, None
try:
channel = Channel.objects.get(pk=channel_id, is_active=True, type="caldav")
except (ValueError, Channel.DoesNotExist):
return None, None
if not channel.verify_token(token):
return None, None
user = channel.user
if not user:
logger.warning("Channel %s has no user", channel.id)
return None, None
# Update last_used_at (fire-and-forget, no extra query on critical path)
Channel.objects.filter(pk=channel.pk).update(last_used_at=timezone.now())
return channel, user
@staticmethod
def _check_channel_path_access(channel, path):
"""Check that the CalDAV path is within the channel's scope.
Returns True if allowed, False if denied.
"""
# Ensure path starts with /
full_path = "/" + path.lstrip("/") if path else "/"
# caldav_path scope: request must be within the scoped calendar
# The trailing slash on caldav_path (enforced by serializer) ensures
# /cal1/ won't match /cal1-secret/
if channel.caldav_path:
if not channel.caldav_path.endswith("/"):
logger.error(
"caldav_path %r missing trailing slash", channel.caldav_path
)
return False
return full_path.startswith(channel.caldav_path)
# user scope: request must be under the user's calendars
if channel.user:
user_prefix = f"/calendars/users/{channel.user.email}/"
return full_path.startswith(user_prefix)
return False
@staticmethod
def _check_entitlements_for_creation(user):
"""Check if user is entitled to create calendars.
@@ -39,7 +110,7 @@ class CalDAVProxyView(View):
"""
try:
entitlements = get_user_entitlements(user.sub, user.email)
if not entitlements.get("can_access", True):
if not entitlements.get("can_access", False):
return HttpResponse(
status=403,
content="Calendar creation not allowed",
@@ -51,27 +122,42 @@ class CalDAVProxyView(View):
)
return None
def dispatch(self, request, *args, **kwargs): # noqa: PLR0912, PLR0911, PLR0915 # pylint: disable=too-many-branches,too-many-return-statements,too-many-statements
def dispatch(self, request, *args, **kwargs): # noqa: PLR0912, PLR0911, PLR0915 # pylint: disable=too-many-branches,too-many-return-statements,too-many-statements,too-many-locals
"""Forward all HTTP methods to CalDAV server."""
# Handle CORS preflight requests
if request.method == "OPTIONS":
response = HttpResponse(status=200)
response["Access-Control-Allow-Methods"] = (
"GET, OPTIONS, PROPFIND, PROPPATCH, REPORT, MKCOL, MKCALENDAR, PUT, DELETE, POST"
"GET, OPTIONS, PROPFIND, PROPPATCH, REPORT,"
" MKCOL, MKCALENDAR, PUT, DELETE, POST"
)
response["Access-Control-Allow-Headers"] = (
"Content-Type, depth, authorization, if-match, if-none-match, prefer"
"Content-Type, depth, x-channel-id, x-channel-token,"
" if-match, if-none-match, prefer"
)
return response
# Try channel token auth first (for external services like Messages)
channel = None
effective_user = None
if not request.user.is_authenticated:
return HttpResponse(status=401)
channel, effective_user = self._authenticate_channel_token(request)
if not channel:
return HttpResponse(status=401)
else:
effective_user = request.user
# Block calendar creation (MKCALENDAR/MKCOL) for non-entitled users.
# Other methods (GET, PROPFIND, REPORT, PUT, DELETE, etc.) are allowed
# so that users invited to shared calendars can still use them.
if channel:
# Enforce role-based method restrictions
allowed = self.ROLE_METHODS.get(channel.role, self.READER_METHODS)
if request.method not in allowed:
return HttpResponse(
status=403, content="Method not allowed for this role"
)
# Check entitlements for calendar creation (all auth methods)
if request.method in ("MKCALENDAR", "MKCOL"):
if denied := self._check_entitlements_for_creation(request.user):
if denied := self._check_entitlements_for_creation(effective_user):
return denied
# Build the CalDAV server URL
@@ -81,8 +167,9 @@ class CalDAVProxyView(View):
if not validate_caldav_proxy_path(path):
return HttpResponse(status=400, content="Invalid path")
# Use user email as the principal (CalDAV server uses email as username)
user_principal = request.user.email
# Enforce channel path scope
if channel and not self._check_channel_path_access(channel, path):
return HttpResponse(status=403, content="Path not allowed for this channel")
http = CalDAVHTTPClient()
@@ -95,7 +182,7 @@ class CalDAVProxyView(View):
# Prepare headers — start with shared auth headers, add proxy-specific ones
try:
headers = CalDAVHTTPClient.build_base_headers(user_principal)
headers = CalDAVHTTPClient.build_base_headers(effective_user)
except ValueError:
logger.error("CALDAV_OUTBOUND_API_KEY is not configured")
return HttpResponse(
@@ -112,7 +199,7 @@ class CalDAVProxyView(View):
# Use CALDAV_CALLBACK_BASE_URL if configured (for Docker environments where
# the CalDAV container needs to reach Django via internal network)
callback_path = reverse("caldav-scheduling-callback")
callback_base_url = getattr(settings, "CALDAV_CALLBACK_BASE_URL", None)
callback_base_url = settings.CALDAV_CALLBACK_BASE_URL
if callback_base_url:
# Use configured internal URL (e.g., http://backend:8000)
headers["X-CalDAV-Callback-URL"] = (
@@ -145,7 +232,7 @@ class CalDAVProxyView(View):
"Forwarding %s request to CalDAV server: %s (user: %s)",
request.method,
target_url,
user_principal,
effective_user.email,
)
response = requests.request(
method=request.method,
@@ -161,7 +248,7 @@ class CalDAVProxyView(View):
if response.status_code == 401:
logger.warning(
"CalDAV server returned 401 for user %s at %s",
user_principal,
effective_user.email,
target_url,
)
@@ -182,7 +269,7 @@ class CalDAVProxyView(View):
except requests.exceptions.RequestException as e:
logger.error("CalDAV server proxy error: %s", str(e))
return HttpResponse(
content=f"CalDAV server error: {str(e)}",
content="CalDAV server is unavailable",
status=502,
content_type="text/plain",
)
@@ -216,9 +303,8 @@ class CalDAVDiscoveryView(View):
# Clients need to discover the CalDAV URL before authenticating
# Return redirect to CalDAV server base URL
caldav_base_url = f"/api/{settings.API_VERSION}/caldav/"
response = HttpResponse(status=301)
response["Location"] = caldav_base_url
response["Location"] = "/caldav/"
return response
@@ -239,24 +325,25 @@ class CalDAVSchedulingCallbackView(View):
See: https://sabre.io/dav/scheduling/
"""
def dispatch(self, request, *args, **kwargs):
http_method_names = ["post"]
def post(self, request, *args, **kwargs): # noqa: PLR0911 # pylint: disable=too-many-return-statements
"""Handle scheduling messages from CalDAV server."""
# Authenticate via API key
api_key = request.headers.get("X-Api-Key", "").strip()
expected_key = settings.CALDAV_INBOUND_API_KEY
if not expected_key or not secrets.compare_digest(api_key, expected_key):
logger.warning(
"CalDAV scheduling callback request with invalid API key. "
"Expected: %s..., Got: %s...",
expected_key[:10] if expected_key else "None",
api_key[:10] if api_key else "None",
)
logger.warning("CalDAV scheduling callback request with invalid API key.")
return HttpResponse(status=401)
# Extract headers
sender = request.headers.get("X-CalDAV-Sender", "")
recipient = request.headers.get("X-CalDAV-Recipient", "")
# Extract and validate sender/recipient emails
sender = re.sub(
r"^mailto:", "", request.headers.get("X-CalDAV-Sender", "")
).strip()
recipient = re.sub(
r"^mailto:", "", request.headers.get("X-CalDAV-Recipient", "")
).strip()
method = request.headers.get("X-CalDAV-Method", "").upper()
# Validate required fields
@@ -275,6 +362,22 @@ class CalDAVSchedulingCallbackView(View):
content_type="text/plain",
)
# Validate email format
try:
validate_email(sender)
validate_email(recipient)
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
logger.warning(
"CalDAV scheduling callback with invalid email: sender=%s, recipient=%s",
sender,
recipient,
)
return HttpResponse(
status=400,
content="Invalid sender or recipient email",
content_type="text/plain",
)
# Get iCalendar data from request body
icalendar_data = (
request.body.decode("utf-8", errors="replace") if request.body else ""
@@ -332,6 +435,6 @@ class CalDAVSchedulingCallbackView(View):
logger.exception("Error processing CalDAV scheduling callback: %s", e)
return HttpResponse(
status=500,
content=f"Internal error: {str(e)}",
content="Internal server error",
content_type="text/plain",
)

View File

@@ -0,0 +1,148 @@
"""Channel API for managing integration tokens."""
import logging
import secrets
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from core import models
from core.api import serializers
from core.services.caldav_service import verify_caldav_access
logger = logging.getLogger(__name__)
class ChannelViewSet(viewsets.GenericViewSet):
"""CRUD for integration channels.
Endpoints:
GET /api/v1.0/channels/ — list (filterable by ?type=)
POST /api/v1.0/channels/ — create (returns token once)
GET /api/v1.0/channels/{id}/ — retrieve
DELETE /api/v1.0/channels/{id}/ — delete
POST /api/v1.0/channels/{id}/regenerate-token/ — regenerate token
"""
permission_classes = [IsAuthenticated]
serializer_class = serializers.ChannelSerializer
def get_queryset(self):
return models.Channel.objects.filter(user=self.request.user).select_related(
"organization", "user"
)
def list(self, request):
"""List channels created by the current user, optionally filtered by type."""
queryset = self.get_queryset()
channel_type = request.query_params.get("type")
if channel_type:
queryset = queryset.filter(type=channel_type)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
def create(self, request):
"""Create a new channel and return the token (once).
For type="ical-feed", returns an existing channel if one already
exists for the same user + caldav_path (get-or-create semantics).
"""
create_serializer = serializers.ChannelCreateSerializer(data=request.data)
create_serializer.is_valid(raise_exception=True)
data = create_serializer.validated_data
caldav_path = data.get("caldav_path", "")
channel_type = data.get("type", "caldav")
calendar_name = data.get("calendar_name", "")
# If a caldav_path is specified, verify the user has access
if caldav_path and not verify_caldav_access(request.user, caldav_path):
return Response(
{"detail": "You don't have access to this calendar."},
status=status.HTTP_403_FORBIDDEN,
)
# For ical-feed, return existing channel if one exists
if channel_type == "ical-feed" and caldav_path:
existing = (
self.get_queryset()
.filter(caldav_path=caldav_path, type="ical-feed")
.first()
)
if existing:
# Update calendar_name if provided and different
current_name = existing.settings.get("calendar_name", "")
if calendar_name and current_name != calendar_name:
existing.settings["calendar_name"] = calendar_name
existing.name = calendar_name
existing.save(update_fields=["settings", "name", "updated_at"])
serializer = self.get_serializer(existing, context={"request": request})
return Response(serializer.data, status=status.HTTP_200_OK)
token = secrets.token_urlsafe(16)
channel_settings = {"role": data.get("role", models.Channel.ROLE_READER)}
if calendar_name:
channel_settings["calendar_name"] = calendar_name
channel = models.Channel(
name=data.get("name") or calendar_name or caldav_path or "Channel",
type=channel_type,
user=request.user,
caldav_path=caldav_path,
organization=request.user.organization,
settings=channel_settings,
encrypted_settings={"token": token},
)
channel.save()
# Attach plaintext token for the response (not persisted)
channel.token = token
serializer = serializers.ChannelWithTokenSerializer(
channel, context={"request": request}
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
def retrieve(self, request, pk=None):
"""Retrieve a channel (without token)."""
channel = self._get_owned_channel(pk)
if channel is None:
return Response(status=status.HTTP_404_NOT_FOUND)
serializer = self.get_serializer(channel, context={"request": request})
return Response(serializer.data)
def destroy(self, request, pk=None):
"""Delete a channel."""
channel = self._get_owned_channel(pk)
if channel is None:
return Response(status=status.HTTP_404_NOT_FOUND)
channel.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=True, methods=["post"], url_path="regenerate-token")
def regenerate_token(self, request, pk=None):
"""Regenerate the token for an existing channel."""
channel = self._get_owned_channel(pk)
if channel is None:
return Response(status=status.HTTP_404_NOT_FOUND)
token = secrets.token_urlsafe(16)
channel.encrypted_settings = {
**channel.encrypted_settings,
"token": token,
}
channel.save(update_fields=["encrypted_settings", "updated_at"])
channel.token = token
serializer = serializers.ChannelWithTokenSerializer(
channel, context={"request": request}
)
return Response(serializer.data)
def _get_owned_channel(self, pk):
"""Get a channel owned by the current user, or None."""
try:
return self.get_queryset().get(pk=pk)
except models.Channel.DoesNotExist:
return None

View File

@@ -2,19 +2,24 @@
import logging
from django.core.cache import cache
from django.http import Http404, HttpResponse
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.utils.text import slugify
from django.views import View
from django.views.decorators.csrf import csrf_exempt
import requests
from core.models import CalendarSubscriptionToken
from core.models import Channel, urlsafe_to_uuid
from core.services.caldav_service import CalDAVHTTPClient
logger = logging.getLogger(__name__)
ICAL_RATE_LIMIT = 5 # requests per minute per channel
ICAL_RATE_WINDOW = 60 # seconds
@method_decorator(csrf_exempt, name="dispatch")
class ICalExportView(View):
@@ -22,40 +27,49 @@ class ICalExportView(View):
Public endpoint for iCal calendar exports.
This view serves calendar data in iCal format without requiring authentication.
The token in the URL path acts as the authentication mechanism.
The channel_id in the URL is used for lookup, and the token for authentication.
URL format: /ical/<uuid:token>.ics
URL format: /ical/<short_id>/<token>/<slug>.ics
The view proxies the request to SabreDAV's ICSExportPlugin, which generates
RFC 5545 compliant iCal data.
Looks up a Channel by base64url-encoded ID, verifies the token, then
proxies the request to SabreDAV's ICSExportPlugin.
"""
def get(self, request, token):
def get(self, request, short_id, token):
"""Handle GET requests for iCal export."""
# Lookup token
subscription = (
CalendarSubscriptionToken.objects.filter(token=token, is_active=True)
.select_related("owner")
.first()
)
try:
channel_id = urlsafe_to_uuid(short_id)
channel = Channel.objects.get(pk=channel_id, is_active=True)
except (ValueError, Channel.DoesNotExist) as exc:
raise Http404("Calendar not found") from exc
if not subscription:
logger.warning("Invalid or inactive subscription token: %s", token)
if channel.type != "ical-feed":
raise Http404("Calendar not found")
# Update last_accessed_at atomically to avoid race conditions
# when multiple calendar clients poll simultaneously
CalendarSubscriptionToken.objects.filter(token=token, is_active=True).update(
last_accessed_at=timezone.now()
)
if not channel.verify_token(token):
raise Http404("Calendar not found")
if not channel.user:
logger.warning("ical-feed channel %s has no user", channel.id)
raise Http404("Calendar not found")
# Rate limit: 5 requests per minute per channel
rate_key = f"ical_rate:{channel_id}"
hits = cache.get(rate_key, 0)
if hits >= ICAL_RATE_LIMIT:
return HttpResponse(status=429, content="Too many requests")
cache.set(rate_key, hits + 1, ICAL_RATE_WINDOW)
# Update last_used_at
Channel.objects.filter(pk=channel.pk).update(last_used_at=timezone.now())
# Proxy to SabreDAV
http = CalDAVHTTPClient()
try:
caldav_path = subscription.caldav_path.lstrip("/")
caldav_path = channel.caldav_path.lstrip("/")
response = http.request(
"GET",
subscription.owner.email,
channel.user,
caldav_path,
query="export",
)
@@ -88,15 +102,12 @@ class ICalExportView(View):
status=200,
content_type="text/calendar; charset=utf-8",
)
# Set filename for download (use calendar_name or fallback to "calendar")
display_name = subscription.calendar_name or "calendar"
safe_name = display_name.replace('"', '\\"')
calendar_name = channel.settings.get("calendar_name", "")
filename = slugify(calendar_name)[:50] or "feed"
django_response["Content-Disposition"] = (
f'attachment; filename="{safe_name}.ics"'
f'attachment; filename="{filename}.ics"'
)
# Prevent caching of potentially sensitive data
django_response["Cache-Control"] = "no-store, private"
# Prevent token leakage via referrer
django_response["Referrer-Policy"] = "no-referrer"
return django_response

View File

@@ -1,16 +1,28 @@
"""RSVP view for handling invitation responses from email links."""
"""RSVP view for handling invitation responses from email links.
GET /rsvp/?token=...&action=accepted -> renders a confirmation page that
auto-submits via JavaScript (no extra click for the user).
POST /api/v1.0/rsvp/ -> processes the RSVP and returns a
result page. Link previewers / prefetchers only issue GET, so the
state-changing work is safely behind POST.
"""
import logging
import re
from datetime import timezone as dt_timezone
from django.core.signing import BadSignature, Signer
from django.conf import settings
from django.core.signing import BadSignature, SignatureExpired, TimestampSigner
from django.shortcuts import render
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from rest_framework.throttling import AnonRateThrottle
from rest_framework.views import APIView
from core.models import User
from core.services.caldav_service import CalDAVHTTPClient
from core.services.translation_service import TranslationService
@@ -35,7 +47,7 @@ PARTSTAT_VALUES = {
}
def _render_error(request, message, lang="fr"):
def _render_error(request, message, lang="en"):
"""Render the RSVP error page."""
t = TranslationService.t
return render(
@@ -85,69 +97,169 @@ def _is_event_past(icalendar_data):
return False
@method_decorator(csrf_exempt, name="dispatch")
class RSVPView(View):
"""Handle RSVP responses from invitation email links."""
def _validate_token(token, max_age=None):
"""Unsign and validate an RSVP token.
def get(self, request): # noqa: PLR0911 # pylint: disable=too-many-return-statements
"""Process an RSVP response."""
Returns (payload, error_key). On success error_key is None.
"""
ts_signer = TimestampSigner(salt="rsvp")
try:
payload = ts_signer.unsign_object(token, max_age=max_age)
except SignatureExpired:
return None, "token_expired"
except BadSignature:
return None, "invalid_token"
uid = payload.get("uid")
recipient_email = payload.get("email")
organizer_email = payload.get("organizer", "")
# Strip mailto: prefix (case-insensitive) in case it leaked into the token
organizer_email = re.sub(r"^mailto:", "", organizer_email, flags=re.IGNORECASE)
if not uid or not recipient_email or not organizer_email:
return None, "invalid_payload"
payload["organizer"] = organizer_email
return payload, None
_TOKEN_ERROR_KEYS = {
"token_expired": "rsvp.error.tokenExpired",
"invalid_token": "rsvp.error.invalidToken",
"invalid_payload": "rsvp.error.invalidPayload",
}
def _validate_and_render_error(request, token, action, lang):
"""Validate action + token; return (payload, error_response).
On success error_response is None.
"""
t = TranslationService.t
if action not in PARTSTAT_VALUES:
return None, _render_error(request, t("rsvp.error.invalidAction", lang), lang)
payload, error = _validate_token(
token, max_age=settings.RSVP_TOKEN_MAX_AGE_RECURRING
)
if error:
return None, _render_error(request, t(_TOKEN_ERROR_KEYS[error], lang), lang)
return payload, None
@method_decorator(csrf_exempt, name="dispatch")
class RSVPConfirmView(View):
"""GET handler: render auto-submitting confirmation page.
This page is safe for link previewers / prefetchers because it
doesn't change any state — only the POST endpoint does.
"""
def get(self, request):
"""Render a page that auto-submits the RSVP via POST."""
token = request.GET.get("token", "")
action = request.GET.get("action", "")
lang = TranslationService.resolve_language(request=request)
_, error_response = _validate_and_render_error(request, token, action, lang)
if error_response:
return error_response
# Render auto-submit page
label = TranslationService.t(f"rsvp.{action}", lang)
return render(
request,
"rsvp/confirm.html",
{
"page_title": label,
"token": token,
"action": action,
"lang": lang,
"heading": label,
"status_icon": PARTSTAT_ICONS[action],
"header_color": PARTSTAT_COLORS[action],
"submit_label": label,
"post_url": f"/api/{settings.API_VERSION}/rsvp/",
},
)
class RSVPThrottle(AnonRateThrottle):
"""Throttle RSVP POST requests: 30/min per IP."""
rate = "30/minute"
def _process_rsvp(request, payload, action, lang):
"""Execute the RSVP: find event, update PARTSTAT, PUT back.
Returns an error response on failure, or the updated calendar data
string on success.
"""
t = TranslationService.t
http = CalDAVHTTPClient()
try:
organizer = User.objects.get(email=payload["organizer"])
except User.DoesNotExist:
return _render_error(request, t("rsvp.error.eventNotFound", lang), lang)
calendar_data, href, etag = http.find_event_by_uid(organizer, payload["uid"])
if not calendar_data or not href:
return _render_error(request, t("rsvp.error.eventNotFound", lang), lang)
if _is_event_past(calendar_data):
return _render_error(request, t("rsvp.error.eventPast", lang), lang)
updated_data = CalDAVHTTPClient.update_attendee_partstat(
calendar_data, payload["email"], PARTSTAT_VALUES[action]
)
if not updated_data:
return _render_error(request, t("rsvp.error.notAttendee", lang), lang)
if not http.put_event(organizer, href, updated_data, etag=etag):
return _render_error(request, t("rsvp.error.updateFailed", lang), lang)
return calendar_data
class RSVPProcessView(APIView):
"""POST handler: actually process the RSVP.
Uses DRF's AnonRateThrottle for rate limiting. No authentication
required — the signed token acts as authorization.
"""
authentication_classes = []
permission_classes = []
throttle_classes = [RSVPThrottle]
def post(self, request):
"""Process the RSVP response."""
token = request.data.get("token", "")
action = request.data.get("action", "")
lang = TranslationService.resolve_language(request=request)
t = TranslationService.t
# Validate action
if action not in PARTSTAT_VALUES:
return _render_error(request, t("rsvp.error.invalidAction", lang), lang)
# Unsign token — tokens don't have a built-in expiry,
# but RSVPs are rejected once the event has ended (_is_event_past).
signer = Signer(salt="rsvp")
try:
payload = signer.unsign_object(token)
except BadSignature:
return _render_error(request, t("rsvp.error.invalidToken", lang), lang)
uid = payload.get("uid")
recipient_email = payload.get("email")
# Strip mailto: prefix (case-insensitive) in case it leaked into the token
organizer_email = re.sub(
r"^mailto:", "", payload.get("organizer", ""), flags=re.IGNORECASE
payload, error_response = _validate_and_render_error(
request, token, action, lang
)
if error_response:
return error_response
if not uid or not recipient_email or not organizer_email:
return _render_error(request, t("rsvp.error.invalidPayload", lang), lang)
result = _process_rsvp(request, payload, action, lang)
http = CalDAVHTTPClient()
# result is either an error HttpResponse or calendar data string
if not isinstance(result, str):
return result
# Find the event in the organizer's CalDAV calendars
calendar_data, href = http.find_event_by_uid(organizer_email, uid)
if not calendar_data or not href:
return _render_error(request, t("rsvp.error.eventNotFound", lang), lang)
# Check if the event is already over
if _is_event_past(calendar_data):
return _render_error(request, t("rsvp.error.eventPast", lang), lang)
# Update the attendee's PARTSTAT
partstat = PARTSTAT_VALUES[action]
updated_data = CalDAVHTTPClient.update_attendee_partstat(
calendar_data, recipient_email, partstat
)
if not updated_data:
return _render_error(request, t("rsvp.error.notAttendee", lang), lang)
# PUT the updated event back to CalDAV
success = http.put_event(organizer_email, href, updated_data)
if not success:
return _render_error(request, t("rsvp.error.updateFailed", lang), lang)
# Extract event summary for display
from core.services.calendar_invitation_service import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
ICalendarParser,
)
summary = ICalendarParser.extract_property(calendar_data, "SUMMARY") or ""
summary = ICalendarParser.extract_property(result, "SUMMARY") or ""
label = t(f"rsvp.{action}", lang)
return render(

View File

@@ -0,0 +1,104 @@
"""API endpoint for polling async task status."""
import logging
import uuid
import dramatiq
from dramatiq.results import ResultFailure, ResultMissing
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from core.task_utils import get_task_progress, get_task_tracking
logger = logging.getLogger(__name__)
class TaskDetailView(APIView):
"""View to retrieve the status of an async task."""
permission_classes = [IsAuthenticated]
def get(self, request, task_id): # noqa: PLR0911 # pylint: disable=too-many-return-statements
"""Get the status of a task."""
try:
uuid.UUID(task_id)
except ValueError:
return Response(
{"status": "FAILURE", "result": None, "error": "Not found"},
status=404,
)
tracking = get_task_tracking(task_id)
if tracking is None:
return Response(
{"status": "FAILURE", "result": None, "error": "Not found"},
status=404,
)
if str(request.user.id) != tracking["owner"]:
return Response(
{"status": "FAILURE", "result": None, "error": "Forbidden"},
status=403,
)
# Try to fetch the result from dramatiq's result backend
message = dramatiq.Message(
queue_name=tracking["queue_name"],
actor_name=tracking["actor_name"],
args=(),
kwargs={},
options={},
message_id=task_id,
)
try:
result_data = message.get_result(block=False)
except ResultMissing:
result_data = None
except ResultFailure as exc:
logger.error("Task %s failed: %s", task_id, exc)
return Response(
{
"status": "FAILURE",
"result": None,
"error": "Task failed",
}
)
if result_data is not None:
resp = {
"status": "SUCCESS",
"result": result_data,
"error": None,
}
# Unpack {status, result, error} convention
if (
isinstance(result_data, dict)
and {"status", "result", "error"} <= result_data.keys()
):
resp["status"] = result_data["status"]
resp["result"] = result_data["result"]
resp["error"] = result_data["error"]
return Response(resp)
# Check for progress data
progress_data = get_task_progress(task_id)
if progress_data:
return Response(
{
"status": "PROGRESS",
"result": None,
"error": None,
"progress": progress_data.get("progress"),
"message": progress_data.get("metadata", {}).get("message"),
"timestamp": progress_data.get("timestamp"),
}
)
# Default to pending
return Response(
{
"status": "PENDING",
"result": None,
"error": None,
}
)

View File

@@ -1,7 +1,6 @@
"""Calendars Core application"""
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class CoreConfig(AppConfig):
@@ -9,7 +8,7 @@ class CoreConfig(AppConfig):
name = "core"
app_label = "core"
verbose_name = _("calendars core application")
verbose_name = "calendars core application"
def ready(self):
"""

View File

@@ -10,11 +10,51 @@ from lasuite.oidc_login.backends import (
)
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
from core.models import DuplicateEmailError
from core.models import DuplicateEmailError, Organization
logger = logging.getLogger(__name__)
def _resolve_org_external_id(claims, email=None):
"""Extract the organization external_id from OIDC claims or email domain."""
claim_key = settings.OIDC_USERINFO_ORGANIZATION_CLAIM
if claim_key:
return claims.get(claim_key)
email = email or claims.get("email")
return email.split("@")[-1] if email and "@" in email else None
def resolve_organization(user, claims, entitlements=None):
"""Resolve and assign the user's organization.
The org identifier (external_id) comes from the OIDC claim configured via
OIDC_USERINFO_ORGANIZATION_CLAIM, or falls back to the email domain.
The org name comes from the entitlements response.
"""
entitlements = entitlements or {}
external_id = _resolve_org_external_id(claims, email=user.email)
if not external_id:
logger.error(
"Cannot resolve organization for user %s: no org claim or email domain",
user.email,
)
return
org_name = entitlements.get("organization_name", "") or external_id
org, created = Organization.objects.get_or_create(
external_id=external_id,
defaults={"name": org_name},
)
if not created and org_name and org.name != org_name:
org.name = org_name
org.save(update_fields=["name"])
if user.organization_id != org.id:
user.organization = org
user.save(update_fields=["organization"])
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
"""Custom OpenID Connect (OIDC) Authentication Backend.
@@ -23,39 +63,46 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
"""
def get_extra_claims(self, user_info):
"""
Return extra claims from user_info.
Args:
user_info (dict): The user information dictionary.
Returns:
dict: A dictionary of extra claims.
"""
# We need to add the claims that we want to store so that they are
# available in the post_get_or_create_user method.
"""Return extra claims from user_info."""
claims_to_store = {
claim: user_info.get(claim) for claim in settings.OIDC_STORE_CLAIMS
}
return {
"full_name": self.compute_full_name(user_info),
"short_name": user_info.get(settings.OIDC_USERINFO_SHORTNAME_FIELD),
"claims": claims_to_store,
}
def get_existing_user(self, sub, email):
"""Fetch existing user by sub or email."""
try:
return self.UserModel.objects.get_user_by_sub_or_email(sub, email)
except DuplicateEmailError as err:
raise SuspiciousOperation(err.message) from err
def create_user(self, claims):
"""Create a new user, resolving their organization first.
Organization is NOT NULL, so we must resolve it before the initial save.
"""
external_id = _resolve_org_external_id(claims)
if not external_id:
raise SuspiciousOperation(
"Cannot create user without an organization "
"(no org claim and no email domain)"
)
org, _ = Organization.objects.get_or_create(
external_id=external_id,
defaults={"name": external_id},
)
claims["organization"] = org
return super().create_user(claims)
def post_get_or_create_user(self, user, claims, is_new_user):
"""Warm the entitlements cache on login (force_refresh)."""
"""Warm the entitlements cache and resolve organization on login."""
entitlements = {}
try:
get_user_entitlements(
entitlements = get_user_entitlements(
user_sub=user.sub,
user_email=user.email,
user_info=claims,
@@ -66,3 +113,5 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
"Entitlements unavailable for %s during login",
user.email,
)
resolve_organization(user, claims, entitlements)

View File

@@ -17,7 +17,7 @@ def get_user_entitlements(user_sub, user_email, user_info=None, force_refresh=Fa
force_refresh: If True, bypass backend cache and fetch fresh data.
Returns:
dict: {"can_access": bool}
dict: {"can_access": bool, "can_admin": bool, ...}
Raises:
EntitlementsUnavailableError: If the backend cannot be reached

View File

@@ -20,7 +20,11 @@ class EntitlementsBackend(ABC):
force_refresh: If True, bypass any cache and fetch fresh data.
Returns:
dict: {"can_access": bool}
dict: {
"can_access": bool,
"can_admin": bool,
"organization_name": str, # optional, extracted from response
}
Raises:
EntitlementsUnavailableError: If the backend cannot be reached.

View File

@@ -114,7 +114,14 @@ class DeployCenterEntitlementsBackend(EntitlementsBackend):
entitlements = data.get("entitlements", {})
result = {
"can_access": entitlements.get("can_access", False),
"can_admin": entitlements.get("can_admin", False),
}
# Organization name from DeployCenter response (if present)
org = data.get("organization") or {}
org_name = org.get("name", "")
if org_name:
result["organization_name"] = org_name
cache.set(cache_key, result, settings.ENTITLEMENTS_CACHE_TIMEOUT)
return result

View File

@@ -4,9 +4,9 @@ from core.entitlements.backends.base import EntitlementsBackend
class LocalEntitlementsBackend(EntitlementsBackend):
"""Local backend that always grants access."""
"""Local backend that always grants access and admin."""
def get_user_entitlements(
self, user_sub, user_email, user_info=None, force_refresh=False
):
return {"can_access": True}
return {"can_access": True, "can_admin": True}

View File

@@ -1,12 +1,3 @@
"""
Core application enums declaration
"""
from django.conf import global_settings
from django.utils.translation import gettext_lazy as _
# In Django's code base, `LANGUAGES` is set by default with all supported languages.
# We can use it for the choice of languages which should not be limited to the few languages
# active in the app.
# pylint: disable=no-member
ALL_LANGUAGES = {language: _(name) for language, name in global_settings.LANGUAGES}

View File

@@ -4,7 +4,7 @@ from django.conf import settings
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from core.api.permissions import AccessPermission, IsSelf
from core.api.permissions import IsSelf
from core.api.viewsets import UserViewSet
from core.external_api.permissions import ResourceServerClientPermission

View File

@@ -2,6 +2,8 @@
Core application factories
"""
import secrets
from django.conf import settings
from django.contrib.auth.hashers import make_password
@@ -13,6 +15,16 @@ from core import models
fake = Faker()
class OrganizationFactory(factory.django.DjangoModelFactory):
"""A factory to create organizations for testing purposes."""
class Meta:
model = models.Organization
name = factory.Faker("company")
external_id = factory.Sequence(lambda n: f"org-{n}")
class UserFactory(factory.django.DjangoModelFactory):
"""A factory to random users for testing purposes."""
@@ -23,20 +35,32 @@ class UserFactory(factory.django.DjangoModelFactory):
sub = factory.Sequence(lambda n: f"user{n!s}")
email = factory.Faker("email")
full_name = factory.Faker("name")
short_name = factory.Faker("first_name")
language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES])
password = make_password("password")
organization = factory.SubFactory(OrganizationFactory)
class CalendarSubscriptionTokenFactory(factory.django.DjangoModelFactory):
"""A factory to create calendar subscription tokens for testing purposes."""
class ChannelFactory(factory.django.DjangoModelFactory):
"""A factory to create channels for testing purposes."""
class Meta:
model = models.CalendarSubscriptionToken
model = models.Channel
owner = factory.SubFactory(UserFactory)
caldav_path = factory.LazyAttribute(
lambda obj: f"/calendars/{obj.owner.email}/{fake.uuid4()}/"
name = factory.Faker("sentence", nb_words=3)
user = factory.SubFactory(UserFactory)
settings = factory.LazyFunction(lambda: {"role": "reader"})
encrypted_settings = factory.LazyFunction(
lambda: {"token": secrets.token_urlsafe(16)}
)
class ICalFeedChannelFactory(ChannelFactory):
"""A factory to create ical-feed channels."""
type = "ical-feed"
caldav_path = factory.LazyAttribute(
lambda obj: f"/calendars/users/{obj.user.email}/{fake.uuid4()}/"
)
settings = factory.LazyAttribute(
lambda obj: {"role": "reader", "calendar_name": fake.sentence(nb_words=3)}
)
calendar_name = factory.Faker("sentence", nb_words=3)
is_active = True

View File

@@ -1,8 +1,9 @@
# Generated by Django 5.2.9 on 2026-01-11 00:45
# Generated by Django 5.2.9 on 2026-03-08 21:40
import core.models
import django.core.validators
import django.db.models.deletion
import encrypted_fields.fields
import timezone_field.fields
import uuid
from django.conf import settings
@@ -18,6 +19,21 @@ class Migration(migrations.Migration):
]
operations = [
migrations.CreateModel(
name='Organization',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('name', models.CharField(blank=True, default='', max_length=200)),
('external_id', models.CharField(db_index=True, help_text='Organization identifier from OIDC claim or email domain.', max_length=128, unique=True)),
],
options={
'verbose_name': 'organization',
'verbose_name_plural': 'organizations',
'db_table': 'calendars_organization',
},
),
migrations.CreateModel(
name='User',
fields=[
@@ -29,8 +45,7 @@ class Migration(migrations.Migration):
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('sub', models.CharField(blank=True, help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters.', regex='^[\\w.@+-:]+\\Z')], verbose_name='sub')),
('full_name', models.CharField(blank=True, max_length=100, null=True, verbose_name='full name')),
('short_name', models.CharField(blank=True, max_length=20, null=True, verbose_name='short name')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='identity email address')),
('email', models.EmailField(blank=True, db_index=True, max_length=254, null=True, verbose_name='identity email address')),
('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')),
('language', models.CharField(blank=True, choices=[('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'), ('nl-nl', 'Dutch')], default=None, help_text='The language in which the user wants to see the interface.', max_length=10, null=True, verbose_name='language')),
('timezone', timezone_field.fields.TimeZoneField(choices_display='WITH_GMT_OFFSET', default='UTC', help_text='The timezone in which the user wants to see times.', use_pytz=False)),
@@ -40,6 +55,7 @@ class Migration(migrations.Migration):
('claims', models.JSONField(blank=True, default=dict, help_text='Claims from the OIDC token.')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
('organization', models.ForeignKey(help_text='The organization this user belongs to.', on_delete=django.db.models.deletion.PROTECT, related_name='members', to='core.organization')),
],
options={
'verbose_name': 'user',
@@ -51,40 +67,26 @@ class Migration(migrations.Migration):
],
),
migrations.CreateModel(
name='Calendar',
name='Channel',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=255)),
('color', models.CharField(default='#3174ad', max_length=7)),
('description', models.TextField(blank=True, default='')),
('is_default', models.BooleanField(default=False)),
('is_visible', models.BooleanField(default=True)),
('caldav_path', models.CharField(max_length=512, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='calendars', to=settings.AUTH_USER_MODEL)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('name', models.CharField(help_text='Human-readable name for this channel.', max_length=255)),
('type', models.CharField(default='caldav', help_text='Type of channel.', max_length=255)),
('caldav_path', models.CharField(blank=True, default='', help_text='CalDAV path scope (e.g. /calendars/users/user@ex.com/cal/).', max_length=512)),
('is_active', models.BooleanField(default=True)),
('settings', models.JSONField(blank=True, default=dict, help_text='Channel-specific configuration settings (e.g. role).', verbose_name='settings')),
('encrypted_settings', encrypted_fields.fields.EncryptedJSONField(blank=True, default=dict, help_text='Encrypted channel settings (e.g. token).', verbose_name='encrypted settings')),
('last_used_at', models.DateTimeField(blank=True, null=True)),
('user', models.ForeignKey(blank=True, help_text='User who created this channel (used for permissions and auditing).', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='channels', to=settings.AUTH_USER_MODEL)),
('organization', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='channels', to='core.organization')),
],
options={
'ordering': ['-is_default', 'name'],
'verbose_name': 'channel',
'verbose_name_plural': 'channels',
'db_table': 'calendars_channel',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='CalendarShare',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('permission', models.CharField(choices=[('read', 'Read only'), ('write', 'Read and write')], default='read', max_length=10)),
('is_visible', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('calendar', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shares', to='core.calendar')),
('shared_with', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shared_calendars', to=settings.AUTH_USER_MODEL)),
],
),
migrations.AddConstraint(
model_name='calendar',
constraint=models.UniqueConstraint(condition=models.Q(('is_default', True)), fields=('owner',), name='unique_default_calendar_per_user'),
),
migrations.AlterUniqueTogether(
name='calendarshare',
unique_together={('calendar', 'shared_with')},
),
]

View File

@@ -1,34 +0,0 @@
# Generated by Django 5.2.9 on 2026-01-25 14:21
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='CalendarSubscriptionToken',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('caldav_path', models.CharField(help_text='CalDAV path of the calendar', max_length=512)),
('calendar_name', models.CharField(blank=True, default='', help_text='Display name of the calendar', max_length=255)),
('token', models.UUIDField(db_index=True, default=uuid.uuid4, help_text='Secret token used in the subscription URL', unique=True)),
('is_active', models.BooleanField(default=True, help_text='Whether this subscription token is active')),
('last_accessed_at', models.DateTimeField(blank=True, help_text='Last time this subscription URL was accessed', null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subscription_tokens', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'calendar subscription token',
'verbose_name_plural': 'calendar subscription tokens',
'constraints': [models.UniqueConstraint(fields=('owner', 'caldav_path'), name='unique_token_per_owner_calendar')],
},
),
]

View File

@@ -1,17 +0,0 @@
# Generated by Django 5.2.9 on 2026-01-25 15:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0002_calendarsubscriptiontoken'),
]
operations = [
migrations.AddIndex(
model_name='calendarsubscriptiontoken',
index=models.Index(fields=['token', 'is_active'], name='token_active_idx'),
),
]

View File

@@ -1,19 +0,0 @@
# Generated by Django 5.2.9 on 2026-02-09 18:23
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('core', '0003_calendarsubscriptiontoken_token_active_idx'),
]
operations = [
migrations.DeleteModel(
name='CalendarShare',
),
migrations.DeleteModel(
name='Calendar',
),
]

View File

@@ -2,6 +2,8 @@
Declare and configure the models for the calendars core application
"""
import base64
import secrets
import uuid
from logging import getLogger
@@ -10,47 +12,13 @@ from django.contrib.auth import models as auth_models
from django.contrib.auth.base_user import AbstractBaseUser
from django.core import mail, validators
from django.db import models
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from encrypted_fields.fields import EncryptedJSONField
from timezone_field import TimeZoneField
logger = getLogger(__name__)
class LinkRoleChoices(models.TextChoices):
"""Defines the possible roles a link can offer on a item."""
READER = "reader", _("Reader") # Can read
EDITOR = "editor", _("Editor") # Can read and edit
class RoleChoices(models.TextChoices):
"""Defines the possible roles a user can have in a resource."""
READER = "reader", _("Reader") # Can read
EDITOR = "editor", _("Editor") # Can read and edit
ADMIN = "administrator", _("Administrator") # Can read, edit, delete and share
OWNER = "owner", _("Owner")
PRIVILEGED_ROLES = [RoleChoices.ADMIN, RoleChoices.OWNER]
class LinkReachChoices(models.TextChoices):
"""Defines types of access for links"""
RESTRICTED = (
"restricted",
_("Restricted"),
) # Only users with a specific access can read/edit the item
AUTHENTICATED = (
"authenticated",
_("Authenticated"),
) # Any authenticated user can access the item
PUBLIC = "public", _("Public") # Even anonymous users can access the item
class DuplicateEmailError(Exception):
"""Raised when an email is already associated with a pre-existing user."""
@@ -70,21 +38,21 @@ class BaseModel(models.Model):
"""
id = models.UUIDField(
verbose_name=_("id"),
help_text=_("primary key for the record as UUID"),
verbose_name="id",
help_text="primary key for the record as UUID",
primary_key=True,
default=uuid.uuid4,
editable=False,
)
created_at = models.DateTimeField(
verbose_name=_("created on"),
help_text=_("date and time at which a record was created"),
verbose_name="created on",
help_text="date and time at which a record was created",
auto_now_add=True,
editable=False,
)
updated_at = models.DateTimeField(
verbose_name=_("updated on"),
help_text=_("date and time at which a record was last updated"),
verbose_name="updated on",
help_text="date and time at which a record was last updated",
auto_now=True,
editable=False,
)
@@ -98,6 +66,46 @@ class BaseModel(models.Model):
super().save(*args, **kwargs)
class Organization(BaseModel):
"""Organization model, populated from OIDC claims and entitlements.
Every user belongs to exactly one organization, determined by their
email domain (default) or a configurable OIDC claim. Orgs are
created automatically on first login.
"""
name = models.CharField(max_length=200, blank=True, default="")
external_id = models.CharField(
max_length=128,
unique=True,
db_index=True,
help_text="Organization identifier from OIDC claim or email domain.",
)
class Meta:
db_table = "calendars_organization"
verbose_name = "organization"
verbose_name_plural = "organizations"
def __str__(self):
return self.name or self.external_id
def delete(self, *args, **kwargs):
"""Delete org after cleaning up members' CalDAV data.
Must run before super().delete() because the User FK uses
on_delete=PROTECT, which blocks deletion while members exist.
The pre_delete signal would never fire with PROTECT, so the
cleanup logic lives here instead.
"""
from core.services.caldav_service import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
cleanup_organization_caldav_data,
)
cleanup_organization_caldav_data(self)
super().delete(*args, **kwargs)
class UserManager(auth_models.UserManager):
"""Custom manager for User model with additional methods."""
@@ -119,10 +127,8 @@ class UserManager(auth_models.UserManager):
and not settings.OIDC_ALLOW_DUPLICATE_EMAILS
):
raise DuplicateEmailError(
_(
"We couldn't find a user with this sub but the email is already "
"associated with a registered user."
)
"We couldn't find a user with this sub but the email is already "
"associated with a registered user."
) from err
return None
@@ -132,16 +138,17 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
sub_validator = validators.RegexValidator(
regex=r"^[\w.@+-:]+\Z",
message=_(
message=(
"Enter a valid sub. This value may contain only letters, "
"numbers, and @/./+/-/_/: characters."
),
)
sub = models.CharField(
_("sub"),
help_text=_(
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
"sub",
help_text=(
"Required. 255 characters or fewer."
" Letters, numbers, and @/./+/-/_/: characters only."
),
max_length=255,
unique=True,
@@ -150,23 +157,24 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
null=True,
)
full_name = models.CharField(_("full name"), max_length=100, null=True, blank=True)
short_name = models.CharField(_("short name"), max_length=20, null=True, blank=True)
full_name = models.CharField("full name", max_length=100, null=True, blank=True)
email = models.EmailField(_("identity email address"), blank=True, null=True)
email = models.EmailField(
"identity email address", blank=True, null=True, db_index=True
)
# Unlike the "email" field which stores the email coming from the OIDC token, this field
# stores the email used by staff users to login to the admin site
admin_email = models.EmailField(
_("admin email address"), unique=True, blank=True, null=True
"admin email address", unique=True, blank=True, null=True
)
language = models.CharField(
max_length=10,
choices=settings.LANGUAGES,
default=None,
verbose_name=_("language"),
help_text=_("The language in which the user wants to see the interface."),
verbose_name="language",
help_text="The language in which the user wants to see the interface.",
null=True,
blank=True,
)
@@ -174,31 +182,38 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
choices_display="WITH_GMT_OFFSET",
use_pytz=False,
default=settings.TIME_ZONE,
help_text=_("The timezone in which the user wants to see times."),
help_text="The timezone in which the user wants to see times.",
)
is_device = models.BooleanField(
_("device"),
"device",
default=False,
help_text=_("Whether the user is a device or a real user."),
help_text="Whether the user is a device or a real user.",
)
is_staff = models.BooleanField(
_("staff status"),
"staff status",
default=False,
help_text=_("Whether the user can log into this admin site."),
help_text="Whether the user can log into this admin site.",
)
is_active = models.BooleanField(
_("active"),
"active",
default=True,
help_text=_(
help_text=(
"Whether this user should be treated as active. "
"Unselect this instead of deleting accounts."
),
)
organization = models.ForeignKey(
Organization,
on_delete=models.PROTECT,
related_name="members",
help_text="The organization this user belongs to.",
)
claims = models.JSONField(
blank=True,
default=dict,
help_text=_("Claims from the OIDC token."),
help_text="Claims from the OIDC token.",
)
objects = UserManager()
@@ -208,8 +223,8 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
class Meta:
db_table = "calendars_user"
verbose_name = _("user")
verbose_name_plural = _("users")
verbose_name = "user"
verbose_name_plural = "users"
def __str__(self):
return self.email or self.admin_email or str(self.id)
@@ -220,152 +235,123 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
raise ValueError("User has no email address.")
mail.send_mail(subject, message, from_email, [self.email], **kwargs)
@cached_property
def teams(self):
"""
Get list of teams in which the user is, as a list of strings.
Must be cached if retrieved remotely.
"""
return []
def uuid_to_urlsafe(u):
"""Encode a UUID as unpadded base64url (22 chars)."""
return base64.urlsafe_b64encode(u.bytes).rstrip(b"=").decode()
class BaseAccess(BaseModel):
"""Base model for accesses to handle resources."""
def urlsafe_to_uuid(s):
"""Decode an unpadded base64url string back to a UUID."""
padded = s + "=" * (-len(s) % 4)
return uuid.UUID(bytes=base64.urlsafe_b64decode(padded))
class Channel(BaseModel):
"""Integration channel for external service access to calendars.
Follows the same pattern as the Messages Channel model. Allows external
services (e.g. Messages) to access CalDAV on behalf of a user via a
bearer token.
Configuration is split between ``settings`` (public, non-sensitive) and
``encrypted_settings`` (sensitive data like tokens). The ``role`` for
CalDAV access control lives in ``settings``.
For iCal feeds, the URL contains the base64url-encoded channel ID (for
lookup) and a base64url token (for authentication):
``/ical/<short_id>/<token>/<slug>.ics``.
"""
ROLE_READER = "reader"
ROLE_EDITOR = "editor"
ROLE_ADMIN = "admin"
VALID_ROLES = {ROLE_READER, ROLE_EDITOR, ROLE_ADMIN}
name = models.CharField(
max_length=255,
help_text="Human-readable name for this channel.",
)
type = models.CharField(
max_length=255,
help_text="Type of channel.",
default="caldav",
)
user = models.ForeignKey(
User,
"User",
on_delete=models.CASCADE,
null=True,
blank=True,
)
team = models.CharField(max_length=100, blank=True)
role = models.CharField(
max_length=20, choices=RoleChoices.choices, default=RoleChoices.READER
related_name="channels",
help_text="User who created this channel (used for permissions and auditing).",
)
class Meta:
abstract = True
def _get_abilities(self, resource, user):
"""
Compute and return abilities for a given user taking into account
the current state of the object.
"""
roles = []
if user.is_authenticated:
teams = user.teams
try:
roles = self.user_roles or []
except AttributeError:
try:
roles = resource.accesses.filter(
models.Q(user=user) | models.Q(team__in=teams),
).values_list("role", flat=True)
except (self._meta.model.DoesNotExist, IndexError):
roles = []
is_owner_or_admin = bool(
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
if self.role == RoleChoices.OWNER:
can_delete = (
RoleChoices.OWNER in roles
and resource.accesses.filter(role=RoleChoices.OWNER).count() > 1
)
set_role_to = (
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
if can_delete
else []
)
else:
can_delete = is_owner_or_admin
set_role_to = []
if RoleChoices.OWNER in roles:
set_role_to.append(RoleChoices.OWNER)
if is_owner_or_admin:
set_role_to.extend(
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
)
# Remove the current role as we don't want to propose it as an option
try:
set_role_to.remove(self.role)
except ValueError:
pass
return {
"destroy": can_delete,
"update": bool(set_role_to),
"partial_update": bool(set_role_to),
"retrieve": bool(roles),
"set_role_to": set_role_to,
}
class CalendarSubscriptionToken(models.Model):
"""
Stores subscription tokens for iCal export.
Each calendar can have one token that allows unauthenticated read-only access
via a public URL for use in external calendar applications.
This model is standalone and stores the CalDAV path directly,
without requiring a foreign key to the Calendar model.
"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
# Owner of the calendar (for permission verification)
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
organization = models.ForeignKey(
Organization,
on_delete=models.CASCADE,
related_name="subscription_tokens",
null=True,
blank=True,
related_name="channels",
)
# CalDAV path stored directly (e.g., /calendars/user@example.com/uuid/)
caldav_path = models.CharField(
max_length=512,
help_text=_("CalDAV path of the calendar"),
)
# Calendar display name (for UI display)
calendar_name = models.CharField(
max_length=255,
blank=True,
default="",
help_text=_("Display name of the calendar"),
help_text="CalDAV path scope (e.g. /calendars/users/user@ex.com/cal/).",
)
token = models.UUIDField(
unique=True,
db_index=True,
default=uuid.uuid4,
help_text=_("Secret token used in the subscription URL"),
)
is_active = models.BooleanField(
default=True,
help_text=_("Whether this subscription token is active"),
)
last_accessed_at = models.DateTimeField(
null=True,
is_active = models.BooleanField(default=True)
settings = models.JSONField(
"settings",
default=dict,
blank=True,
help_text=_("Last time this subscription URL was accessed"),
help_text="Channel-specific configuration settings (e.g. role).",
)
created_at = models.DateTimeField(auto_now_add=True)
encrypted_settings = EncryptedJSONField(
"encrypted settings",
default=dict,
blank=True,
help_text="Encrypted channel settings (e.g. token).",
)
last_used_at = models.DateTimeField(null=True, blank=True)
class Meta:
verbose_name = _("calendar subscription token")
verbose_name_plural = _("calendar subscription tokens")
constraints = [
models.UniqueConstraint(
fields=["owner", "caldav_path"],
name="unique_token_per_owner_calendar",
)
]
indexes = [
# Composite index for the public iCal endpoint query:
# CalendarSubscriptionToken.objects.filter(token=..., is_active=True)
models.Index(fields=["token", "is_active"], name="token_active_idx"),
]
db_table = "calendars_channel"
verbose_name = "channel"
verbose_name_plural = "channels"
ordering = ["-created_at"]
def __str__(self):
return f"Subscription token for {self.calendar_name or self.caldav_path}"
return self.name
@property
def role(self):
"""Get the role from settings, defaulting to reader."""
return self.settings.get("role", self.ROLE_READER)
@role.setter
def role(self, value):
"""Set the role in settings."""
self.settings["role"] = value
def clean(self):
"""Validate that at least one scope is set."""
from django.core.exceptions import ValidationError # noqa: PLC0415, I001 # pylint: disable=C0415
if not self.organization and not self.user and not self.caldav_path:
raise ValidationError(
"At least one scope must be set: organization, user, or caldav_path."
)
def verify_token(self, token):
"""Check that *token* matches the stored encrypted token."""
stored = self.encrypted_settings.get("token", "")
if not token or not stored:
return False
return secrets.compare_digest(token, stored)

View File

@@ -1,8 +1,10 @@
"""Services for CalDAV integration."""
import json
import logging
import re
from datetime import date, datetime, timedelta
from datetime import timezone as dt_timezone
from typing import Optional
from urllib.parse import unquote
from uuid import uuid4
@@ -30,7 +32,7 @@ class CalDAVHTTPClient:
and HTTP requests. All higher-level CalDAV consumers delegate to this.
"""
BASE_URI_PATH = "/api/v1.0/caldav"
BASE_URI_PATH = "/caldav"
DEFAULT_TIMEOUT = 30
def __init__(self):
@@ -45,11 +47,21 @@ class CalDAVHTTPClient:
return key
@classmethod
def build_base_headers(cls, email: str) -> dict:
"""Build authentication headers for CalDAV requests."""
def build_base_headers(cls, user) -> dict:
"""Build authentication headers for CalDAV requests.
Args:
user: Object with .email and .organization_id attributes.
Raises:
ValueError: If user.email is not set.
"""
if not user.email:
raise ValueError("User has no email address")
return {
"X-Api-Key": cls.get_api_key(),
"X-Forwarded-User": email,
"X-Forwarded-User": user.email,
"X-CalDAV-Organization": str(user.organization_id),
}
def build_url(self, path: str, query: str = "") -> str:
@@ -70,7 +82,7 @@ class CalDAVHTTPClient:
def request( # noqa: PLR0913 # pylint: disable=too-many-arguments
self,
method: str,
email: str,
user,
path: str,
*,
query: str = "",
@@ -80,7 +92,7 @@ class CalDAVHTTPClient:
content_type: str | None = None,
) -> requests.Response:
"""Make an authenticated HTTP request to the CalDAV server."""
headers = self.build_base_headers(email)
headers = self.build_base_headers(user)
if content_type:
headers["Content-Type"] = content_type
if extra_headers:
@@ -95,9 +107,13 @@ class CalDAVHTTPClient:
timeout=timeout or self.DEFAULT_TIMEOUT,
)
def get_dav_client(self, email: str) -> DAVClient:
"""Return a configured caldav.DAVClient for the given user email."""
headers = self.build_base_headers(email)
def get_dav_client(self, user) -> DAVClient:
"""Return a configured caldav.DAVClient for the given user.
Args:
user: Object with .email and .organization_id attributes.
"""
headers = self.build_base_headers(user)
caldav_url = f"{self.base_url}{self.BASE_URI_PATH}/"
return DAVClient(
url=caldav_url,
@@ -107,38 +123,58 @@ class CalDAVHTTPClient:
headers=headers,
)
def find_event_by_uid(self, email: str, uid: str) -> tuple[str | None, str | None]:
def find_event_by_uid(
self, user, uid: str
) -> tuple[str | None, str | None, str | None]:
"""Find an event by UID across all of the user's calendars.
Returns (ical_data, href) or (None, None).
Returns (ical_data, href, etag) or (None, None, None).
"""
client = self.get_dav_client(email)
client = self.get_dav_client(user)
try:
principal = client.principal()
for cal in principal.calendars():
try:
event = cal.object_by_uid(uid)
return event.data, str(event.url.path)
etag = getattr(event, "props", {}).get("{DAV:}getetag") or getattr(
event, "etag", None
)
return event.data, str(event.url.path), etag
except caldav_lib.error.NotFoundError:
continue
logger.warning("Event UID %s not found in user %s calendars", uid, email)
return None, None
logger.warning(
"Event UID %s not found in user %s calendars", uid, user.email
)
return None, None, None
except Exception: # pylint: disable=broad-exception-caught
logger.exception("CalDAV error looking up event %s", uid)
return None, None
return None, None, None
def put_event(self, email: str, href: str, ical_data: str) -> bool:
"""PUT updated iCalendar data back to CalDAV. Returns True on success."""
def put_event(
self, user, href: str, ical_data: str, etag: str | None = None
) -> bool:
"""PUT updated iCalendar data back to CalDAV. Returns True on success.
If *etag* is provided, the request includes an If-Match header to
prevent lost updates from concurrent modifications.
"""
try:
extra_headers = {}
if etag:
extra_headers["If-Match"] = etag
response = self.request(
"PUT",
email,
user,
href,
data=ical_data.encode("utf-8"),
content_type="text/calendar; charset=utf-8",
extra_headers=extra_headers or None,
)
if response.status_code in (200, 201, 204):
return True
if response.status_code == 412:
logger.warning("CalDAV PUT conflict (ETag mismatch) for %s", href)
return False
logger.error(
"CalDAV PUT failed: %s %s",
response.status_code,
@@ -160,10 +196,10 @@ class CalDAVHTTPClient:
cal = icalendar.Calendar.from_ical(ical_data)
updated = False
target = f"mailto:{email.lower()}"
for component in cal.walk("VEVENT"):
for _name, attendee in component.property_items("ATTENDEE"):
attendee_val = str(attendee).lower()
if email.lower() in attendee_val:
if str(attendee).lower().strip() == target:
attendee.params["PARTSTAT"] = icalendar.vText(new_partstat)
updated = True
@@ -182,14 +218,19 @@ class CalDAVClient:
self._http = CalDAVHTTPClient()
self.base_url = self._http.base_url
def _calendar_url(self, calendar_path: str) -> str:
"""Build a full URL for a calendar path, including the BASE_URI_PATH."""
return f"{self.base_url}{CalDAVHTTPClient.BASE_URI_PATH}{calendar_path}"
def _get_client(self, user) -> DAVClient:
"""
Get a CalDAV client for the given user.
The CalDAV server requires API key authentication via Authorization header
and X-Forwarded-User header for user identification.
Includes X-CalDAV-Organization when the user has an org.
"""
return self._http.get_dav_client(user.email)
return self._http.get_dav_client(user)
def get_calendar_info(self, user, calendar_path: str) -> dict | None:
"""
@@ -197,7 +238,7 @@ class CalDAVClient:
Returns dict with name, color, description or None if not found.
"""
client = self._get_client(user)
calendar_url = f"{self.base_url}{calendar_path}"
calendar_url = self._calendar_url(calendar_path)
try:
calendar = client.calendar(url=calendar_url)
@@ -227,37 +268,53 @@ class CalDAVClient:
logger.error("Failed to get calendar info from CalDAV: %s", str(e))
return None
def create_calendar(
self, user, calendar_name: str, calendar_id: str, color: str = ""
def create_calendar( # pylint: disable=too-many-arguments
self,
user,
calendar_name: str = "",
calendar_id: str = "",
color: str = "",
*,
name: str = "",
) -> str:
"""
Create a new calendar in CalDAV server for the given user.
Returns the CalDAV server path for the calendar.
"""
calendar_name = calendar_name or name
if not calendar_id:
calendar_id = str(uuid4())
if not color:
color = settings.DEFAULT_CALENDAR_COLOR
client = self._get_client(user)
principal = client.principal()
try:
# Create calendar using caldav library
calendar = principal.make_calendar(name=calendar_name)
# Pass cal_id so the library uses our UUID for the path.
calendar = principal.make_calendar(name=calendar_name, cal_id=calendar_id)
# Set calendar color if provided
if color:
calendar.set_properties([CalendarColor(color)])
# CalDAV server calendar path format: /calendars/{username}/{calendar_id}/
# The caldav library returns a URL object, convert to string and extract path
# Extract CalDAV-relative path from the calendar URL
calendar_url = str(calendar.url)
# Extract path from full URL
if calendar_url.startswith(self.base_url):
path = calendar_url[len(self.base_url) :]
else:
# Fallback: construct path manually based on standard CalDAV structure
# CalDAV servers typically create calendars under /calendars/{principal}/
path = f"/calendars/{user.email}/{calendar_id}/"
path = f"/calendars/users/{user.email}/{calendar_id}/"
base_prefix = CalDAVHTTPClient.BASE_URI_PATH
if path.startswith(base_prefix):
path = path[len(base_prefix) :]
if not path.startswith("/"):
path = "/" + path
path = unquote(path)
logger.info(
"Created calendar in CalDAV server: %s at %s", calendar_name, path
"Created calendar in CalDAV server: %s at %s",
calendar_name,
path,
)
return path
except Exception as e:
@@ -285,7 +342,7 @@ class CalDAVClient:
client = self._get_client(user)
# Get calendar by URL
calendar_url = f"{self.base_url}{calendar_path}"
calendar_url = self._calendar_url(calendar_path)
calendar = client.calendar(url=calendar_url)
try:
@@ -323,7 +380,7 @@ class CalDAVClient:
Returns the event UID.
"""
client = self._get_client(user)
calendar_url = f"{self.base_url}{calendar_path}"
calendar_url = self._calendar_url(calendar_path)
calendar = client.calendar(url=calendar_url)
try:
@@ -342,7 +399,7 @@ class CalDAVClient:
"""
client = self._get_client(user)
calendar_url = f"{self.base_url}{calendar_path}"
calendar_url = self._calendar_url(calendar_path)
calendar = client.calendar(url=calendar_url)
# Extract event data
@@ -385,27 +442,11 @@ class CalDAVClient:
"""Update an existing event in CalDAV server."""
client = self._get_client(user)
calendar_url = f"{self.base_url}{calendar_path}"
calendar_url = self._calendar_url(calendar_path)
calendar = client.calendar(url=calendar_url)
try:
# Search for the event by UID
events = calendar.search(event=True)
target_event = None
for event in events:
event_uid_value = None
if hasattr(event, "icalendar_component"):
event_uid_value = str(event.icalendar_component.get("uid", ""))
elif hasattr(event, "vobject_instance"):
event_uid_value = event.vobject_instance.vevent.uid.value
if event_uid_value == event_uid:
target_event = event
break
if not target_event:
raise ValueError(f"Event with UID {event_uid} not found")
target_event = calendar.object_by_uid(event_uid)
# Update event properties
dtstart = event_data.get("start")
@@ -432,6 +473,8 @@ class CalDAVClient:
target_event.save()
logger.info("Updated event in CalDAV server: %s", event_uid)
except NotFoundError:
raise ValueError(f"Event with UID {event_uid} not found") from None
except Exception as e:
logger.error("Failed to update event in CalDAV server: %s", str(e))
raise
@@ -440,36 +483,44 @@ class CalDAVClient:
"""Delete an event from CalDAV server."""
client = self._get_client(user)
calendar_url = f"{self.base_url}{calendar_path}"
calendar_url = self._calendar_url(calendar_path)
calendar = client.calendar(url=calendar_url)
try:
# Search for the event by UID
events = calendar.search(event=True)
target_event = None
for event in events:
event_uid_value = None
if hasattr(event, "icalendar_component"):
event_uid_value = str(event.icalendar_component.get("uid", ""))
elif hasattr(event, "vobject_instance"):
event_uid_value = event.vobject_instance.vevent.uid.value
if event_uid_value == event_uid:
target_event = event
break
if not target_event:
raise ValueError(f"Event with UID {event_uid} not found")
# Delete the event
target_event = calendar.object_by_uid(event_uid)
target_event.delete()
logger.info("Deleted event from CalDAV server: %s", event_uid)
except NotFoundError:
raise ValueError(f"Event with UID {event_uid} not found") from None
except Exception as e:
logger.error("Failed to delete event from CalDAV server: %s", str(e))
raise
def get_user_calendar_paths(self, user) -> list[str]:
"""Return a list of CalDAV-relative calendar paths for the user."""
client = self._get_client(user)
principal = client.principal()
paths = []
base = f"{self.base_url}{CalDAVHTTPClient.BASE_URI_PATH}"
for cal in principal.calendars():
url = str(cal.url)
if url.startswith(base):
paths.append(unquote(url[len(base) :]))
return paths
def create_default_calendar(self, user) -> str:
"""Create a default calendar for a user. Returns the caldav_path."""
from core.services.translation_service import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
TranslationService,
)
calendar_id = str(uuid4())
lang = TranslationService.resolve_language(email=user.email)
calendar_name = TranslationService.t("calendar.list.defaultCalendarName", lang)
return self.create_calendar(
user, calendar_name, calendar_id, color=settings.DEFAULT_CALENDAR_COLOR
)
def _parse_event(self, event) -> Optional[dict]:
"""
Parse a caldav Event object and return event data as dictionary.
@@ -491,13 +542,15 @@ class CalDAVClient:
# Convert datetime to string format for consistency
if event_data["start"]:
if isinstance(event_data["start"], datetime):
event_data["start"] = event_data["start"].strftime("%Y%m%dT%H%M%SZ")
utc_start = event_data["start"].astimezone(dt_timezone.utc)
event_data["start"] = utc_start.strftime("%Y%m%dT%H%M%SZ")
elif isinstance(event_data["start"], date):
event_data["start"] = event_data["start"].strftime("%Y%m%d")
if event_data["end"]:
if isinstance(event_data["end"], datetime):
event_data["end"] = event_data["end"].strftime("%Y%m%dT%H%M%SZ")
utc_end = event_data["end"].astimezone(dt_timezone.utc)
event_data["end"] = utc_end.strftime("%Y%m%dT%H%M%SZ")
elif isinstance(event_data["end"], date):
event_data["end"] = event_data["end"].strftime("%Y%m%d")
@@ -507,60 +560,19 @@ class CalDAVClient:
return None
class CalendarService:
"""
High-level service for managing calendars and events.
"""
def __init__(self):
self.caldav = CalDAVClient()
def create_default_calendar(self, user) -> str:
"""Create a default calendar for a user. Returns the caldav_path."""
from core.services.translation_service import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
TranslationService,
)
calendar_id = str(uuid4())
lang = TranslationService.resolve_language(email=user.email)
calendar_name = TranslationService.t("calendar.list.defaultCalendarName", lang)
return self.caldav.create_calendar(
user, calendar_name, calendar_id, color=settings.DEFAULT_CALENDAR_COLOR
)
def create_calendar(self, user, name: str, color: str = "") -> str:
"""Create a new calendar for a user. Returns the caldav_path."""
calendar_id = str(uuid4())
return self.caldav.create_calendar(
user, name, calendar_id, color=color or settings.DEFAULT_CALENDAR_COLOR
)
def get_events(self, user, caldav_path: str, start=None, end=None) -> list:
"""Get events from a calendar. Returns parsed event data."""
return self.caldav.get_events(user, caldav_path, start, end)
def create_event(self, user, caldav_path: str, event_data: dict) -> str:
"""Create a new event."""
return self.caldav.create_event(user, caldav_path, event_data)
def update_event(
self, user, caldav_path: str, event_uid: str, event_data: dict
) -> None:
"""Update an existing event."""
self.caldav.update_event(user, caldav_path, event_uid, event_data)
def delete_event(self, user, caldav_path: str, event_uid: str) -> None:
"""Delete an event."""
self.caldav.delete_event(user, caldav_path, event_uid)
# CalendarService is kept as an alias for backwards compatibility
# with tests and signals that reference it.
CalendarService = CalDAVClient
# ---------------------------------------------------------------------------
# CalDAV path utilities
# ---------------------------------------------------------------------------
# Pattern: /calendars/<email-or-encoded>/<calendar-id>/
# Pattern: /calendars/users/<email-or-encoded>/<calendar-id>/
# or /calendars/resources/<resource-id>/<calendar-id>/
CALDAV_PATH_PATTERN = re.compile(
r"^/calendars/[^/]+/[a-zA-Z0-9-]+/$",
r"^/calendars/(users|resources)/[^/]+/[a-zA-Z0-9-]+/$",
)
@@ -568,8 +580,8 @@ def normalize_caldav_path(caldav_path):
"""Normalize CalDAV path to consistent format.
Strips the CalDAV API prefix (e.g. /api/v1.0/caldav/) if present,
so that paths like /api/v1.0/caldav/calendars/user@ex.com/uuid/
become /calendars/user@ex.com/uuid/.
so that paths like /api/v1.0/caldav/calendars/users/user@ex.com/uuid/
become /calendars/users/user@ex.com/uuid/.
"""
if not caldav_path.startswith("/"):
caldav_path = "/" + caldav_path
@@ -582,19 +594,60 @@ def normalize_caldav_path(caldav_path):
return caldav_path
def _resource_belongs_to_org(resource_id: str, org_id: str) -> bool:
"""Check whether a resource principal belongs to the given organization.
Queries the CalDAV internal API. Returns False on any error (fail-closed).
"""
api_key = settings.CALDAV_INTERNAL_API_KEY
caldav_url = settings.CALDAV_URL
if not api_key or not caldav_url:
return False
try:
resp = requests.get(
f"{caldav_url.rstrip('/')}/caldav/internal-api/resources/{resource_id}",
headers={"X-Internal-Api-Key": api_key},
timeout=10,
)
if resp.status_code != 200:
return False
data = resp.json()
return data.get("org_id") == org_id
except Exception: # pylint: disable=broad-exception-caught
logger.exception("Failed to verify resource org for %s", resource_id)
return False
def verify_caldav_access(user, caldav_path):
"""Verify that the user has access to the CalDAV calendar.
Checks that:
1. The path matches the expected pattern (prevents path injection)
2. The user's email matches the email in the path
2. For user calendars: the user's email matches the email in the path
3. For resource calendars: the user has an organization
Note: Fine-grained org-to-resource authorization is enforced by SabreDAV
itself (via X-CalDAV-Organization header). This check only gates access
for Django-level features (subscription tokens, imports).
"""
if not CALDAV_PATH_PATTERN.match(caldav_path):
return False
parts = caldav_path.strip("/").split("/")
if len(parts) >= 2 and parts[0] == "calendars":
path_email = unquote(parts[1])
if len(parts) < 3 or parts[0] != "calendars":
return False
# User calendars: calendars/users/<email>/<calendar-id>
if parts[1] == "users":
if not user.email:
return False
path_email = unquote(parts[2])
return path_email.lower() == user.email.lower()
# Resource calendars: calendars/resources/<resource-id>/<calendar-id>
# Org membership is required. Fine-grained org-to-resource authorization
# is enforced by SabreDAV via the X-CalDAV-Organization header on every
# proxied request. For subscription tokens / imports, callers should
# additionally use _resource_belongs_to_org() to verify ownership.
if parts[1] == "resources":
return bool(getattr(user, "organization_id", None))
return False
@@ -605,10 +658,16 @@ def validate_caldav_proxy_path(path):
- Directory traversal sequences (../)
- Null bytes
- Paths that don't start with expected prefixes
URL-decodes the path first so that encoded payloads like
``%2e%2e`` or ``%00`` cannot bypass the checks.
"""
if not path:
return True # Empty path is fine (root request)
# Decode percent-encoded characters before validation
path = unquote(path)
# Block directory traversal
if ".." in path:
return False
@@ -617,10 +676,60 @@ def validate_caldav_proxy_path(path):
if "\x00" in path:
return False
clean = path.lstrip("/")
# Explicitly block internal-api/ paths — these must never be proxied.
# The allowlist below already rejects them, but an explicit block makes
# the intent clear and survives future allowlist additions.
blocked_prefixes = ("internal-api/",)
if clean and any(clean.startswith(prefix) for prefix in blocked_prefixes):
return False
# Path must start with a known CalDAV resource prefix
allowed_prefixes = ("calendars/", "principals/", ".well-known/")
clean = path.lstrip("/")
if clean and not any(clean.startswith(prefix) for prefix in allowed_prefixes):
return False
return True
def cleanup_organization_caldav_data(org):
"""Clean up CalDAV data for all members of an organization.
Deletes each member's CalDAV data via the SabreDAV internal API,
then deletes the Django User objects so the PROTECT foreign key
on User.organization doesn't block org deletion.
Called from Organization.delete() — NOT a signal, because the
PROTECT FK raises ProtectedError before pre_delete fires.
"""
if not settings.CALDAV_INTERNAL_API_KEY:
return
http = CalDAVHTTPClient()
members = list(org.members.all())
for user in members:
if not user.email:
continue
try:
http.request(
"POST",
user,
"internal-api/users/delete",
data=json.dumps({"email": user.email}).encode("utf-8"),
content_type="application/json",
extra_headers={
"X-Internal-Api-Key": settings.CALDAV_INTERNAL_API_KEY,
},
)
except Exception: # pylint: disable=broad-exception-caught
logger.exception(
"Failed to clean up CalDAV data for user %s (org %s)",
user.email,
org.external_id,
)
# Delete all members so the PROTECT FK doesn't block org deletion.
# CalDAV cleanup is best-effort; orphaned CalDAV data is acceptable.
org.members.all().delete()

View File

@@ -20,7 +20,7 @@ from urllib.parse import urlencode
from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from django.core.signing import Signer
from django.core.signing import TimestampSigner
from django.template.loader import render_to_string
from core.services.translation_service import TranslationService
@@ -424,8 +424,8 @@ class CalendarInvitationService: # pylint: disable=too-many-instance-attributes
"time_str": time_str,
"is_update": event.sequence > 0,
"is_cancel": method == self.METHOD_CANCEL,
"app_name": getattr(settings, "APP_NAME", "Calendrier"),
"app_url": getattr(settings, "APP_URL", ""),
"app_name": settings.APP_NAME,
"app_url": settings.APP_URL,
# Translated content blocks
"content": {
"title": t(f"email.{type_key}.title", lang),
@@ -457,13 +457,13 @@ class CalendarInvitationService: # pylint: disable=too-many-instance-attributes
"footer": t(
f"email.footer.{'invitation' if type_key == 'invitation' else 'notification'}",
lang,
appName=getattr(settings, "APP_NAME", "Calendrier"),
appName=settings.APP_NAME,
),
}
# Add RSVP links for REQUEST method (invitations and updates)
if method == self.METHOD_REQUEST:
signer = Signer(salt="rsvp")
signer = TimestampSigner(salt="rsvp")
# Strip mailto: prefix (case-insensitive) for shorter tokens
organizer = re.sub(
r"^mailto:", "", event.organizer_email, flags=re.IGNORECASE
@@ -475,7 +475,7 @@ class CalendarInvitationService: # pylint: disable=too-many-instance-attributes
"organizer": organizer,
}
)
app_url = getattr(settings, "APP_URL", "")
app_url = settings.APP_URL
base = f"{app_url}/rsvp/"
for action in ("accept", "tentative", "decline"):
partstat = {
@@ -498,7 +498,7 @@ class CalendarInvitationService: # pylint: disable=too-many-instance-attributes
When False (default), strips METHOD so the ICS is treated as a plain
calendar object — our own RSVP web links handle responses instead.
"""
itip_enabled = getattr(settings, "CALENDAR_ITIP_ENABLED", False)
itip_enabled = settings.CALENDAR_ITIP_ENABLED
if itip_enabled:
if "METHOD:" not in icalendar_data.upper():
@@ -549,10 +549,8 @@ class CalendarInvitationService: # pylint: disable=too-many-instance-attributes
"""
try:
# Get email settings
from_addr = getattr(
settings,
"CALENDAR_INVITATION_FROM_EMAIL",
getattr(settings, "DEFAULT_FROM_EMAIL", "noreply@example.com"),
from_addr = (
settings.CALENDAR_INVITATION_FROM_EMAIL or settings.DEFAULT_FROM_EMAIL
)
# Create the email message
@@ -571,7 +569,7 @@ class CalendarInvitationService: # pylint: disable=too-many-instance-attributes
ics_attachment = MIMEBase("text", "calendar")
ics_attachment.set_payload(ics_content.encode("utf-8"))
encoders.encode_base64(ics_attachment)
itip_enabled = getattr(settings, "CALENDAR_ITIP_ENABLED", False)
itip_enabled = settings.CALENDAR_ITIP_ENABLED
content_type = "text/calendar; charset=utf-8"
if itip_enabled:
content_type += f"; method={ics_method}"

View File

@@ -3,6 +3,8 @@
import logging
from dataclasses import dataclass, field
from django.conf import settings
import requests
from core.services.caldav_service import CalDAVHTTPClient
@@ -41,37 +43,43 @@ class ICSImportService:
def import_events(self, user, caldav_path: str, ics_data: bytes) -> ImportResult:
"""Import events from ICS data into a calendar.
Sends the raw ICS bytes to SabreDAV's ?import endpoint which
handles all ICS parsing, splitting by UID, VALARM repair, and
per-event insertion.
Sends the raw ICS bytes to the SabreDAV internal API import
endpoint which handles all ICS parsing, splitting by UID,
VALARM repair, and per-event insertion.
Args:
user: The authenticated user performing the import.
caldav_path: CalDAV path of the calendar
(e.g. /calendars/user@example.com/uuid/).
(e.g. /calendars/users/user@example.com/uuid/).
ics_data: Raw ICS file content.
"""
result = ImportResult()
try:
api_key = CalDAVHTTPClient.get_api_key()
except ValueError:
result.errors.append("CALDAV_OUTBOUND_API_KEY is not configured")
api_key = settings.CALDAV_INTERNAL_API_KEY
if not api_key:
result.errors.append("CALDAV_INTERNAL_API_KEY is not configured")
return result
# Timeout scales with file size: 60s base + 30s per MB of ICS data.
# 8000 events (~4MB) took ~70s in practice.
timeout = 60 + int(len(ics_data) / 1024 / 1024) * 30
# Extract calendar URI from caldav_path
# Path format: /calendars/users/<email>/<calendar-uri>/
parts = caldav_path.strip("/").split("/")
if len(parts) == 4 and parts[0] == "calendars" and parts[1] == "users":
principal_user = parts[2]
calendar_uri = parts[3]
else:
result.errors.append("Invalid calendar path")
return result
# import runs in a background task so we can wait a decent amount of time
timeout = 1200 # 20 minutes
try:
response = self._http.request(
"POST",
user.email,
caldav_path,
query="import",
user,
f"internal-api/import/{principal_user}/{calendar_uri}",
data=ics_data,
content_type="text/calendar",
extra_headers={"X-Calendars-Import": api_key},
extra_headers={"X-Internal-Api-Key": api_key},
timeout=timeout,
)
except requests.RequestException as exc:

View File

@@ -0,0 +1,182 @@
"""Service for managing calendar resource provisioning via CalDAV."""
import json
import logging
from uuid import UUID, uuid4
from django.conf import settings
from core.services.caldav_service import CalDAVHTTPClient
logger = logging.getLogger(__name__)
class ResourceProvisioningError(Exception):
"""Raised when resource provisioning fails."""
class ResourceService:
"""Provisions and deletes resource principals in SabreDAV.
Resources are CalDAV principals — this service creates them by
making HTTP requests to the SabreDAV internal API. No Django model
is created; the CalDAV principal IS the resource.
"""
def __init__(self):
self._http = CalDAVHTTPClient()
def _resource_email(self, resource_id):
"""Generate a resource scheduling address."""
domain = settings.RESOURCE_EMAIL_DOMAIN
if not domain:
domain = "resource.invalid"
return f"{resource_id}@{domain}"
def create_resource(self, user, name, resource_type="ROOM"):
"""Provision a resource principal and its default calendar.
Args:
user: The admin user creating the resource (provides auth context).
name: Display name for the resource.
resource_type: "ROOM" or "RESOURCE".
Returns:
dict with resource info: id, email, principal_uri, calendar_uri.
Raises:
ResourceProvisioningError on failure.
"""
if resource_type not in ("ROOM", "RESOURCE"):
raise ResourceProvisioningError(
"resource_type must be 'ROOM' or 'RESOURCE'."
)
if not settings.CALDAV_INTERNAL_API_KEY:
raise ResourceProvisioningError(
"CALDAV_INTERNAL_API_KEY is not configured."
)
resource_id = str(uuid4())
email = self._resource_email(resource_id)
org_id = str(user.organization_id)
try:
response = self._http.request(
"POST",
user,
"internal-api/resources/",
data=self._json_bytes(
{
"resource_id": resource_id,
"name": name,
"email": email,
"resource_type": resource_type,
"org_id": org_id,
}
),
content_type="application/json",
extra_headers={
"X-Internal-Api-Key": settings.CALDAV_INTERNAL_API_KEY,
},
)
except Exception as e:
logger.error("Failed to create resource principal: %s", e)
raise ResourceProvisioningError(
"Failed to create resource principal."
) from e
if response.status_code == 409:
raise ResourceProvisioningError(f"Resource '{resource_id}' already exists.")
if response.status_code != 201:
logger.error(
"InternalApi create resource returned %s: %s",
response.status_code,
response.text[:500],
)
raise ResourceProvisioningError("Failed to create resource principal.")
principal_uri = f"principals/resources/{resource_id}"
calendar_uri = f"calendars/resources/{resource_id}/default/"
return {
"id": resource_id,
"email": email,
"name": name,
"resource_type": resource_type,
"principal_uri": principal_uri,
"calendar_uri": calendar_uri,
}
@staticmethod
def _validate_resource_id(resource_id):
"""Validate that resource_id is a proper UUID.
Raises ResourceProvisioningError if the ID is not a valid UUID,
preventing path traversal via crafted IDs.
"""
try:
UUID(str(resource_id))
except (ValueError, AttributeError) as e:
raise ResourceProvisioningError(
"Invalid resource ID: must be a valid UUID."
) from e
def delete_resource(self, user, resource_id):
"""Delete a resource principal and its calendar.
Events in user calendars that reference this resource are left
as-is — the resource address becomes unresolvable.
Args:
user: The admin user requesting deletion.
resource_id: The resource UUID.
Raises:
ResourceProvisioningError on failure.
"""
self._validate_resource_id(resource_id)
if not settings.CALDAV_INTERNAL_API_KEY:
raise ResourceProvisioningError(
"CALDAV_INTERNAL_API_KEY is not configured."
)
try:
response = self._http.request(
"DELETE",
user,
f"internal-api/resources/{resource_id}",
extra_headers={
"X-Internal-Api-Key": settings.CALDAV_INTERNAL_API_KEY,
},
)
except Exception as e:
logger.error("Failed to delete resource: %s", e)
raise ResourceProvisioningError("Failed to delete resource.") from e
if response.status_code == 404:
raise ResourceProvisioningError(f"Resource '{resource_id}' not found.")
if response.status_code == 403:
try:
error_msg = response.json().get("error", "")
except ValueError:
error_msg = ""
raise ResourceProvisioningError(
error_msg or "Cannot delete a resource from a different organization."
)
if response.status_code not in (200, 204):
logger.error(
"InternalApi delete resource returned %s: %s",
response.status_code,
response.text[:500],
)
raise ResourceProvisioningError("Failed to delete resource.")
@staticmethod
def _json_bytes(data):
"""Serialize a dict to JSON bytes."""
return json.dumps(data).encode("utf-8")

View File

@@ -2,6 +2,7 @@
import json
import logging
import threading
from datetime import datetime
from typing import Optional
@@ -40,6 +41,7 @@ class TranslationService:
"""Lightweight translation service backed by translations.json."""
_translations = None
_load_lock = threading.Lock()
@classmethod
def _load(cls):
@@ -47,12 +49,17 @@ class TranslationService:
if cls._translations is not None:
return
path = getattr(settings, "TRANSLATIONS_JSON_PATH", "")
if not path:
raise RuntimeError("TRANSLATIONS_JSON_PATH setting is not configured")
with cls._load_lock:
# Double-check after acquiring lock
if cls._translations is not None:
return
with open(path, encoding="utf-8") as f:
cls._translations = json.load(f)
path = settings.TRANSLATIONS_JSON_PATH
if not path:
raise RuntimeError("TRANSLATIONS_JSON_PATH setting is not configured")
with open(path, encoding="utf-8") as f:
cls._translations = json.load(f)
@classmethod
def _get_nested(cls, data: dict, dotted_key: str):
@@ -104,15 +111,15 @@ class TranslationService:
if email:
try:
from core.models import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
User,
from django.contrib.auth import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
get_user_model,
)
user = User.objects.filter(email=email).first()
user = get_user_model().objects.filter(email=email).first()
if user and user.language:
return cls.normalize_lang(user.language)
except Exception: # pylint: disable=broad-exception-caught
logger.exception("Failed to resolve language for email %s", email)
logger.exception("Failed to resolve language for recipient")
return "fr"

View File

@@ -2,15 +2,17 @@
Declare and configure the signals for the calendars core application
"""
import json
import logging
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db.models.signals import post_save
from django.db import transaction
from django.db.models.signals import post_save, pre_delete
from django.dispatch import receiver
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
from core.services.caldav_service import CalendarService
from core.services.caldav_service import CalDAVHTTPClient, CalendarService
logger = logging.getLogger(__name__)
User = get_user_model()
@@ -32,7 +34,7 @@ def provision_default_calendar(sender, instance, created, **kwargs): # pylint:
# never create a calendar if we can't confirm access.
try:
entitlements = get_user_entitlements(instance.sub, instance.email)
if not entitlements.get("can_access", True):
if not entitlements.get("can_access", False):
logger.info(
"Skipped calendar creation for %s (not entitled)",
instance.email,
@@ -48,22 +50,45 @@ def provision_default_calendar(sender, instance, created, **kwargs): # pylint:
try:
service = CalendarService()
service.create_default_calendar(instance)
logger.info("Created default calendar for user %s", instance.email)
except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught
# In tests, CalDAV server may not be available, so fail silently
# Check if it's a database error that suggests we're in tests
error_str = str(e).lower()
if "does not exist" in error_str or "relation" in error_str:
# Likely in test environment, fail silently
logger.debug(
"Skipped calendar creation for user %s (likely test environment): %s",
instance.email,
str(e),
logger.info("Created default calendar for user %s", instance.pk)
except Exception: # pylint: disable=broad-exception-caught
logger.exception(
"Failed to create default calendar for user %s",
instance.pk,
)
@receiver(pre_delete, sender=User)
def delete_user_caldav_data(sender, instance, **kwargs): # pylint: disable=unused-argument
"""Schedule CalDAV data cleanup when a user is deleted.
Uses on_commit so the external CalDAV call only fires after
the DB transaction commits — avoids orphaned state on rollback.
"""
email = instance.email
if not email:
return
if not settings.CALDAV_INTERNAL_API_KEY:
return
api_key = settings.CALDAV_INTERNAL_API_KEY
def _cleanup():
try:
http = CalDAVHTTPClient()
http.request(
"POST",
instance,
"internal-api/users/delete",
data=json.dumps({"email": email}).encode("utf-8"),
content_type="application/json",
extra_headers={"X-Internal-Api-Key": api_key},
)
else:
# Real error, log it
logger.error(
"Failed to create default calendar for user %s: %s",
instance.email,
str(e),
except Exception: # pylint: disable=broad-exception-caught
logger.exception(
"Failed to clean up CalDAV data for user %s",
email,
)
transaction.on_commit(_cleanup)

View File

@@ -0,0 +1,170 @@
"""Task queue utilities.
Provides decorators and helpers that abstract away the underlying task
queue library (currently Dramatiq). Application code should import from
here instead of importing dramatiq directly.
"""
import json
import logging
from typing import Any, Optional
from django.core.cache import cache
from django.utils import timezone
import dramatiq
from dramatiq.brokers.stub import StubBroker
from dramatiq.middleware import CurrentMessage
logger = logging.getLogger(__name__)
TASK_PROGRESS_CACHE_TIMEOUT = 86400 # 24 hours
TASK_TRACKING_CACHE_TTL = 86400 * 30 # 30 days
# ---------------------------------------------------------------------------
# Task wrapper (Celery-compatible API)
# ---------------------------------------------------------------------------
class Task:
"""Wrapper around a Dramatiq Message with a Celery-like API."""
def __init__(self, message):
self._message = message
@property
def id(self):
"""Celery-compatible task ID (maps to message_id)."""
return self._message.message_id
def track_owner(self, user_id):
"""Register tracking metadata for permission checks."""
cache.set(
f"task_tracking:{self.id}",
json.dumps(
{
"owner": str(user_id),
"actor_name": self._message.actor_name,
"queue_name": self._message.queue_name,
}
),
timeout=TASK_TRACKING_CACHE_TTL,
)
def __getattr__(self, name):
return getattr(self._message, name)
class CeleryCompatActor(dramatiq.Actor):
"""Actor subclass that adds a .delay() method returning a Task."""
def delay(self, *args, **kwargs):
"""Dispatch the task asynchronously, returning a Task wrapper."""
message = self.send(*args, **kwargs)
return Task(message)
# ---------------------------------------------------------------------------
# Decorators
# ---------------------------------------------------------------------------
def register_task(*args, **kwargs):
"""Decorator to register a task (wraps dramatiq.actor).
Usage::
@register_task(queue="import")
def my_task(arg):
...
"""
kwargs.setdefault("store_results", True)
if "queue" in kwargs:
kwargs.setdefault("queue_name", kwargs.pop("queue"))
kwargs.setdefault("actor_class", CeleryCompatActor)
def decorator(fn):
return dramatiq.actor(fn, **kwargs)
if args and callable(args[0]):
return decorator(args[0])
return decorator
# ---------------------------------------------------------------------------
# Task tracking & progress
# ---------------------------------------------------------------------------
def get_task_tracking(task_id: str) -> Optional[dict]:
"""Get tracking metadata for a task, or None if not found."""
raw = cache.get(f"task_tracking:{task_id}")
if raw is None:
return None
return json.loads(raw)
def set_task_progress(progress: int, metadata: Optional[dict[str, Any]] = None) -> None:
"""Set the progress of the currently executing task."""
current_message = CurrentMessage.get_current_message()
if not current_message:
logger.warning("set_task_progress called outside of a task")
return
task_id = current_message.message_id
try:
progress = max(0, min(100, int(progress)))
except (TypeError, ValueError):
progress = 0
cache.set(
f"task_progress:{task_id}",
{
"progress": progress,
"timestamp": timezone.now().timestamp(),
"metadata": metadata or {},
},
timeout=TASK_PROGRESS_CACHE_TIMEOUT,
)
def get_task_progress(task_id: str) -> Optional[dict[str, Any]]:
"""Get the progress of a task by ID."""
return cache.get(f"task_progress:{task_id}")
# ---------------------------------------------------------------------------
# EagerBroker for tests
# ---------------------------------------------------------------------------
class EagerBroker(StubBroker):
"""Broker that executes tasks synchronously (for tests).
Equivalent to Celery's CELERY_TASK_ALWAYS_EAGER mode.
Only runs CurrentMessage and Results middleware.
"""
def enqueue(self, message, *, delay=None):
from dramatiq.results import Results # noqa: PLC0415 # pylint: disable=C0415
actor = self.get_actor(message.actor_name)
cm = next(
(m for m in self.middleware if isinstance(m, CurrentMessage)),
None,
)
rm = next((m for m in self.middleware if isinstance(m, Results)), None)
prev = CurrentMessage.get_current_message() if cm else None
if cm:
cm.before_process_message(self, message)
try:
result = actor.fn(*message.args, **message.kwargs)
if rm:
rm.after_process_message(self, message, result=result)
finally:
if cm:
cm.after_process_message(self, message)
if prev is not None:
cm.before_process_message(self, prev)
return message

50
src/backend/core/tasks.py Normal file
View File

@@ -0,0 +1,50 @@
"""Background tasks for the calendars core application."""
# pylint: disable=import-outside-toplevel
import logging
from dataclasses import asdict
from core.services.import_service import ICSImportService
from core.task_utils import register_task, set_task_progress
logger = logging.getLogger(__name__)
@register_task(queue="import")
def import_events_task(user_id, caldav_path, ics_data_hex):
"""Import events from ICS data in the background.
Parameters are kept JSON-serialisable:
- user_id: pk of the User who triggered the import
- caldav_path: target CalDAV calendar path
- ics_data_hex: ICS bytes encoded as hex string
"""
from core.models import User # noqa: PLC0415
set_task_progress(0, {"message": "Starting import..."})
try:
user = User.objects.get(pk=user_id)
except User.DoesNotExist:
logger.error("import_events_task: user %s not found", user_id)
return {
"status": "FAILURE",
"result": None,
"error": "User not found",
}
ics_data = bytes.fromhex(ics_data_hex)
set_task_progress(10, {"message": "Sending to CalDAV server..."})
service = ICSImportService()
result = service.import_events(user, caldav_path, ics_data)
set_task_progress(100, {"message": "Import complete"})
result_dict = asdict(result)
return {
"status": "SUCCESS",
"result": result_dict,
"error": None,
}

View File

@@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="{{ lang|default:'fr' }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ page_title }}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
.container {
background-color: #ffffff;
border-radius: 8px;
padding: 30px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
text-align: center;
}
.icon {
font-size: 48px;
margin-bottom: 16px;
}
h1 {
color: {{ header_color }};
font-size: 24px;
margin: 0 0 16px;
}
.message {
font-size: 16px;
color: #555;
margin-bottom: 24px;
}
.submit-btn {
display: inline-block;
padding: 12px 32px;
background-color: {{ header_color }};
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
}
.submit-btn:hover {
opacity: 0.9;
}
</style>
</head>
<body>
<div class="container">
<div class="icon">{{ status_icon|safe }}</div>
<h1>{{ heading }}</h1>
<form id="rsvp-form" method="post" action="{{ post_url }}">
<input type="hidden" name="token" value="{{ token }}">
<input type="hidden" name="action" value="{{ action }}">
<noscript>
<button type="submit" class="submit-btn">{{ submit_label }}</button>
</noscript>
<p class="message" id="loading-msg">...</p>
</form>
</div>
<script>
document.getElementById('rsvp-form').submit();
</script>
</body>
</html>

View File

@@ -1,7 +1,7 @@
"""Unit tests for the Authentication Backends."""
import random
import re
from unittest import mock
from django.core.exceptions import SuspiciousOperation
from django.test.utils import override_settings
@@ -17,7 +17,14 @@ from core.factories import UserFactory
pytestmark = pytest.mark.django_db
# Patch org resolution out by default in this module.
# Tests for org resolution are in test_organizations.py.
_no_org_resolve = mock.patch(
"core.authentication.backends.resolve_organization", lambda *a, **kw: None
)
@_no_org_resolve
def test_authentication_getter_existing_user_no_email(
django_assert_num_queries, monkeypatch
):
@@ -41,6 +48,7 @@ def test_authentication_getter_existing_user_no_email(
assert user == db_user
@_no_org_resolve
def test_authentication_getter_existing_user_via_email(
django_assert_num_queries, monkeypatch
):
@@ -57,7 +65,7 @@ def test_authentication_getter_existing_user_via_email(
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(4): # user by sub, user by mail, update sub
with django_assert_num_queries(5): # user by sub, user by mail, update sub, org
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
@@ -65,30 +73,24 @@ def test_authentication_getter_existing_user_via_email(
assert user == db_user
def test_authentication_getter_email_none(monkeypatch):
def test_authentication_getter_email_none_rejected(monkeypatch):
"""
If no user is found with the sub and no email is provided, a new user should be created.
If no user is found with the sub and no email is provided,
user creation is rejected (organization requires email domain).
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory(email=None)
UserFactory() # existing user with different sub
def get_userinfo_mocked(*args):
user_info = {"sub": "123"}
if random.choice([True, False]):
user_info["email"] = None
return user_info
return {"sub": "123"}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
# Since the sub and email didn't match, it should create a new user
assert models.User.objects.count() == 2
assert user != db_user
assert user.sub == "123"
with pytest.raises(
SuspiciousOperation, match="Cannot create user without an organization"
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
def test_authentication_getter_existing_user_no_fallback_to_email_allow_duplicate(
@@ -154,6 +156,7 @@ def test_authentication_getter_existing_user_no_fallback_to_email_no_duplicate(
assert models.User.objects.count() == 1
@_no_org_resolve
def test_authentication_getter_existing_user_with_email(
django_assert_num_queries, monkeypatch
):
@@ -161,7 +164,7 @@ def test_authentication_getter_existing_user_with_email(
When the user's info contains an email and targets an existing user,
"""
klass = OIDCAuthenticationBackend()
user = UserFactory(full_name="John Doe", short_name="John")
user = UserFactory(full_name="John Doe")
def get_userinfo_mocked(*args):
return {
@@ -182,6 +185,7 @@ def test_authentication_getter_existing_user_with_email(
assert user == authenticated_user
@_no_org_resolve
@pytest.mark.parametrize(
"first_name, last_name, email",
[
@@ -199,9 +203,7 @@ def test_authentication_getter_existing_user_change_fields_sub(
and the user was identified by its "sub".
"""
klass = OIDCAuthenticationBackend()
user = UserFactory(
full_name="John Doe", short_name="John", email="john.doe@example.com"
)
user = UserFactory(full_name="John Doe", email="john.doe@example.com")
def get_userinfo_mocked(*args):
return {
@@ -213,8 +215,7 @@ def test_authentication_getter_existing_user_change_fields_sub(
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed
with django_assert_num_queries(3):
with django_assert_num_queries(4):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
@@ -223,9 +224,9 @@ def test_authentication_getter_existing_user_change_fields_sub(
user.refresh_from_db()
assert user.email == email
assert user.full_name == f"{first_name:s} {last_name:s}"
assert user.short_name == first_name
@_no_org_resolve
@pytest.mark.parametrize(
"first_name, last_name, email",
[
@@ -241,9 +242,7 @@ def test_authentication_getter_existing_user_change_fields_email(
and the user was identified by its "email" as fallback.
"""
klass = OIDCAuthenticationBackend()
user = UserFactory(
full_name="John Doe", short_name="John", email="john.doe@example.com"
)
user = UserFactory(full_name="John Doe", email="john.doe@example.com")
def get_userinfo_mocked(*args):
return {
@@ -255,8 +254,7 @@ def test_authentication_getter_existing_user_change_fields_email(
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed
with django_assert_num_queries(4):
with django_assert_num_queries(5):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
@@ -265,13 +263,12 @@ def test_authentication_getter_existing_user_change_fields_email(
user.refresh_from_db()
assert user.email == email
assert user.full_name == f"{first_name:s} {last_name:s}"
assert user.short_name == first_name
def test_authentication_getter_new_user_no_email(monkeypatch):
def test_authentication_getter_new_user_no_email_rejected(monkeypatch):
"""
If no user matches the user's info sub, a user should be created.
User's info doesn't contain an email, created user's email should be empty.
If no user matches the sub and no email is provided,
user creation is rejected (organization requires email domain).
"""
klass = OIDCAuthenticationBackend()
@@ -280,16 +277,10 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user.sub == "123"
assert user.email is None
assert user.full_name is None
assert user.short_name is None
assert user.has_usable_password() is False
assert models.User.objects.count() == 1
with pytest.raises(
SuspiciousOperation, match="Cannot create user without an organization"
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
def test_authentication_getter_new_user_with_email(monkeypatch):
@@ -314,7 +305,7 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
assert user.sub == "123"
assert user.email == email
assert user.full_name == "John Doe"
assert user.short_name == "John"
assert user.has_usable_password() is False
assert models.User.objects.count() == 1
@@ -458,6 +449,7 @@ def test_authentication_getter_existing_disabled_user_via_email(
assert models.User.objects.count() == 1
@_no_org_resolve
@responses.activate
def test_authentication_session_tokens(
django_assert_num_queries, monkeypatch, rf, settings
@@ -498,7 +490,7 @@ def test_authentication_session_tokens(
status=200,
)
with django_assert_num_queries(6):
with django_assert_num_queries(12):
user = klass.authenticate(
request,
code="test-code",
@@ -538,7 +530,7 @@ def test_authentication_store_claims_new_user(monkeypatch):
assert user.sub == "123"
assert user.email == email
assert user.full_name == "John Doe"
assert user.short_name == "John"
assert user.has_usable_password() is False
assert user.claims == {"iss": "https://example.com"}
assert models.User.objects.count() == 1

View File

@@ -1,10 +1,9 @@
"""Fixtures for tests in the calendars core application"""
import base64
from unittest import mock
from django.conf import settings
from django.core.cache import cache
from django.db import connection
import pytest
import responses
@@ -13,43 +12,86 @@ from core import factories
from core.tests.utils.urls import reload_urls
USER = "user"
TEAM = "team"
VIA = [USER, TEAM]
def _has_caldav_marker(request):
"""Check if the test has the xdist_group('caldav') marker."""
marker = request.node.get_closest_marker("xdist_group")
return marker is not None and marker.args and marker.args[0] == "caldav"
@pytest.fixture(autouse=True)
def truncate_caldav_tables(django_db_setup, django_db_blocker): # pylint: disable=unused-argument
"""Fixture to truncate CalDAV server tables at the start of each test.
def truncate_caldav_tables(request, django_db_setup, django_db_blocker): # pylint: disable=unused-argument
"""Truncate CalDAV tables before each CalDAV E2E test.
CalDAV server tables are created by the CalDAV server container migrations, not Django.
We just truncate them to ensure clean state for each test.
Only runs for tests marked with @pytest.mark.xdist_group("caldav").
Non-CalDAV tests don't touch the SabreDAV database, so truncating
from their worker would corrupt state for CalDAV tests running
concurrently on another xdist worker.
"""
with django_db_blocker.unblock():
with connection.cursor() as cursor:
# Truncate CalDAV server tables if they exist (created by CalDAV server container)
cursor.execute("""
DO $$
BEGIN
IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'principals') THEN
TRUNCATE TABLE principals CASCADE;
END IF;
IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'users') THEN
TRUNCATE TABLE users CASCADE;
END IF;
IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'calendars') THEN
TRUNCATE TABLE calendars CASCADE;
END IF;
IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'calendarinstances') THEN
TRUNCATE TABLE calendarinstances CASCADE;
END IF;
IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'calendarobjects') THEN
TRUNCATE TABLE calendarobjects CASCADE;
END IF;
END $$;
""")
if not _has_caldav_marker(request):
yield
return
import psycopg # noqa: PLC0415 # pylint: disable=import-outside-toplevel
db_settings = settings.DATABASES["default"]
conn = psycopg.connect(
host=db_settings["HOST"],
port=db_settings["PORT"],
dbname="calendars", # SabreDAV always uses this DB
user=db_settings["USER"],
password=db_settings["PASSWORD"],
)
conn.autocommit = True
try:
with conn.cursor() as cur: # pylint: disable=no-member
for table in [
"calendarobjects",
"calendarinstances",
"calendars",
"principals",
]:
cur.execute(f"TRUNCATE TABLE {table} CASCADE")
finally:
conn.close() # pylint: disable=no-member
yield
@pytest.fixture(autouse=True)
def disconnect_caldav_signals_for_unit_tests(request):
"""Disconnect CalDAV signal handlers for non-CalDAV tests.
Prevents non-CalDAV tests from hitting the real SabreDAV server
(e.g. via post_save signal when UserFactory creates a user),
which would interfere with CalDAV E2E tests running concurrently
on another xdist worker.
"""
if _has_caldav_marker(request):
yield
return
from django.contrib.auth import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
get_user_model,
)
from django.db.models.signals import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
post_save,
pre_delete,
)
from core.signals import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
delete_user_caldav_data,
provision_default_calendar,
)
user_model = get_user_model()
post_save.disconnect(provision_default_calendar, sender=user_model)
pre_delete.disconnect(delete_user_caldav_data, sender=user_model)
yield
post_save.connect(provision_default_calendar, sender=user_model)
pre_delete.connect(delete_user_caldav_data, sender=user_model)
@pytest.fixture(autouse=True)
def clear_cache():
"""Fixture to clear the cache after each test."""
@@ -58,16 +100,7 @@ def clear_cache():
# Clear functools.cache for functions decorated with @functools.cache
@pytest.fixture
def mock_user_teams():
"""Mock for the "teams" property on the User model."""
with mock.patch(
"core.models.User.teams", new_callable=mock.PropertyMock
) as mock_teams:
yield mock_teams
def resource_server_backend_setup(settings):
def resource_server_backend_setup(settings): # pylint: disable=redefined-outer-name
"""
A fixture to create a user token for testing.
"""
@@ -91,7 +124,7 @@ def resource_server_backend_setup(settings):
@pytest.fixture
def resource_server_backend_conf(settings):
def resource_server_backend_conf(settings): # pylint: disable=redefined-outer-name
"""
A fixture to create a user token for testing.
"""
@@ -100,7 +133,7 @@ def resource_server_backend_conf(settings):
@pytest.fixture
def resource_server_backend(settings):
def resource_server_backend(settings): # pylint: disable=redefined-outer-name
"""
A fixture to create a user token for testing.
Including a mocked introspection endpoint.

View File

@@ -43,29 +43,32 @@ def test_api_users_list_authenticated():
"/api/v1.0/users/",
)
assert response.status_code == 200
assert response.json() == []
assert response.json()["results"] == []
def test_api_users_list_query_inactive():
"""Inactive users should not be listed."""
user = factories.UserFactory()
org = user.organization
client = APIClient()
client.force_login(user)
factories.UserFactory(email="john.doe@example.com", is_active=False)
lennon = factories.UserFactory(email="john.lennon@example.com")
factories.UserFactory(
email="john.doe@example.com", is_active=False, organization=org
)
lennon = factories.UserFactory(email="john.lennon@example.com", organization=org)
# Use email query to get exact match
response = client.get("/api/v1.0/users/?q=john.lennon@example.com")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(lennon.id)]
# Inactive user should not be returned even with exact match
response = client.get("/api/v1.0/users/?q=john.doe@example.com")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == []
@@ -83,16 +86,16 @@ def test_api_users_list_query_short_queries():
response = client.get("/api/v1.0/users/?q=jo")
assert response.status_code == 200
assert response.json() == []
assert response.json()["results"] == []
response = client.get("/api/v1.0/users/?q=john")
assert response.status_code == 200
assert response.json() == []
assert response.json()["results"] == []
# Non-email queries (without @) return empty
response = client.get("/api/v1.0/users/?q=john.")
assert response.status_code == 200
assert response.json() == []
assert response.json()["results"] == []
def test_api_users_list_limit(settings):
@@ -101,6 +104,7 @@ def test_api_users_list_limit(settings):
should be limited to 10.
"""
user = factories.UserFactory()
org = user.organization
client = APIClient()
client.force_login(user)
@@ -108,14 +112,14 @@ def test_api_users_list_limit(settings):
# Use a base name with a length equal 5 to test that the limit is applied
base_name = "alice"
for i in range(15):
factories.UserFactory(email=f"{base_name}.{i}@example.com")
factories.UserFactory(email=f"{base_name}.{i}@example.com", organization=org)
# Non-email queries (without @) return empty
response = client.get(
"/api/v1.0/users/?q=alice",
)
assert response.status_code == 200
assert response.json() == []
assert response.json()["results"] == []
# Email queries require exact match
settings.API_USERS_LIST_LIMIT = 100
@@ -123,7 +127,7 @@ def test_api_users_list_limit(settings):
"/api/v1.0/users/?q=alice.0@example.com",
)
assert response.status_code == 200
assert len(response.json()) == 1
assert len(response.json()["results"]) == 1
def test_api_users_list_throttling_authenticated(settings):
@@ -157,19 +161,20 @@ def test_api_users_list_query_email(settings):
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["user_list_burst"] = "9999/minute"
user = factories.UserFactory()
org = user.organization
client = APIClient()
client.force_login(user)
dave = factories.UserFactory(email="david.bowman@work.com")
factories.UserFactory(email="nicole.bowman@work.com")
dave = factories.UserFactory(email="david.bowman@work.com", organization=org)
factories.UserFactory(email="nicole.bowman@work.com", organization=org)
# Exact match works
response = client.get(
"/api/v1.0/users/?q=david.bowman@work.com",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(dave.id)]
# Case-insensitive match works
@@ -177,7 +182,7 @@ def test_api_users_list_query_email(settings):
"/api/v1.0/users/?q=David.Bowman@Work.COM",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(dave.id)]
# Typos don't match (exact match only)
@@ -185,43 +190,48 @@ def test_api_users_list_query_email(settings):
"/api/v1.0/users/?q=davig.bovman@worm.com",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == []
response = client.get(
"/api/v1.0/users/?q=davig.bovman@worm.cop",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == []
def test_api_users_list_query_email_matching():
"""Email queries return exact matches only (case-insensitive)."""
user = factories.UserFactory()
org = user.organization
client = APIClient()
client.force_login(user)
user1 = factories.UserFactory(email="alice.johnson@example.gouv.fr")
factories.UserFactory(email="alice.johnnson@example.gouv.fr")
factories.UserFactory(email="alice.kohlson@example.gouv.fr")
user4 = factories.UserFactory(email="alicia.johnnson@example.gouv.fr")
factories.UserFactory(email="alicia.johnnson@example.gov.uk")
factories.UserFactory(email="alice.thomson@example.gouv.fr")
user1 = factories.UserFactory(
email="alice.johnson@example.gouv.fr", organization=org
)
factories.UserFactory(email="alice.johnnson@example.gouv.fr", organization=org)
factories.UserFactory(email="alice.kohlson@example.gouv.fr", organization=org)
user4 = factories.UserFactory(
email="alicia.johnnson@example.gouv.fr", organization=org
)
factories.UserFactory(email="alicia.johnnson@example.gov.uk", organization=org)
factories.UserFactory(email="alice.thomson@example.gouv.fr", organization=org)
# Exact match returns only that user
response = client.get(
"/api/v1.0/users/?q=alice.johnson@example.gouv.fr",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(user1.id)]
# Different email returns different user
response = client.get("/api/v1.0/users/?q=alicia.johnnson@example.gouv.fr")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(user4.id)]
@@ -260,9 +270,13 @@ def test_api_users_retrieve_me_authenticated():
"id": str(user.id),
"email": user.email,
"full_name": user.full_name,
"short_name": user.short_name,
"language": user.language,
"can_access": True,
"can_admin": True,
"organization": {
"id": str(user.organization.id),
"name": user.organization.name,
},
}

View File

@@ -27,7 +27,7 @@ class TestCalDAVProxy:
def test_proxy_requires_authentication(self):
"""Test that unauthenticated requests return 401."""
client = APIClient()
response = client.generic("PROPFIND", "/api/v1.0/caldav/")
response = client.generic("PROPFIND", "/caldav/")
assert response.status_code == HTTP_401_UNAUTHORIZED
@responses.activate
@@ -49,7 +49,7 @@ class TestCalDAVProxy:
)
)
client.generic("PROPFIND", "/api/v1.0/caldav/")
client.generic("PROPFIND", "/caldav/")
# Verify request was made to CalDAV server
assert len(responses.calls) == 1
@@ -77,7 +77,7 @@ class TestCalDAVProxy:
responses.add(
responses.Response(
method="PROPFIND",
url=f"{caldav_url}/api/v1.0/caldav/",
url=f"{caldav_url}/caldav/",
status=HTTP_207_MULTI_STATUS,
body='<?xml version="1.0"?><multistatus xmlns="DAV:"></multistatus>',
headers={"Content-Type": "application/xml"},
@@ -88,7 +88,7 @@ class TestCalDAVProxy:
malicious_email = "attacker@example.com"
client.generic(
"PROPFIND",
"/api/v1.0/caldav/",
"/caldav/",
HTTP_X_FORWARDED_USER=malicious_email,
)
@@ -107,10 +107,6 @@ class TestCalDAVProxy:
"X-Forwarded-User should NOT use client-sent header value"
)
@pytest.mark.skipif(
not settings.CALDAV_URL,
reason="CalDAV server URL not configured - integration test requires real server",
)
def test_proxy_propfind_response_contains_prefixed_urls(self):
"""PROPFIND responses should contain URLs with proxy prefix.
@@ -130,7 +126,7 @@ class TestCalDAVProxy:
)
response = client.generic(
"PROPFIND",
"/api/v1.0/caldav/",
"/caldav/",
data=propfind_body,
content_type="application/xml",
)
@@ -154,8 +150,8 @@ class TestCalDAVProxy:
if href and (
href.startswith("/principals/") or href.startswith("/calendars/")
):
assert href.startswith("/api/v1.0/caldav/"), (
f"Expected URL to start with /api/v1.0/caldav/, "
assert href.startswith("/caldav/"), (
f"Expected URL to start with /caldav/, "
f"got {href}. BaseUriPlugin is not using "
f"X-Forwarded-Prefix correctly. Full response: "
f"{response.content.decode('utf-8', errors='ignore')}"
@@ -178,7 +174,7 @@ class TestCalDAVProxy:
propfind_xml = """<?xml version="1.0"?>
<multistatus xmlns="DAV:">
<response>
<href>/api/v1.0/caldav/calendars/test@example.com/calendar-id/</href>
<href>/caldav/calendars/users/test@example.com/calendar-id/</href>
<propstat>
<prop>
<resourcetype>
@@ -193,14 +189,14 @@ class TestCalDAVProxy:
responses.add(
responses.Response(
method="PROPFIND",
url=f"{caldav_url}/api/v1.0/caldav/",
url=f"{caldav_url}/caldav/",
status=HTTP_207_MULTI_STATUS,
body=propfind_xml,
headers={"Content-Type": "application/xml"},
)
)
response = client.generic("PROPFIND", "/api/v1.0/caldav/")
response = client.generic("PROPFIND", "/caldav/")
assert response.status_code == HTTP_207_MULTI_STATUS
@@ -213,7 +209,7 @@ class TestCalDAVProxy:
# Verify the URL is passed through unchanged (sabre/dav should generate it with prefix)
href = href_elem.text
assert href == "/api/v1.0/caldav/calendars/test@example.com/calendar-id/", (
assert href == "/caldav/calendars/users/test@example.com/calendar-id/", (
f"Expected URL to be passed through unchanged, got {href}"
)
@@ -234,7 +230,7 @@ class TestCalDAVProxy:
propfind_xml = """<?xml version="1.0"?>
<multistatus xmlns="DAV:" xmlns:D="DAV:">
<response>
<D:href>/api/v1.0/caldav/principals/test@example.com/</D:href>
<D:href>/caldav/principals/users/test@example.com/</D:href>
<propstat>
<prop>
<resourcetype>
@@ -248,14 +244,14 @@ class TestCalDAVProxy:
responses.add(
responses.Response(
method="PROPFIND",
url=f"{caldav_url}/api/v1.0/caldav/",
url=f"{caldav_url}/caldav/",
status=HTTP_207_MULTI_STATUS,
body=propfind_xml,
headers={"Content-Type": "application/xml"},
)
)
response = client.generic("PROPFIND", "/api/v1.0/caldav/")
response = client.generic("PROPFIND", "/caldav/")
assert response.status_code == HTTP_207_MULTI_STATUS
@@ -268,7 +264,7 @@ class TestCalDAVProxy:
# Verify the URL is passed through unchanged (sabre/dav should generate it with prefix)
href = href_elem.text
assert href == "/api/v1.0/caldav/principals/test@example.com/", (
assert href == "/caldav/principals/users/test@example.com/", (
f"Expected URL to be passed through unchanged, got {href}"
)
@@ -283,7 +279,7 @@ class TestCalDAVProxy:
responses.add(
responses.Response(
method="PROPFIND",
url=f"{caldav_url}/api/v1.0/caldav/principals/test@example.com/",
url=f"{caldav_url}/caldav/principals/users/test@example.com/",
status=HTTP_207_MULTI_STATUS,
body='<?xml version="1.0"?><multistatus xmlns="DAV:"></multistatus>',
headers={"Content-Type": "application/xml"},
@@ -291,14 +287,12 @@ class TestCalDAVProxy:
)
# Request a specific path
client.generic("PROPFIND", "/api/v1.0/caldav/principals/test@example.com/")
client.generic("PROPFIND", "/caldav/principals/users/test@example.com/")
# Verify the request was made to the correct URL
assert len(responses.calls) == 1
request = responses.calls[0].request
assert (
request.url == f"{caldav_url}/api/v1.0/caldav/principals/test@example.com/"
)
assert request.url == f"{caldav_url}/caldav/principals/users/test@example.com/"
@responses.activate
def test_proxy_handles_options_request(self):
@@ -307,7 +301,7 @@ class TestCalDAVProxy:
client = APIClient()
client.force_login(user)
response = client.options("/api/v1.0/caldav/")
response = client.options("/caldav/")
assert response.status_code == HTTP_200_OK
assert "Access-Control-Allow-Methods" in response
@@ -319,9 +313,7 @@ class TestCalDAVProxy:
client = APIClient()
client.force_login(user)
response = client.generic(
"PROPFIND", "/api/v1.0/caldav/calendars/../../etc/passwd"
)
response = client.generic("PROPFIND", "/caldav/calendars/../../etc/passwd")
assert response.status_code == HTTP_400_BAD_REQUEST
def test_proxy_rejects_non_caldav_path(self):
@@ -330,7 +322,16 @@ class TestCalDAVProxy:
client = APIClient()
client.force_login(user)
response = client.generic("PROPFIND", "/api/v1.0/caldav/etc/passwd")
response = client.generic("PROPFIND", "/caldav/etc/passwd")
assert response.status_code == HTTP_400_BAD_REQUEST
def test_proxy_rejects_internal_api_path(self):
"""Test that proxy explicitly blocks /internal-api/ paths."""
user = factories.UserFactory(email="test@example.com")
client = APIClient()
client.force_login(user)
response = client.generic("POST", "/caldav/internal-api/resources/")
assert response.status_code == HTTP_400_BAD_REQUEST
@@ -343,11 +344,11 @@ class TestValidateCaldavProxyPath:
def test_calendars_path_is_valid(self):
"""Standard calendars path should be valid."""
assert validate_caldav_proxy_path("calendars/user@ex.com/uuid/") is True
assert validate_caldav_proxy_path("calendars/users/user@ex.com/uuid/") is True
def test_principals_path_is_valid(self):
"""Standard principals path should be valid."""
assert validate_caldav_proxy_path("principals/user@ex.com/") is True
assert validate_caldav_proxy_path("principals/users/user@ex.com/") is True
def test_traversal_is_rejected(self):
"""Directory traversal attempts should be rejected."""
@@ -363,4 +364,24 @@ class TestValidateCaldavProxyPath:
def test_leading_slash_calendars_is_valid(self):
"""Paths with leading slash should still be valid."""
assert validate_caldav_proxy_path("/calendars/user@ex.com/uuid/") is True
assert validate_caldav_proxy_path("/calendars/users/user@ex.com/uuid/") is True
def test_internal_api_is_rejected(self):
"""Internal API paths should be explicitly blocked."""
assert validate_caldav_proxy_path("internal-api/resources/") is False
def test_internal_api_with_leading_slash_is_rejected(self):
"""Internal API paths with leading slash should be blocked."""
assert validate_caldav_proxy_path("/internal-api/import/user/cal") is False
def test_encoded_traversal_is_rejected(self):
"""URL-encoded directory traversal should be rejected."""
assert validate_caldav_proxy_path("calendars/%2e%2e/%2e%2e/etc/passwd") is False
def test_encoded_internal_api_is_rejected(self):
"""URL-encoded internal-api path should be blocked."""
assert validate_caldav_proxy_path("%69nternal-api/resources/") is False
def test_encoded_null_byte_is_rejected(self):
"""URL-encoded null byte should be rejected."""
assert validate_caldav_proxy_path("calendars/user%00/") is False

View File

@@ -72,13 +72,10 @@ def create_test_server() -> tuple:
@pytest.mark.django_db
@pytest.mark.xdist_group("caldav")
class TestCalDAVScheduling:
"""Tests for CalDAV scheduling callback when creating events with attendees."""
@pytest.mark.skipif(
not settings.CALDAV_URL,
reason="CalDAV server URL not configured - integration test requires real server",
)
def test_scheduling_callback_received_when_creating_event_with_attendee( # noqa: PLR0915 # pylint: disable=too-many-locals,too-many-statements
self,
):
@@ -125,8 +122,8 @@ class TestCalDAVScheduling:
try:
# Create an event with an attendee
client = service.caldav._get_client(organizer) # pylint: disable=protected-access
calendar_url = f"{settings.CALDAV_URL}{caldav_path}"
client = service._get_client(organizer) # pylint: disable=protected-access
calendar_url = service._calendar_url(caldav_path) # pylint: disable=protected-access
# Add custom callback URL header to the client
# The CalDAV server will use this URL for the callback

View File

@@ -1,7 +1,5 @@
"""Tests for CalDAV service integration."""
from django.conf import settings
import pytest
from core import factories
@@ -9,6 +7,7 @@ from core.services.caldav_service import CalDAVClient, CalendarService
@pytest.mark.django_db
@pytest.mark.xdist_group("caldav")
class TestCalDAVClient:
"""Tests for CalDAVClient authentication and communication."""
@@ -30,10 +29,6 @@ class TestCalDAVClient:
assert "X-Forwarded-User" in dav_client.headers
assert dav_client.headers["X-Forwarded-User"] == user.email
@pytest.mark.skipif(
not settings.CALDAV_URL,
reason="CalDAV server URL not configured",
)
def test_create_calendar_authenticates_with_caldav_server(self):
"""Test that calendar creation authenticates successfully with CalDAV server."""
user = factories.UserFactory(email="test@example.com")
@@ -65,10 +60,6 @@ class TestCalDAVClient:
assert isinstance(caldav_path, str)
assert "calendars/" in caldav_path
@pytest.mark.skipif(
not settings.CALDAV_URL,
reason="CalDAV server URL not configured",
)
def test_create_calendar_with_color_persists(self):
"""Test that creating a calendar with a color saves it in CalDAV."""
user = factories.UserFactory(email="color-test@example.com")
@@ -79,7 +70,7 @@ class TestCalDAVClient:
caldav_path = service.create_calendar(user, name="Red Calendar", color=color)
# Fetch the calendar info and verify the color was persisted
info = service.caldav.get_calendar_info(user, caldav_path)
info = service.get_calendar_info(user, caldav_path)
assert info is not None
assert info["color"] == color
assert info["name"] == "Red Calendar"

View File

@@ -1,40 +1,37 @@
"""Tests for calendar subscription token API."""
from urllib.parse import quote
from django.urls import reverse
"""Tests for iCal feed channel creation via the channels API."""
import pytest
from rest_framework.status import (
HTTP_200_OK,
HTTP_201_CREATED,
HTTP_204_NO_CONTENT,
HTTP_400_BAD_REQUEST,
HTTP_401_UNAUTHORIZED,
HTTP_403_FORBIDDEN,
HTTP_404_NOT_FOUND,
)
from rest_framework.test import APIClient
from core import factories
from core.models import CalendarSubscriptionToken
from core.models import Channel
CHANNELS_URL = "/api/v1.0/channels/"
@pytest.mark.django_db
class TestSubscriptionTokenViewSet:
"""Tests for the new standalone SubscriptionTokenViewSet."""
class TestICalFeedChannels:
"""Tests for ical-feed channel creation via ChannelViewSet."""
def test_create_subscription_token(self):
"""Test creating a subscription token for a calendar."""
def test_create_ical_feed_channel(self):
"""Test creating an ical-feed channel for a calendar."""
user = factories.UserFactory()
caldav_path = f"/calendars/{user.email}/test-calendar-uuid/"
caldav_path = f"/calendars/users/{user.email}/test-calendar-uuid/"
client = APIClient()
client.force_login(user)
url = reverse("subscription-tokens-list")
response = client.post(
url,
CHANNELS_URL,
{
"name": "My Test Calendar",
"type": "ical-feed",
"caldav_path": caldav_path,
"calendar_name": "My Test Calendar",
},
@@ -47,233 +44,210 @@ class TestSubscriptionTokenViewSet:
assert "/ical/" in response.data["url"]
assert ".ics" in response.data["url"]
assert response.data["caldav_path"] == caldav_path
assert response.data["calendar_name"] == "My Test Calendar"
assert response.data["type"] == "ical-feed"
# Verify token was created in database
assert CalendarSubscriptionToken.objects.filter(
owner=user, caldav_path=caldav_path
# Verify channel was created in database
assert Channel.objects.filter(
user=user, caldav_path=caldav_path, type="ical-feed"
).exists()
def test_create_subscription_token_normalizes_path(self):
def test_create_ical_feed_normalizes_path(self):
"""Test that caldav_path is normalized to have leading/trailing slashes."""
user = factories.UserFactory()
caldav_path = f"calendars/{user.email}/test-uuid" # No leading/trailing slash
caldav_path = f"calendars/users/{user.email}/test-uuid"
client = APIClient()
client.force_login(user)
url = reverse("subscription-tokens-list")
response = client.post(
url,
{"caldav_path": caldav_path},
CHANNELS_URL,
{"name": "Cal", "type": "ical-feed", "caldav_path": caldav_path},
format="json",
)
assert response.status_code == HTTP_201_CREATED
# Path should be normalized
assert response.data["caldav_path"] == f"/calendars/{user.email}/test-uuid/"
assert (
response.data["caldav_path"] == f"/calendars/users/{user.email}/test-uuid/"
)
def test_create_subscription_token_returns_existing(self):
"""Test that creating a token when one exists returns the existing one."""
subscription = factories.CalendarSubscriptionTokenFactory()
def test_create_ical_feed_returns_existing(self):
"""Test that creating an ical-feed channel when one exists returns it."""
channel = factories.ICalFeedChannelFactory()
client = APIClient()
client.force_login(subscription.owner)
client.force_login(channel.user)
url = reverse("subscription-tokens-list")
response = client.post(
url,
CHANNELS_URL,
{
"caldav_path": subscription.caldav_path,
"name": "Updated Name",
"type": "ical-feed",
"caldav_path": channel.caldav_path,
"calendar_name": "Updated Name",
},
format="json",
)
assert response.status_code == HTTP_200_OK
assert response.data["token"] == str(subscription.token)
# Name should be updated
subscription.refresh_from_db()
assert subscription.calendar_name == "Updated Name"
channel.refresh_from_db()
assert channel.settings["calendar_name"] == "Updated Name"
def test_get_subscription_token_by_path(self):
"""Test retrieving an existing subscription token by CalDAV path."""
subscription = factories.CalendarSubscriptionTokenFactory()
def test_list_ical_feed_channels(self):
"""Test filtering channels by type=ical-feed."""
user = factories.UserFactory()
client = APIClient()
client.force_login(subscription.owner)
client.force_login(user)
url = reverse("subscription-tokens-by-path")
response = client.get(url, {"caldav_path": subscription.caldav_path})
# Create one ical-feed and one caldav channel
client.post(
CHANNELS_URL,
{
"name": "Feed",
"type": "ical-feed",
"caldav_path": f"/calendars/users/{user.email}/cal1/",
},
format="json",
)
client.post(
CHANNELS_URL,
{"name": "CalDAV Channel"},
format="json",
)
# Filter by type
response = client.get(CHANNELS_URL, {"type": "ical-feed"})
assert response.status_code == HTTP_200_OK
assert response.data["token"] == str(subscription.token)
assert "url" in response.data
assert len(response.data) == 1
assert response.data[0]["type"] == "ical-feed"
def test_get_subscription_token_not_found(self):
"""Test retrieving token when none exists."""
user = factories.UserFactory()
caldav_path = f"/calendars/{user.email}/nonexistent/"
# Without filter, both show up
response = client.get(CHANNELS_URL)
assert len(response.data) == 2
def test_delete_ical_feed_channel(self):
"""Test deleting an ical-feed channel."""
channel = factories.ICalFeedChannelFactory()
client = APIClient()
client.force_login(user)
client.force_login(channel.user)
url = reverse("subscription-tokens-by-path")
response = client.get(url, {"caldav_path": caldav_path})
assert response.status_code == HTTP_404_NOT_FOUND
def test_get_subscription_token_missing_path(self):
"""Test that missing caldav_path query param returns 400."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
url = reverse("subscription-tokens-by-path")
response = client.get(url)
assert response.status_code == HTTP_400_BAD_REQUEST
def test_delete_subscription_token(self):
"""Test revoking a subscription token."""
subscription = factories.CalendarSubscriptionTokenFactory()
client = APIClient()
client.force_login(subscription.owner)
base_url = reverse("subscription-tokens-by-path")
url = f"{base_url}?caldav_path={quote(subscription.caldav_path, safe='')}"
response = client.delete(url)
response = client.delete(f"{CHANNELS_URL}{channel.pk}/")
assert response.status_code == HTTP_204_NO_CONTENT
assert not CalendarSubscriptionToken.objects.filter(pk=subscription.pk).exists()
assert not Channel.objects.filter(pk=channel.pk).exists()
def test_delete_subscription_token_not_found(self):
"""Test deleting token when none exists."""
user = factories.UserFactory()
caldav_path = f"/calendars/{user.email}/nonexistent/"
client = APIClient()
client.force_login(user)
base_url = reverse("subscription-tokens-by-path")
url = f"{base_url}?caldav_path={quote(caldav_path, safe='')}"
response = client.delete(url)
assert response.status_code == HTTP_404_NOT_FOUND
def test_non_owner_cannot_create_token(self):
"""Test that users cannot create tokens for other users' calendars."""
def test_non_owner_cannot_create_ical_feed(self):
"""Test that users cannot create ical-feed channels for others' calendars."""
user = factories.UserFactory()
other_user = factories.UserFactory()
caldav_path = f"/calendars/{other_user.email}/test-calendar/"
caldav_path = f"/calendars/users/{other_user.email}/test-calendar/"
client = APIClient()
client.force_login(user)
url = reverse("subscription-tokens-list")
response = client.post(
url,
{"caldav_path": caldav_path},
CHANNELS_URL,
{
"name": "Stolen",
"type": "ical-feed",
"caldav_path": caldav_path,
},
format="json",
)
assert response.status_code == HTTP_403_FORBIDDEN
def test_non_owner_cannot_get_token(self):
"""Test that users cannot get tokens for other users' calendars."""
subscription = factories.CalendarSubscriptionTokenFactory()
def test_non_owner_cannot_list_others_channels(self):
"""Test that users only see their own channels."""
factories.ICalFeedChannelFactory()
other_user = factories.UserFactory()
client = APIClient()
client.force_login(other_user)
url = reverse("subscription-tokens-by-path")
response = client.get(url, {"caldav_path": subscription.caldav_path})
response = client.get(CHANNELS_URL, {"type": "ical-feed"})
assert response.status_code == HTTP_200_OK
assert len(response.data) == 0
assert response.status_code == HTTP_403_FORBIDDEN
def test_non_owner_cannot_delete_token(self):
"""Test that users cannot delete tokens for other users' calendars."""
subscription = factories.CalendarSubscriptionTokenFactory()
other_user = factories.UserFactory()
client = APIClient()
client.force_login(other_user)
base_url = reverse("subscription-tokens-by-path")
url = f"{base_url}?caldav_path={quote(subscription.caldav_path, safe='')}"
response = client.delete(url)
assert response.status_code == HTTP_403_FORBIDDEN
# Token should still exist
assert CalendarSubscriptionToken.objects.filter(pk=subscription.pk).exists()
def test_unauthenticated_cannot_create_token(self):
"""Test that unauthenticated users cannot create tokens."""
def test_unauthenticated_cannot_create(self):
"""Test that unauthenticated users cannot create channels."""
user = factories.UserFactory()
caldav_path = f"/calendars/{user.email}/test-calendar/"
caldav_path = f"/calendars/users/{user.email}/test-calendar/"
client = APIClient()
url = reverse("subscription-tokens-list")
response = client.post(
url,
{"caldav_path": caldav_path},
CHANNELS_URL,
{
"name": "Feed",
"type": "ical-feed",
"caldav_path": caldav_path,
},
format="json",
)
assert response.status_code == HTTP_401_UNAUTHORIZED
def test_unauthenticated_cannot_get_token(self):
"""Test that unauthenticated users cannot get tokens."""
subscription = factories.CalendarSubscriptionTokenFactory()
client = APIClient()
url = reverse("subscription-tokens-by-path")
response = client.get(url, {"caldav_path": subscription.caldav_path})
assert response.status_code == HTTP_401_UNAUTHORIZED
def test_regenerate_token(self):
"""Test regenerating a token by delete + create."""
subscription = factories.CalendarSubscriptionTokenFactory()
old_token = subscription.token
channel = factories.ICalFeedChannelFactory()
old_token = channel.encrypted_settings["token"]
client = APIClient()
client.force_login(subscription.owner)
client.force_login(channel.user)
base_by_path_url = reverse("subscription-tokens-by-path")
by_path_url = (
f"{base_by_path_url}?caldav_path={quote(subscription.caldav_path, safe='')}"
)
create_url = reverse("subscription-tokens-list")
# Delete old token
response = client.delete(by_path_url)
# Delete old channel
response = client.delete(f"{CHANNELS_URL}{channel.pk}/")
assert response.status_code == HTTP_204_NO_CONTENT
# Create new token
# Create new one for the same path
response = client.post(
create_url,
{"caldav_path": subscription.caldav_path},
CHANNELS_URL,
{
"name": "Feed",
"type": "ical-feed",
"caldav_path": channel.caldav_path,
},
format="json",
)
assert response.status_code == HTTP_201_CREATED
assert response.data["token"] != str(old_token)
assert response.data["token"] != old_token
def test_unique_constraint_per_owner_calendar(self):
"""Test that only one token can exist per owner+caldav_path."""
subscription = factories.CalendarSubscriptionTokenFactory()
# Try to create another token for the same path - should return existing
"""Test that only one ical-feed channel exists per owner+caldav_path."""
channel = factories.ICalFeedChannelFactory()
client = APIClient()
client.force_login(subscription.owner)
client.force_login(channel.user)
url = reverse("subscription-tokens-list")
# Try to create another - should return existing
response = client.post(
url,
{"caldav_path": subscription.caldav_path},
CHANNELS_URL,
{
"name": "Duplicate",
"type": "ical-feed",
"caldav_path": channel.caldav_path,
},
format="json",
)
# Should return the existing token, not create a new one
assert response.status_code == HTTP_200_OK
assert response.data["token"] == str(subscription.token)
assert (
CalendarSubscriptionToken.objects.filter(owner=subscription.owner).count()
== 1
assert Channel.objects.filter(user=channel.user, type="ical-feed").count() == 1
def test_url_contains_slugified_calendar_name(self):
"""Test that the URL contains the slugified calendar name."""
user = factories.UserFactory()
caldav_path = f"/calendars/users/{user.email}/cal/"
client = APIClient()
client.force_login(user)
response = client.post(
CHANNELS_URL,
{
"name": "My Awesome Calendar",
"type": "ical-feed",
"caldav_path": caldav_path,
"calendar_name": "My Awesome Calendar",
},
format="json",
)
assert response.status_code == HTTP_201_CREATED
assert "my-awesome-calendar.ics" in response.data["url"]
@pytest.mark.django_db
class TestPathInjectionProtection:
@@ -290,45 +264,40 @@ class TestPathInjectionProtection:
@pytest.mark.parametrize(
"malicious_suffix",
[
# Path traversal attacks
"../other-calendar/",
"../../etc/passwd/",
"..%2F..%2Fetc%2Fpasswd/", # URL-encoded traversal
# Query parameter injection
"..%2F..%2Fetc%2Fpasswd/",
"uuid?export=true/",
"uuid?admin=true/",
# Fragment injection
"uuid#malicious/",
# Special characters that shouldn't be in calendar IDs
"uuid;rm -rf/",
"uuid|cat /etc/passwd/",
"uuid$(whoami)/",
"uuid`whoami`/",
# Double slashes
"uuid//",
"/uuid/",
# Spaces and other whitespace
"uuid with spaces/",
"uuid\ttab/",
# Unicode tricks
"uuid\u002e\u002e/", # Unicode dots
"uuid\u002e\u002e/",
],
)
def test_create_token_rejects_malicious_calendar_id(self, malicious_suffix):
def test_create_rejects_malicious_calendar_id(self, malicious_suffix):
"""Test that malicious calendar IDs in paths are rejected."""
user = factories.UserFactory()
caldav_path = f"/calendars/{user.email}/{malicious_suffix}"
caldav_path = f"/calendars/users/{user.email}/{malicious_suffix}"
client = APIClient()
client.force_login(user)
url = reverse("subscription-tokens-list")
response = client.post(
url,
{"caldav_path": caldav_path},
CHANNELS_URL,
{
"name": "Bad",
"type": "ical-feed",
"caldav_path": caldav_path,
},
format="json",
)
# Should be rejected - either 403 (invalid format) or path doesn't normalize
assert response.status_code == HTTP_403_FORBIDDEN, (
f"Path '{caldav_path}' should be rejected but got {response.status_code}"
)
@@ -336,31 +305,30 @@ class TestPathInjectionProtection:
@pytest.mark.parametrize(
"malicious_path",
[
# Completely wrong structure
"/etc/passwd/",
"/admin/calendars/user@test.com/uuid/",
"/../calendars/user@test.com/uuid/",
# Missing segments
"/calendars/",
"/calendars/user@test.com/",
# Path traversal to access another user's calendar
"/calendars/victim@test.com/../attacker@test.com/uuid/",
],
)
def test_create_token_rejects_malformed_paths(self, malicious_path):
def test_create_rejects_malformed_paths(self, malicious_path):
"""Test that malformed CalDAV paths are rejected."""
user = factories.UserFactory(email="attacker@test.com")
client = APIClient()
client.force_login(user)
url = reverse("subscription-tokens-list")
response = client.post(
url,
{"caldav_path": malicious_path},
CHANNELS_URL,
{
"name": "Bad",
"type": "ical-feed",
"caldav_path": malicious_path,
},
format="json",
)
# Should be rejected
assert response.status_code == HTTP_403_FORBIDDEN, (
f"Path '{malicious_path}' should be rejected but got {response.status_code}"
)
@@ -368,21 +336,23 @@ class TestPathInjectionProtection:
def test_path_traversal_to_other_user_calendar_rejected(self):
"""Test that path traversal to access another user's calendar is blocked."""
attacker = factories.UserFactory(email="attacker@example.com")
victim = factories.UserFactory(email="victim@example.com")
factories.UserFactory(email="victim@example.com")
client = APIClient()
client.force_login(attacker)
# Try to access victim's calendar via path traversal
malicious_paths = [
f"/calendars/{attacker.email}/../{victim.email}/secret-calendar/",
f"/calendars/{victim.email}/secret-calendar/", # Direct access
f"/calendars/{attacker.email}/../victim@example.com/secret-calendar/",
"/calendars/victim@example.com/secret-calendar/",
]
url = reverse("subscription-tokens-list")
for path in malicious_paths:
response = client.post(
url,
{"caldav_path": path},
CHANNELS_URL,
{
"name": "Bad",
"type": "ical-feed",
"caldav_path": path,
},
format="json",
)
assert response.status_code == HTTP_403_FORBIDDEN, (
@@ -392,15 +362,19 @@ class TestPathInjectionProtection:
def test_valid_uuid_path_accepted(self):
"""Test that valid UUID-style calendar IDs are accepted."""
user = factories.UserFactory()
# Standard UUID format
caldav_path = f"/calendars/{user.email}/550e8400-e29b-41d4-a716-446655440000/"
caldav_path = (
f"/calendars/users/{user.email}/550e8400-e29b-41d4-a716-446655440000/"
)
client = APIClient()
client.force_login(user)
url = reverse("subscription-tokens-list")
response = client.post(
url,
{"caldav_path": caldav_path},
CHANNELS_URL,
{
"name": "Good",
"type": "ical-feed",
"caldav_path": caldav_path,
},
format="json",
)
@@ -409,43 +383,18 @@ class TestPathInjectionProtection:
def test_valid_alphanumeric_path_accepted(self):
"""Test that valid alphanumeric calendar IDs are accepted."""
user = factories.UserFactory()
# Alphanumeric with hyphens (allowed by regex)
caldav_path = f"/calendars/{user.email}/my-calendar-2024/"
caldav_path = f"/calendars/users/{user.email}/my-calendar-2024/"
client = APIClient()
client.force_login(user)
url = reverse("subscription-tokens-list")
response = client.post(
url,
{"caldav_path": caldav_path},
CHANNELS_URL,
{
"name": "Good",
"type": "ical-feed",
"caldav_path": caldav_path,
},
format="json",
)
assert response.status_code == HTTP_201_CREATED
def test_get_token_with_malicious_path_rejected(self):
"""Test that GET requests with malicious paths are rejected."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
malicious_path = f"/calendars/{user.email}/../../../etc/passwd/"
url = reverse("subscription-tokens-by-path")
response = client.get(url, {"caldav_path": malicious_path})
assert response.status_code == HTTP_403_FORBIDDEN
def test_delete_token_with_malicious_path_rejected(self):
"""Test that DELETE requests with malicious paths are rejected."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
malicious_path = f"/calendars/{user.email}/../../../etc/passwd/"
base_url = reverse("subscription-tokens-by-path")
url = f"{base_url}?caldav_path={quote(malicious_path, safe='')}"
response = client.delete(url)
assert response.status_code == HTTP_403_FORBIDDEN

View File

@@ -0,0 +1,456 @@
"""Tests for the Channel model and API."""
# pylint: disable=redefined-outer-name,missing-function-docstring,no-member
import uuid
from unittest.mock import patch
from django.core.exceptions import ValidationError
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
CHANNELS_URL = "/api/v1.0/channels/"
@pytest.fixture
def authenticated_client():
"""Return an (APIClient, User) pair with forced authentication."""
user = factories.UserFactory()
client = APIClient()
client.force_authenticate(user=user)
return client, user
# ---------------------------------------------------------------------------
# Model tests
# ---------------------------------------------------------------------------
class TestChannelModel:
"""Tests for the Channel model."""
def test_verify_token(self):
channel = factories.ChannelFactory()
token = channel.encrypted_settings["token"]
assert channel.verify_token(token)
assert not channel.verify_token("wrong-token")
def test_scope_validation_requires_at_least_one(self):
"""Channel with no scope should fail validation."""
channel = models.Channel(name="no-scope")
with pytest.raises(ValidationError):
channel.full_clean()
def test_role_property(self):
"""Role is stored in settings and accessible via property."""
user = factories.UserFactory()
channel = models.Channel(
name="test",
user=user,
settings={"role": "editor"},
)
assert channel.role == "editor"
channel.role = "admin"
assert channel.settings["role"] == "admin"
def test_role_default(self):
"""Role defaults to reader when not set."""
user = factories.UserFactory()
channel = models.Channel(name="test", user=user)
assert channel.role == "reader"
# ---------------------------------------------------------------------------
# API tests
# ---------------------------------------------------------------------------
class TestChannelAPI:
"""Tests for the Channel CRUD API."""
def test_create_channel(self, authenticated_client):
client, user = authenticated_client
response = client.post(
CHANNELS_URL,
{"name": "My Channel"},
format="json",
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "My Channel"
assert "token" in data # token revealed on creation
assert len(data["token"]) >= 20
assert data["role"] == "reader"
assert data["user"] == str(user.pk)
def test_create_channel_with_caldav_path(self, authenticated_client):
client, user = authenticated_client
caldav_path = f"/calendars/users/{user.email}/my-cal/"
response = client.post(
CHANNELS_URL,
{"name": "Cal Channel", "caldav_path": caldav_path},
format="json",
)
assert response.status_code == 201
assert response.json()["caldav_path"] == caldav_path
def test_create_channel_wrong_caldav_path(self, authenticated_client):
client, _user = authenticated_client
response = client.post(
CHANNELS_URL,
{
"name": "Bad",
"caldav_path": "/calendars/users/other@example.com/cal/",
},
format="json",
)
assert response.status_code == 403
def test_list_channels(self, authenticated_client):
client, _user = authenticated_client
# Create 2 channels
for i in range(2):
client.post(
CHANNELS_URL,
{"name": f"Channel {i}"},
format="json",
)
response = client.get(CHANNELS_URL)
assert response.status_code == 200
assert len(response.json()) == 2
def test_list_channels_only_own(self, authenticated_client):
"""Users should only see their own channels."""
client, _user = authenticated_client
# Create a channel for another user
factories.ChannelFactory()
response = client.get(CHANNELS_URL)
assert response.status_code == 200
assert len(response.json()) == 0
def test_retrieve_channel(self, authenticated_client):
client, _user = authenticated_client
create_resp = client.post(
CHANNELS_URL,
{"name": "Retrieve Me"},
format="json",
)
channel_id = create_resp.json()["id"]
response = client.get(f"{CHANNELS_URL}{channel_id}/")
assert response.status_code == 200
assert response.json()["name"] == "Retrieve Me"
assert "token" not in response.json() # token NOT in retrieve
def test_delete_channel(self, authenticated_client):
client, _user = authenticated_client
create_resp = client.post(
CHANNELS_URL,
{"name": "Delete Me"},
format="json",
)
channel_id = create_resp.json()["id"]
response = client.delete(f"{CHANNELS_URL}{channel_id}/")
assert response.status_code == 204
assert not models.Channel.objects.filter(pk=channel_id).exists()
def test_regenerate_token(self, authenticated_client):
client, _user = authenticated_client
create_resp = client.post(
CHANNELS_URL,
{"name": "Regen"},
format="json",
)
old_token = create_resp.json()["token"]
channel_id = create_resp.json()["id"]
response = client.post(f"{CHANNELS_URL}{channel_id}/regenerate-token/")
assert response.status_code == 200
new_token = response.json()["token"]
assert new_token != old_token
assert len(new_token) >= 20
def test_unauthenticated(self):
client = APIClient()
response = client.get(CHANNELS_URL)
assert response.status_code in (401, 403)
# ---------------------------------------------------------------------------
# CalDAV proxy channel auth tests
# ---------------------------------------------------------------------------
class TestCalDAVProxyChannelAuth:
"""Tests for channel token authentication in the CalDAV proxy."""
@patch("core.api.viewsets_caldav.CalDAVHTTPClient")
def test_channel_token_auth_propfind(self, mock_http_cls):
"""A reader channel token should allow PROPFIND."""
user = factories.UserFactory()
channel = factories.ChannelFactory(
user=user,
settings={"role": "reader"},
)
token = channel.encrypted_settings["token"]
mock_response = type(
"R",
(),
{
"status_code": 207,
"content": b"<xml/>",
"headers": {"Content-Type": "application/xml"},
},
)()
mock_http_cls.build_base_headers.return_value = {
"X-Api-Key": "test",
"X-Forwarded-User": user.email,
}
client = APIClient()
with patch(
"core.api.viewsets_caldav.requests.request", return_value=mock_response
):
response = client.generic(
"PROPFIND",
f"/caldav/calendars/users/{user.email}/",
HTTP_X_CHANNEL_ID=str(channel.pk),
HTTP_X_CHANNEL_TOKEN=token,
HTTP_DEPTH="1",
)
assert response.status_code == 207
@patch("core.api.viewsets_caldav.CalDAVHTTPClient")
def test_channel_token_reader_cannot_put(self, _mock_http_cls):
"""A reader channel should NOT allow PUT."""
user = factories.UserFactory()
channel = factories.ChannelFactory(
user=user,
settings={"role": "reader"},
)
token = channel.encrypted_settings["token"]
client = APIClient()
response = client.put(
f"/caldav/calendars/users/{user.email}/cal/event.ics",
data=b"BEGIN:VCALENDAR",
content_type="text/calendar",
HTTP_X_CHANNEL_ID=str(channel.pk),
HTTP_X_CHANNEL_TOKEN=token,
)
assert response.status_code == 403
@patch("core.api.viewsets_caldav.CalDAVHTTPClient")
def test_channel_token_editor_can_put(self, mock_http_cls):
"""An editor channel should allow PUT."""
user = factories.UserFactory()
channel = factories.ChannelFactory(
user=user,
settings={"role": "editor"},
)
token = channel.encrypted_settings["token"]
mock_response = type(
"R",
(),
{
"status_code": 201,
"content": b"",
"headers": {"Content-Type": "text/plain"},
},
)()
mock_http_cls.build_base_headers.return_value = {
"X-Api-Key": "test",
"X-Forwarded-User": user.email,
}
client = APIClient()
with patch(
"core.api.viewsets_caldav.requests.request", return_value=mock_response
):
response = client.put(
f"/caldav/calendars/users/{user.email}/cal/event.ics",
data=b"BEGIN:VCALENDAR",
content_type="text/calendar",
HTTP_X_CHANNEL_ID=str(channel.pk),
HTTP_X_CHANNEL_TOKEN=token,
)
assert response.status_code == 201
def test_channel_token_wrong_path(self):
"""Channel should not access paths outside its user scope."""
user = factories.UserFactory()
channel = factories.ChannelFactory(
user=user,
settings={"role": "reader"},
)
token = channel.encrypted_settings["token"]
client = APIClient()
response = client.generic(
"PROPFIND",
"/caldav/calendars/users/other@example.com/cal/",
HTTP_X_CHANNEL_ID=str(channel.pk),
HTTP_X_CHANNEL_TOKEN=token,
)
assert response.status_code == 403
def test_invalid_token(self):
"""Invalid token should return 401."""
user = factories.UserFactory()
channel = factories.ChannelFactory(
user=user,
settings={"role": "reader"},
)
client = APIClient()
response = client.generic(
"PROPFIND",
"/caldav/calendars/",
HTTP_X_CHANNEL_ID=str(channel.pk),
HTTP_X_CHANNEL_TOKEN="invalid-token-12345",
)
assert response.status_code == 401
def test_missing_channel_id(self):
"""Token without channel ID should return 401."""
client = APIClient()
response = client.generic(
"PROPFIND",
"/caldav/calendars/",
HTTP_X_CHANNEL_TOKEN="some-token",
)
assert response.status_code == 401
def test_nonexistent_channel_id(self):
"""Non-existent channel ID should return 401."""
client = APIClient()
response = client.generic(
"PROPFIND",
"/caldav/calendars/",
HTTP_X_CHANNEL_ID=str(uuid.uuid4()),
HTTP_X_CHANNEL_TOKEN="some-token",
)
assert response.status_code == 401
def test_inactive_channel_id(self):
"""Inactive channel should return 401."""
user = factories.UserFactory()
channel = factories.ChannelFactory(
user=user,
settings={"role": "reader"},
is_active=False,
)
token = channel.encrypted_settings["token"]
client = APIClient()
response = client.generic(
"PROPFIND",
f"/caldav/calendars/users/{user.email}/",
HTTP_X_CHANNEL_ID=str(channel.pk),
HTTP_X_CHANNEL_TOKEN=token,
)
assert response.status_code == 401
@patch("core.api.viewsets_caldav.CalDAVHTTPClient")
def test_caldav_path_scoped_channel(self, mock_http_cls):
"""Channel with caldav_path scope restricts to that path."""
user = factories.UserFactory()
scoped_path = f"/calendars/users/{user.email}/specific-cal/"
channel = factories.ChannelFactory(
user=user,
settings={"role": "reader"},
caldav_path=scoped_path,
)
token = channel.encrypted_settings["token"]
mock_response = type(
"R",
(),
{
"status_code": 207,
"content": b"<xml/>",
"headers": {"Content-Type": "application/xml"},
},
)()
mock_http_cls.build_base_headers.return_value = {
"X-Api-Key": "test",
"X-Forwarded-User": user.email,
}
client = APIClient()
# Allowed: within scoped path
with patch(
"core.api.viewsets_caldav.requests.request", return_value=mock_response
):
response = client.generic(
"PROPFIND",
f"/caldav{scoped_path}",
HTTP_X_CHANNEL_ID=str(channel.pk),
HTTP_X_CHANNEL_TOKEN=token,
HTTP_DEPTH="1",
)
assert response.status_code == 207
# Denied: different calendar
response = client.generic(
"PROPFIND",
f"/caldav/calendars/users/{user.email}/other-cal/",
HTTP_X_CHANNEL_ID=str(channel.pk),
HTTP_X_CHANNEL_TOKEN=token,
)
assert response.status_code == 403
def test_caldav_path_boundary_no_prefix_leak(self):
"""Scoped path /cal1/ must NOT match /cal1-secret/ (trailing slash boundary)."""
user = factories.UserFactory()
scoped_path = f"/calendars/users/{user.email}/cal1/"
channel = factories.ChannelFactory(
user=user,
settings={"role": "reader"},
caldav_path=scoped_path,
)
token = channel.encrypted_settings["token"]
client = APIClient()
response = client.generic(
"PROPFIND",
f"/caldav/calendars/users/{user.email}/cal1-secret/",
HTTP_X_CHANNEL_ID=str(channel.pk),
HTTP_X_CHANNEL_TOKEN=token,
)
assert response.status_code == 403
@patch("core.api.viewsets_caldav.get_user_entitlements")
def test_channel_mkcalendar_checks_entitlements(self, mock_entitlements):
"""MKCALENDAR via channel token must still check entitlements."""
mock_entitlements.return_value = {"can_access": False}
user = factories.UserFactory()
channel = factories.ChannelFactory(
user=user,
settings={"role": "admin"},
)
token = channel.encrypted_settings["token"]
client = APIClient()
response = client.generic(
"MKCALENDAR",
f"/caldav/calendars/users/{user.email}/new-cal/",
HTTP_X_CHANNEL_ID=str(channel.pk),
HTTP_X_CHANNEL_TOKEN=token,
)
assert response.status_code == 403
mock_entitlements.assert_called_once_with(user.sub, user.email)

View File

@@ -0,0 +1,709 @@
"""End-to-end cross-organization isolation tests against real SabreDAV.
These tests verify that org-scoped resources, calendars, and operations
are properly isolated between organizations. They hit the real SabreDAV
server (no mocks) to validate the full stack: Django -> SabreDAV -> DB.
Requires: CalDAV server running (skipped otherwise).
"""
# pylint: disable=no-member,broad-exception-caught,unused-variable
from datetime import datetime, timedelta
from types import SimpleNamespace
import pytest
from rest_framework.status import (
HTTP_201_CREATED,
HTTP_204_NO_CONTENT,
HTTP_207_MULTI_STATUS,
HTTP_400_BAD_REQUEST,
)
from rest_framework.test import APIClient
from core import factories
from core.entitlements.factory import get_entitlements_backend
from core.models import Organization, User
from core.services.caldav_service import CalDAVHTTPClient, CalendarService
from core.services.resource_service import ResourceProvisioningError, ResourceService
pytestmark = [
pytest.mark.django_db,
pytest.mark.xdist_group("caldav"),
]
@pytest.fixture(autouse=True)
def _local_entitlements(settings):
"""Use local entitlements backend for all tests in this module."""
settings.ENTITLEMENTS_BACKEND = (
"core.entitlements.backends.local.LocalEntitlementsBackend"
)
settings.ENTITLEMENTS_BACKEND_PARAMETERS = {}
get_entitlements_backend.cache_clear()
yield
get_entitlements_backend.cache_clear()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _create_org_admin(org):
"""Create a user in the given org and return (user, api_client).
Uses force_login (not force_authenticate) so that session-based auth
works for the CalDAV proxy view, which checks request.user.is_authenticated
via Django's session middleware rather than DRF's token auth.
"""
user = factories.UserFactory(organization=org)
client = APIClient()
client.force_login(user)
return user, client
def _create_resource_via_internal_api(user, name="Room 1", resource_type="ROOM"):
"""Create a resource using ResourceService (hits real SabreDAV)."""
service = ResourceService()
return service.create_resource(user, name, resource_type)
def _get_dav_client_with_org(user):
"""Get a DAVClient with org header via CalDAVHTTPClient."""
http = CalDAVHTTPClient()
return http.get_dav_client(user)
def _propfind_resource_principals(api_client):
"""PROPFIND /caldav/principals/resources/ and return parsed XML root."""
body = (
'<?xml version="1.0"?>'
'<propfind xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">'
"<prop>"
"<displayname/>"
"<C:calendar-user-type/>"
"</prop>"
"</propfind>"
)
response = api_client.generic(
"PROPFIND",
"/caldav/principals/resources/",
data=body,
content_type="application/xml",
HTTP_DEPTH="1",
)
return response
def _propfind_resource_calendar(api_client, resource_id):
"""PROPFIND a specific resource's calendar collection."""
body = (
'<?xml version="1.0"?>'
'<propfind xmlns="DAV:"><prop><resourcetype/></prop></propfind>'
)
response = api_client.generic(
"PROPFIND",
f"/caldav/calendars/resources/{resource_id}/",
data=body,
content_type="application/xml",
HTTP_DEPTH="1",
)
return response
def _put_event_on_resource(api_client, resource_id, event_uid, organizer_email):
"""PUT an event directly onto a resource's default calendar."""
dtstart = datetime.now() + timedelta(days=1)
dtend = dtstart + timedelta(hours=1)
ical = (
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"PRODID:-//Test//Test//EN\r\n"
"BEGIN:VEVENT\r\n"
f"UID:{event_uid}\r\n"
f"DTSTART:{dtstart.strftime('%Y%m%dT%H%M%SZ')}\r\n"
f"DTEND:{dtend.strftime('%Y%m%dT%H%M%SZ')}\r\n"
"SUMMARY:Cross-org test event\r\n"
f"ORGANIZER:mailto:{organizer_email}\r\n"
"END:VEVENT\r\n"
"END:VCALENDAR\r\n"
)
return api_client.generic(
"PUT",
f"/caldav/calendars/resources/{resource_id}/default/{event_uid}.ics",
data=ical,
content_type="text/calendar",
)
# ---------------------------------------------------------------------------
# 1. Resource provisioning — cross-org isolation via real SabreDAV
# ---------------------------------------------------------------------------
class TestResourceProvisioningE2E:
"""Resource creation and deletion hit real SabreDAV internal API."""
def test_create_resource_e2e(self):
"""POST /resources/ creates a principal in SabreDAV."""
org = factories.OrganizationFactory(external_id="res-e2e-org")
admin, client = _create_org_admin(org)
response = client.post(
"/api/v1.0/resources/",
{"name": "Meeting Room A", "resource_type": "ROOM"},
format="json",
)
assert response.status_code == HTTP_201_CREATED, response.json()
data = response.json()
assert data["name"] == "Meeting Room A"
assert data["resource_type"] == "ROOM"
resource_id = data["id"]
# Verify the principal actually exists in SabreDAV via PROPFIND
propfind = _propfind_resource_calendar(client, resource_id)
assert propfind.status_code == HTTP_207_MULTI_STATUS, (
f"Resource calendar not found in SabreDAV: {propfind.status_code}"
)
def test_delete_resource_e2e_same_org(self):
"""Admin can delete a resource belonging to their own org."""
org = factories.OrganizationFactory(external_id="del-same-org")
admin, client = _create_org_admin(org)
resource = _create_resource_via_internal_api(admin, "Doomed Room")
resource_id = resource["id"]
response = client.delete(f"/api/v1.0/resources/{resource_id}/")
assert response.status_code == HTTP_204_NO_CONTENT
# Verify the principal is gone from SabreDAV
propfind = _propfind_resource_calendar(client, resource_id)
# Should be 404 or 207 with empty result — the principal was deleted
assert propfind.status_code != HTTP_207_MULTI_STATUS or (
b"<response>" not in propfind.content
)
def test_delete_resource_e2e_cross_org_blocked(self):
"""Admin from org A CANNOT delete a resource belonging to org B.
This is enforced by SabreDAV's InternalApiPlugin, not Django.
"""
org_a = factories.OrganizationFactory(external_id="del-org-a")
org_b = factories.OrganizationFactory(external_id="del-org-b")
# Create resource in org B
admin_b = factories.UserFactory(organization=org_b)
resource = _create_resource_via_internal_api(admin_b, "Org B Room")
resource_id = resource["id"]
# Admin from org A tries to delete it
_, client_a = _create_org_admin(org_a)
response = client_a.delete(f"/api/v1.0/resources/{resource_id}/")
# Django returns 400 because SabreDAV returned 403
assert response.status_code == HTTP_400_BAD_REQUEST
assert "different organization" in response.json()["detail"].lower()
# ---------------------------------------------------------------------------
# 2. CalDAV proxy — org header forwarding verified E2E
# ---------------------------------------------------------------------------
class TestCalDAVProxyOrgHeaderE2E:
"""Verify org header reaches SabreDAV and affects responses."""
def test_user_can_propfind_own_calendar(self):
"""User can PROPFIND their own calendar home."""
org = factories.OrganizationFactory(external_id="proxy-own")
user, client = _create_org_admin(org)
# Create a calendar for the user
service = CalendarService()
service.create_calendar(user, name="My Cal")
response = client.generic(
"PROPFIND",
f"/caldav/calendars/users/{user.email}/",
data='<?xml version="1.0"?><propfind xmlns="DAV:"><prop>'
"<displayname/></prop></propfind>",
content_type="application/xml",
HTTP_DEPTH="1",
)
assert response.status_code == HTTP_207_MULTI_STATUS
def test_user_cannot_read_other_users_calendar_objects(self):
"""User from org A cannot read events from user B's calendar.
SabreDAV allows PROPFIND on calendar homes (auto-creates principals),
but blocks reading actual calendar objects via ACLs. The key isolation
is that event data (REPORT/GET on .ics) is protected.
"""
org_a = factories.OrganizationFactory(external_id="proxy-org-a")
org_b = factories.OrganizationFactory(external_id="proxy-org-b")
user_a, client_a = _create_org_admin(org_a)
user_b = factories.UserFactory(organization=org_b)
# Create a calendar with an event for user B
service = CalendarService()
caldav_path = service.create_calendar(user_b, name="B's Calendar")
parts = caldav_path.strip("/").split("/")
cal_id = parts[-1] if len(parts) >= 4 else "default"
# Add an event to user B's calendar
dav_b = CalDAVHTTPClient().get_dav_client(user_b)
principal_b = dav_b.principal()
cals_b = principal_b.calendars()
dtstart = datetime.now() + timedelta(days=10)
dtend = dtstart + timedelta(hours=1)
ical = (
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"PRODID:-//Test//Test//EN\r\n"
"BEGIN:VEVENT\r\n"
"UID:private-event-uid\r\n"
f"DTSTART:{dtstart.strftime('%Y%m%dT%H%M%SZ')}\r\n"
f"DTEND:{dtend.strftime('%Y%m%dT%H%M%SZ')}\r\n"
"SUMMARY:Private Event\r\n"
"END:VEVENT\r\n"
"END:VCALENDAR\r\n"
)
cals_b[0].save_event(ical)
# User A tries to GET user B's event
response = client_a.generic(
"GET",
f"/caldav/calendars/users/{user_b.email}/{cal_id}/private-event-uid.ics",
)
# SabreDAV should block with 403 (ACL) or 404
assert response.status_code in (403, 404), (
f"Expected 403/404 for cross-user GET, got {response.status_code}"
)
# ---------------------------------------------------------------------------
# 3. Resource calendar access — cross-org isolation
# ---------------------------------------------------------------------------
class TestResourceCalendarAccessE2E:
"""Verify that resource calendar data is org-scoped in SabreDAV.
Users cannot PUT events directly on resource calendars — only
SabreDAV's scheduling plugin writes there via iTIP. These tests
verify that direct PUT is blocked by ACLs for both same-org and
cross-org users.
"""
def test_direct_put_on_resource_calendar_blocked(self):
"""Users cannot PUT events directly on resource calendars.
Resource calendars are managed by the auto-schedule plugin.
Direct writes are blocked by SabreDAV ACLs.
"""
org = factories.OrganizationFactory(external_id="put-direct-org")
user, client = _create_org_admin(org)
resource = _create_resource_via_internal_api(user, "ACL Room")
response = _put_event_on_resource(
client, resource["id"], "direct-put-uid", user.email
)
# Direct PUT on resource calendar is blocked by ACLs
assert response.status_code in (403, 404), (
f"Expected 403/404 for direct PUT on resource calendar, "
f"got {response.status_code}: "
f"{response.content.decode('utf-8', errors='ignore')[:500]}"
)
def test_cross_org_put_on_resource_calendar_blocked(self):
"""Cross-org direct PUT on resource calendar is also blocked."""
org_a = factories.OrganizationFactory(external_id="put-org-a")
org_b = factories.OrganizationFactory(external_id="put-org-b")
_, client_a = _create_org_admin(org_a)
admin_b = factories.UserFactory(organization=org_b)
resource_b = _create_resource_via_internal_api(admin_b, "Org B Room")
response = _put_event_on_resource(
client_a, resource_b["id"], "cross-org-event-uid", "attacker@test.com"
)
assert response.status_code in (403, 404), (
f"Expected 403/404 for cross-org PUT on resource calendar, "
f"got {response.status_code}"
)
# ---------------------------------------------------------------------------
# 4. Resource auto-scheduling — cross-org booking rejection
# ---------------------------------------------------------------------------
class TestResourceAutoScheduleCrossOrgE2E:
"""Verify that cross-org resource bookings are declined by SabreDAV.
The ResourceAutoSchedulePlugin checks X-CalDAV-Organization against
the resource's org_id and declines cross-org booking requests.
NOTE: SabreDAV's scheduling plugin resolves attendees via the principal
backend. Resource principals live under principals/resources/, and the
scheduling plugin must find them by email for iTIP delivery to work.
If SCHEDULE-STATUS=5.x appears, it means the principal wasn't resolved,
which blocks both auto-accept and auto-decline. These tests verify the
org-scoping logic in InternalApiPlugin (create/delete) rather than
iTIP scheduling, since iTIP requires searchPrincipals() support for
the resources collection.
"""
def test_resource_create_stores_org_id(self):
"""Resource creation stores org_id in SabreDAV for later scoping."""
org = factories.OrganizationFactory(external_id="sched-orgid")
user, _ = _create_org_admin(org)
resource = _create_resource_via_internal_api(user, "Org Room")
# Verify the resource exists by PROPFIND on its calendar
propfind = _propfind_resource_calendar(
_create_org_admin(org)[1], resource["id"]
)
assert propfind.status_code == HTTP_207_MULTI_STATUS
def test_resource_delete_cross_org_rejected_by_sabredav(self):
"""SabreDAV's InternalApiPlugin rejects cross-org resource deletion.
This is the core org-scoping enforcement: the org_id stored on creation
is checked on deletion.
"""
org_a = factories.OrganizationFactory(external_id="sched-del-a")
org_b = factories.OrganizationFactory(external_id="sched-del-b")
admin_a = factories.UserFactory(organization=org_a)
admin_b = factories.UserFactory(organization=org_b)
# Create resource in org B
resource = _create_resource_via_internal_api(admin_b, "Org B Room")
# Attempt delete from org A — should fail
service = ResourceService()
with pytest.raises(ResourceProvisioningError, match="different organization"):
service.delete_resource(admin_a, resource["id"])
# Verify resource still exists
_, client_b = _create_org_admin(org_b)
propfind = _propfind_resource_calendar(client_b, resource["id"])
assert propfind.status_code == HTTP_207_MULTI_STATUS
# ---------------------------------------------------------------------------
# 5. Resource principal discovery — all orgs see resource list
# but data is org-scoped
# ---------------------------------------------------------------------------
class TestResourceDiscoveryE2E:
"""Verify resource principal discovery behavior across orgs."""
def test_resource_principals_visible_to_authenticated_users(self):
"""Any authenticated user can PROPFIND /principals/resources/.
ResourcePrincipal grants {DAV:}read to {DAV:}authenticated.
This allows resource discovery for scheduling.
"""
org = factories.OrganizationFactory(external_id="disc-org")
user, client = _create_org_admin(org)
resource = _create_resource_via_internal_api(user, "Discoverable Room")
response = _propfind_resource_principals(client)
assert response.status_code == HTTP_207_MULTI_STATUS
# The response should contain the resource we just created
content = response.content.decode("utf-8", errors="ignore")
assert resource["id"] in content or "Discoverable Room" in content
# ---------------------------------------------------------------------------
# 6. User deletion cleanup — real SabreDAV
# ---------------------------------------------------------------------------
class TestUserDeletionCleanupE2E:
"""Verify user deletion cleans up CalDAV data in SabreDAV."""
def test_deleting_user_removes_caldav_principal(self):
"""When a Django user is deleted, their SabreDAV principal is cleaned up."""
user = factories.UserFactory(email="doomed-user@test-e2e.com")
# Create a calendar for the user (creates principal in SabreDAV)
service = CalendarService()
service.create_calendar(user, name="Soon Deleted")
# Verify calendar exists
dav = CalDAVHTTPClient().get_dav_client(user)
principal = dav.principal()
assert len(principal.calendars()) > 0
# Capture org before delete (Python obj persists but be explicit)
org_id = user.organization_id
# Delete the user (signal triggers CalDAV cleanup)
user.delete()
# Verify the principal's calendars are gone
# After deletion, the principal shouldn't exist, but due to
# auto-create behavior, just check calendars are empty
ghost = SimpleNamespace(
email="doomed-user@test-e2e.com", organization_id=org_id
)
dav2 = CalDAVHTTPClient().get_dav_client(ghost)
try:
principal2 = dav2.principal()
cals = principal2.calendars()
# Either no calendars or the principal doesn't exist
assert len(cals) == 0, (
f"Expected 0 calendars after deletion, found {len(cals)}"
)
except Exception: # noqa: BLE001
# Principal not found — expected
pass
# ---------------------------------------------------------------------------
# 7. Organization deletion cleanup — real SabreDAV
# ---------------------------------------------------------------------------
class TestOrgDeletionCleanupE2E:
"""Verify org deletion cleans up all member CalDAV data."""
def test_deleting_org_removes_all_member_caldav_data(self):
"""Deleting an org cleans up CalDAV data for all its members."""
org = factories.OrganizationFactory(external_id="doomed-org-e2e")
alice = factories.UserFactory(
email="alice-doomed@test-e2e.com", organization=org
)
bob = factories.UserFactory(email="bob-doomed@test-e2e.com", organization=org)
# Create calendars for both users
service = CalendarService()
service.create_calendar(alice, name="Alice Cal")
service.create_calendar(bob, name="Bob Cal")
# Verify calendars exist
for user in [alice, bob]:
dav = CalDAVHTTPClient().get_dav_client(user)
assert len(dav.principal().calendars()) > 0
# Capture org_id before deletion
org_id = org.id
# Delete the org (cascades to member cleanup + user deletion)
org.delete()
# Verify both users' calendars are gone
emails = ["alice-doomed@test-e2e.com", "bob-doomed@test-e2e.com"]
for email in emails:
ghost = SimpleNamespace(email=email, organization_id=org_id)
dav = CalDAVHTTPClient().get_dav_client(ghost)
try:
cals = dav.principal().calendars()
assert len(cals) == 0
except Exception: # noqa: BLE001
pass # Principal not found — expected
assert not User.objects.filter(
email__in=[
"alice-doomed@test-e2e.com",
"bob-doomed@test-e2e.com",
]
).exists()
assert not Organization.objects.filter(external_id="doomed-org-e2e").exists()
# ---------------------------------------------------------------------------
# 8. Calendar creation isolation
# ---------------------------------------------------------------------------
class TestCalendarCreationIsolationE2E:
"""Verify calendar creation is scoped to the authenticated user."""
def test_mkcalendar_creates_for_authenticated_user_only(self):
"""MKCALENDAR via proxy creates calendar under the authenticated user's principal."""
org = factories.OrganizationFactory(external_id="mkcal-org")
user_a, client_a = _create_org_admin(org)
user_b = factories.UserFactory(organization=org)
# User A creates a calendar via proxy
response = client_a.generic(
"MKCALENDAR",
f"/caldav/calendars/users/{user_a.email}/new-cal-e2e/",
data=(
'<?xml version="1.0" encoding="utf-8" ?>'
'<C:mkcalendar xmlns:D="DAV:" '
'xmlns:C="urn:ietf:params:xml:ns:caldav">'
"<D:set><D:prop>"
"<D:displayname>E2E Test Calendar</D:displayname>"
"</D:prop></D:set>"
"</C:mkcalendar>"
),
content_type="application/xml",
)
assert response.status_code == 201, (
f"MKCALENDAR failed: {response.status_code} "
f"{response.content.decode('utf-8', errors='ignore')[:500]}"
)
# Verify user A has the calendar via CalDAV PROPFIND
dav_a = CalDAVHTTPClient().get_dav_client(user_a)
cal_names_a = [c.name for c in dav_a.principal().calendars()]
assert "E2E Test Calendar" in cal_names_a, (
f"Calendar 'E2E Test Calendar' not found for user A. Found: {cal_names_a}"
)
# Verify user B does NOT have this calendar
dav_b = CalDAVHTTPClient().get_dav_client(user_b)
try:
cal_names_b = [c.name for c in dav_b.principal().calendars()]
assert "E2E Test Calendar" not in cal_names_b
except Exception: # noqa: BLE001
pass # User B has no principal — that's fine
def test_cannot_create_calendar_under_other_user(self):
"""User A cannot MKCALENDAR under user B's principal."""
org = factories.OrganizationFactory(external_id="mkcal-cross")
user_a, client_a = _create_org_admin(org)
user_b = factories.UserFactory(
email="mkcal-victim@test-e2e.com", organization=org
)
response = client_a.generic(
"MKCALENDAR",
f"/caldav/calendars/users/{user_b.email}/hijacked-cal/",
data=(
'<?xml version="1.0" encoding="utf-8" ?>'
'<C:mkcalendar xmlns:D="DAV:" '
'xmlns:C="urn:ietf:params:xml:ns:caldav">'
"<D:set><D:prop>"
"<D:displayname>Hijacked Calendar</D:displayname>"
"</D:prop></D:set>"
"</C:mkcalendar>"
),
content_type="application/xml",
)
# SabreDAV should block this with 403 (ACL violation)
assert response.status_code in (403, 404, 409), (
f"Expected 403/404 for MKCALENDAR under another user, "
f"got {response.status_code}"
)
# ---------------------------------------------------------------------------
# 9. Event CRUD isolation
# ---------------------------------------------------------------------------
class TestEventCRUDIsolationE2E:
"""Verify event CRUD is scoped to the calendar owner."""
def test_user_cannot_put_event_in_other_users_calendar(self):
"""User A cannot PUT an event into user B's calendar."""
org = factories.OrganizationFactory(external_id="event-cross")
user_a, client_a = _create_org_admin(org)
user_b = factories.UserFactory(
email="event-victim@test-e2e.com", organization=org
)
# Create a calendar for user B
service = CalendarService()
caldav_path = service.create_calendar(user_b, name="B's Private Cal")
# Extract calendar ID from path
# Path format: calendars/users/email/cal-id/
parts = caldav_path.strip("/").split("/")
cal_id = parts[-1] if len(parts) >= 4 else "default"
dtstart = datetime.now() + timedelta(days=5)
dtend = dtstart + timedelta(hours=1)
ical = (
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"PRODID:-//Test//Test//EN\r\n"
"BEGIN:VEVENT\r\n"
"UID:malicious-event-uid\r\n"
f"DTSTART:{dtstart.strftime('%Y%m%dT%H%M%SZ')}\r\n"
f"DTEND:{dtend.strftime('%Y%m%dT%H%M%SZ')}\r\n"
"SUMMARY:Malicious Event\r\n"
f"ORGANIZER:mailto:{user_a.email}\r\n"
"END:VEVENT\r\n"
"END:VCALENDAR\r\n"
)
response = client_a.generic(
"PUT",
f"/caldav/calendars/users/{user_b.email}/{cal_id}/malicious.ics",
data=ical,
content_type="text/calendar",
)
# SabreDAV should block with 403 (ACL)
assert response.status_code in (403, 404, 409), (
f"Expected 403/404 for cross-user PUT, got {response.status_code}"
)
def test_user_cannot_delete_other_users_event(self):
"""User A cannot DELETE an event from user B's calendar."""
org = factories.OrganizationFactory(external_id="event-del-cross")
user_a, client_a = _create_org_admin(org)
user_b = factories.UserFactory(
email="event-del-victim@test-e2e.com", organization=org
)
# Create a calendar and event for user B
service = CalendarService()
caldav_path = service.create_calendar(user_b, name="B's Cal")
parts = caldav_path.strip("/").split("/")
cal_id = parts[-1] if len(parts) >= 4 else "default"
# Create event as user B
dav_b = CalDAVHTTPClient().get_dav_client(user_b)
principal_b = dav_b.principal()
cals_b = principal_b.calendars()
assert len(cals_b) > 0
dtstart = datetime.now() + timedelta(days=6)
dtend = dtstart + timedelta(hours=1)
ical = (
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"PRODID:-//Test//Test//EN\r\n"
"BEGIN:VEVENT\r\n"
"UID:victim-event-uid\r\n"
f"DTSTART:{dtstart.strftime('%Y%m%dT%H%M%SZ')}\r\n"
f"DTEND:{dtend.strftime('%Y%m%dT%H%M%SZ')}\r\n"
"SUMMARY:Victim's Event\r\n"
"END:VEVENT\r\n"
"END:VCALENDAR\r\n"
)
cals_b[0].save_event(ical)
# User A tries to DELETE user B's event
response = client_a.generic(
"DELETE",
f"/caldav/calendars/users/{user_b.email}/{cal_id}/victim-event-uid.ics",
)
assert response.status_code in (403, 404), (
f"Expected 403/404 for cross-user DELETE, got {response.status_code}"
)
# Verify event still exists via CalDAV API
http = CalDAVHTTPClient()
ical_data, _, _ = http.find_event_by_uid(user_b, "victim-event-uid")
assert ical_data is not None, (
"Victim's event should still exist after blocked deletion attempt"
)

View File

@@ -27,10 +27,10 @@ from core.entitlements.factory import get_entitlements_backend
def test_local_backend_always_grants_access():
"""The local backend should always return can_access=True."""
"""The local backend should always return can_access=True and can_admin=True."""
backend = LocalEntitlementsBackend()
result = backend.get_user_entitlements("sub-123", "user@example.com")
assert result == {"can_access": True}
assert result == {"can_access": True, "can_admin": True}
def test_local_backend_ignores_parameters():
@@ -42,7 +42,7 @@ def test_local_backend_ignores_parameters():
user_info={"some": "claim"},
force_refresh=True,
)
assert result == {"can_access": True}
assert result == {"can_access": True, "can_admin": True}
# -- Factory --
@@ -99,7 +99,7 @@ def test_deploycenter_backend_grants_access():
responses.add(
responses.GET,
DC_URL,
json={"entitlements": {"can_access": True}},
json={"entitlements": {"can_access": True, "can_admin": True}},
status=200,
)
@@ -109,7 +109,7 @@ def test_deploycenter_backend_grants_access():
api_key="test-key",
)
result = backend.get_user_entitlements("sub-123", "user@example.com")
assert result == {"can_access": True}
assert result == {"can_access": True, "can_admin": True}
# Verify request was made with correct params and header
assert len(responses.calls) == 1
@@ -135,7 +135,7 @@ def test_deploycenter_backend_denies_access():
api_key="test-key",
)
result = backend.get_user_entitlements("sub-123", "user@example.com")
assert result == {"can_access": False}
assert result == {"can_access": False, "can_admin": False}
@responses.activate
@@ -157,12 +157,12 @@ def test_deploycenter_backend_uses_cache():
# First call hits the API
result1 = backend.get_user_entitlements("sub-123", "user@example.com")
assert result1 == {"can_access": True}
assert result1["can_access"] is True
assert len(responses.calls) == 1
# Second call should use cache
result2 = backend.get_user_entitlements("sub-123", "user@example.com")
assert result2 == {"can_access": True}
assert result2["can_access"] is True
assert len(responses.calls) == 1 # No additional API call
@@ -230,7 +230,7 @@ def test_deploycenter_backend_fallback_to_stale_cache():
result = backend.get_user_entitlements(
"sub-123", "user@example.com", force_refresh=True
)
assert result == {"can_access": True}
assert result["can_access"] is True
@responses.activate
@@ -355,20 +355,21 @@ def test_user_me_serializer_includes_can_access_false():
assert data["can_access"] is False
def test_user_me_serializer_can_access_fail_open():
"""UserMeSerializer should return can_access=True when entitlements unavailable."""
def test_user_me_serializer_can_access_fail_closed():
"""UserMeSerializer should return can_access=False when entitlements unavailable."""
user = factories.UserFactory()
with mock.patch(
"core.api.serializers.get_user_entitlements",
side_effect=EntitlementsUnavailableError("unavailable"),
):
data = UserMeSerializer(user).data
assert data["can_access"] is True
assert data["can_access"] is False
# -- Signals integration --
@pytest.mark.xdist_group("caldav")
@override_settings(
CALDAV_URL="http://caldav:80",
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
@@ -392,6 +393,7 @@ def test_signal_skips_calendar_when_not_entitled():
get_entitlements_backend.cache_clear()
@pytest.mark.xdist_group("caldav")
@override_settings(
CALDAV_URL="http://caldav:80",
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
@@ -414,6 +416,7 @@ def test_signal_skips_calendar_when_entitlements_unavailable():
get_entitlements_backend.cache_clear()
@pytest.mark.xdist_group("caldav")
@override_settings(
CALDAV_URL="http://caldav:80",
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
@@ -456,7 +459,7 @@ class TestCalDAVProxyEntitlements: # pylint: disable=no-member
):
response = client.generic(
"MKCALENDAR",
"/api/v1.0/caldav/calendars/test@example.com/new-cal/",
"/caldav/calendars/users/test@example.com/new-cal/",
)
assert response.status_code == HTTP_403_FORBIDDEN
@@ -474,7 +477,7 @@ class TestCalDAVProxyEntitlements: # pylint: disable=no-member
):
response = client.generic(
"MKCOL",
"/api/v1.0/caldav/calendars/test@example.com/new-cal/",
"/caldav/calendars/users/test@example.com/new-cal/",
)
assert response.status_code == HTTP_403_FORBIDDEN
@@ -493,7 +496,7 @@ class TestCalDAVProxyEntitlements: # pylint: disable=no-member
):
response = client.generic(
"MKCALENDAR",
"/api/v1.0/caldav/calendars/test@example.com/new-cal/",
"/caldav/calendars/users/test@example.com/new-cal/",
)
assert response.status_code == HTTP_403_FORBIDDEN
@@ -509,7 +512,7 @@ class TestCalDAVProxyEntitlements: # pylint: disable=no-member
responses.add(
responses.Response(
method="MKCALENDAR",
url=f"{caldav_url}/api/v1.0/caldav/calendars/test@example.com/new-cal/",
url=f"{caldav_url}/caldav/calendars/users/test@example.com/new-cal/",
status=201,
body="",
)
@@ -521,7 +524,7 @@ class TestCalDAVProxyEntitlements: # pylint: disable=no-member
):
response = client.generic(
"MKCALENDAR",
"/api/v1.0/caldav/calendars/test@example.com/new-cal/",
"/caldav/calendars/users/test@example.com/new-cal/",
)
assert response.status_code == 201
@@ -538,7 +541,7 @@ class TestCalDAVProxyEntitlements: # pylint: disable=no-member
responses.add(
responses.Response(
method="PROPFIND",
url=f"{caldav_url}/api/v1.0/caldav/",
url=f"{caldav_url}/caldav/",
status=HTTP_207_MULTI_STATUS,
body='<?xml version="1.0"?><multistatus xmlns="DAV:"></multistatus>',
headers={"Content-Type": "application/xml"},
@@ -546,7 +549,7 @@ class TestCalDAVProxyEntitlements: # pylint: disable=no-member
)
# No entitlements mock needed — PROPFIND should not check entitlements
response = client.generic("PROPFIND", "/api/v1.0/caldav/")
response = client.generic("PROPFIND", "/caldav/")
assert response.status_code == HTTP_207_MULTI_STATUS
@@ -561,7 +564,7 @@ class TestCalDAVProxyEntitlements: # pylint: disable=no-member
responses.add(
responses.Response(
method="REPORT",
url=f"{caldav_url}/api/v1.0/caldav/calendars/other@example.com/cal-id/",
url=f"{caldav_url}/caldav/calendars/users/other@example.com/cal-id/",
status=HTTP_207_MULTI_STATUS,
body='<?xml version="1.0"?><multistatus xmlns="DAV:"></multistatus>',
headers={"Content-Type": "application/xml"},
@@ -570,7 +573,7 @@ class TestCalDAVProxyEntitlements: # pylint: disable=no-member
response = client.generic(
"REPORT",
"/api/v1.0/caldav/calendars/other@example.com/cal-id/",
"/caldav/calendars/users/other@example.com/cal-id/",
)
assert response.status_code == HTTP_207_MULTI_STATUS
@@ -587,7 +590,7 @@ class TestCalDAVProxyEntitlements: # pylint: disable=no-member
responses.add(
responses.Response(
method="PUT",
url=f"{caldav_url}/api/v1.0/caldav/calendars/other@example.com/cal-id/event.ics",
url=f"{caldav_url}/caldav/calendars/users/other@example.com/cal-id/event.ics",
status=HTTP_200_OK,
body="",
)
@@ -595,7 +598,7 @@ class TestCalDAVProxyEntitlements: # pylint: disable=no-member
response = client.generic(
"PUT",
"/api/v1.0/caldav/calendars/other@example.com/cal-id/event.ics",
"/caldav/calendars/users/other@example.com/cal-id/event.ics",
data=b"BEGIN:VCALENDAR\nEND:VCALENDAR",
content_type="text/calendar",
)

View File

@@ -1,9 +1,6 @@
"""Tests for iCal export endpoint."""
import uuid
"""Tests for iCal export endpoint (using Channel type=ical-feed)."""
from django.conf import settings
from django.urls import reverse
import pytest
import responses
@@ -11,22 +8,28 @@ from rest_framework.status import HTTP_200_OK, HTTP_404_NOT_FOUND, HTTP_502_BAD_
from rest_framework.test import APIClient
from core import factories
from core.models import uuid_to_urlsafe
@pytest.mark.django_db
class TestICalExport:
"""Tests for ICalExportView."""
def _ical_url(self, channel):
"""Build the iCal export URL for a channel."""
token = channel.encrypted_settings["token"]
short_id = uuid_to_urlsafe(channel.pk)
return f"/ical/{short_id}/{token}/calendar.ics"
def test_export_with_valid_token_returns_ics(self):
"""Test that a valid token returns iCal data."""
subscription = factories.CalendarSubscriptionTokenFactory()
channel = factories.ICalFeedChannelFactory()
client = APIClient()
# Mock CalDAV server response
with responses.RequestsMock() as rsps:
caldav_url = settings.CALDAV_URL
caldav_path = subscription.caldav_path.lstrip("/")
target_url = f"{caldav_url}/api/v1.0/caldav/{caldav_path}?export"
caldav_path = channel.caldav_path.lstrip("/")
target_url = f"{caldav_url}/caldav/{caldav_path}?export"
ics_content = b"""BEGIN:VCALENDAR
VERSION:2.0
@@ -47,8 +50,7 @@ END:VCALENDAR"""
content_type="text/calendar",
)
url = reverse("ical-export", kwargs={"token": subscription.token})
response = client.get(url)
response = client.get(self._ical_url(channel))
assert response.status_code == HTTP_200_OK
assert response["Content-Type"] == "text/calendar; charset=utf-8"
@@ -58,35 +60,38 @@ END:VCALENDAR"""
def test_export_with_invalid_token_returns_404(self):
"""Test that an invalid token returns 404."""
channel = factories.ICalFeedChannelFactory()
short_id = uuid_to_urlsafe(channel.pk)
client = APIClient()
invalid_token = uuid.uuid4()
url = reverse("ical-export", kwargs={"token": invalid_token})
response = client.get(url)
response = client.get(f"/ical/{short_id}/WrongTokenHere123/calendar.ics")
assert response.status_code == HTTP_404_NOT_FOUND
def test_export_with_invalid_channel_id_returns_404(self):
"""Test that a nonexistent channel ID returns 404."""
client = APIClient()
# base62-encoded zero UUID
response = client.get("/ical/0/SomeToken123/calendar.ics")
assert response.status_code == HTTP_404_NOT_FOUND
def test_export_with_inactive_token_returns_404(self):
"""Test that an inactive token returns 404."""
subscription = factories.CalendarSubscriptionTokenFactory(is_active=False)
channel = factories.ICalFeedChannelFactory(is_active=False)
client = APIClient()
url = reverse("ical-export", kwargs={"token": subscription.token})
response = client.get(url)
response = client.get(self._ical_url(channel))
assert response.status_code == HTTP_404_NOT_FOUND
def test_export_updates_last_accessed_at(self):
"""Test that accessing the export updates last_accessed_at."""
subscription = factories.CalendarSubscriptionTokenFactory()
assert subscription.last_accessed_at is None
def test_export_updates_last_used_at(self):
"""Test that accessing the export updates last_used_at."""
channel = factories.ICalFeedChannelFactory()
assert channel.last_used_at is None
client = APIClient()
with responses.RequestsMock() as rsps:
caldav_url = settings.CALDAV_URL
caldav_path = subscription.caldav_path.lstrip("/")
target_url = f"{caldav_url}/api/v1.0/caldav/{caldav_path}?export"
caldav_path = channel.caldav_path.lstrip("/")
target_url = f"{caldav_url}/caldav/{caldav_path}?export"
rsps.add(
responses.GET,
@@ -96,22 +101,20 @@ END:VCALENDAR"""
content_type="text/calendar",
)
url = reverse("ical-export", kwargs={"token": subscription.token})
client.get(url)
client.get(self._ical_url(channel))
subscription.refresh_from_db()
assert subscription.last_accessed_at is not None
channel.refresh_from_db()
assert channel.last_used_at is not None
def test_export_does_not_require_authentication(self):
"""Test that the endpoint is accessible without authentication."""
subscription = factories.CalendarSubscriptionTokenFactory()
channel = factories.ICalFeedChannelFactory()
client = APIClient()
# Not logging in - should still work
with responses.RequestsMock() as rsps:
caldav_url = settings.CALDAV_URL
caldav_path = subscription.caldav_path.lstrip("/")
target_url = f"{caldav_url}/api/v1.0/caldav/{caldav_path}?export"
caldav_path = channel.caldav_path.lstrip("/")
target_url = f"{caldav_url}/caldav/{caldav_path}?export"
rsps.add(
responses.GET,
@@ -121,20 +124,18 @@ END:VCALENDAR"""
content_type="text/calendar",
)
url = reverse("ical-export", kwargs={"token": subscription.token})
response = client.get(url)
response = client.get(self._ical_url(channel))
assert response.status_code == HTTP_200_OK
def test_export_sends_correct_headers_to_caldav(self):
"""Test that the proxy sends correct authentication headers to CalDAV."""
subscription = factories.CalendarSubscriptionTokenFactory()
channel = factories.ICalFeedChannelFactory()
client = APIClient()
with responses.RequestsMock() as rsps:
caldav_url = settings.CALDAV_URL
caldav_path = subscription.caldav_path.lstrip("/")
target_url = f"{caldav_url}/api/v1.0/caldav/{caldav_path}?export"
caldav_path = channel.caldav_path.lstrip("/")
target_url = f"{caldav_url}/caldav/{caldav_path}?export"
rsps.add(
responses.GET,
@@ -144,24 +145,22 @@ END:VCALENDAR"""
content_type="text/calendar",
)
url = reverse("ical-export", kwargs={"token": subscription.token})
client.get(url)
client.get(self._ical_url(channel))
# Verify headers sent to CalDAV
assert len(rsps.calls) == 1
request = rsps.calls[0].request
assert request.headers["X-Forwarded-User"] == subscription.owner.email
assert request.headers["X-Forwarded-User"] == channel.user.email
assert request.headers["X-Api-Key"] == settings.CALDAV_OUTBOUND_API_KEY
def test_export_handles_caldav_error(self):
"""Test that CalDAV server errors are handled gracefully."""
subscription = factories.CalendarSubscriptionTokenFactory()
channel = factories.ICalFeedChannelFactory()
client = APIClient()
with responses.RequestsMock() as rsps:
caldav_url = settings.CALDAV_URL
caldav_path = subscription.caldav_path.lstrip("/")
target_url = f"{caldav_url}/api/v1.0/caldav/{caldav_path}?export"
caldav_path = channel.caldav_path.lstrip("/")
target_url = f"{caldav_url}/caldav/{caldav_path}?export"
rsps.add(
responses.GET,
@@ -170,20 +169,18 @@ END:VCALENDAR"""
status=500,
)
url = reverse("ical-export", kwargs={"token": subscription.token})
response = client.get(url)
response = client.get(self._ical_url(channel))
assert response.status_code == HTTP_502_BAD_GATEWAY
def test_export_sets_security_headers(self):
"""Test that security headers are set correctly."""
subscription = factories.CalendarSubscriptionTokenFactory()
channel = factories.ICalFeedChannelFactory()
client = APIClient()
with responses.RequestsMock() as rsps:
caldav_url = settings.CALDAV_URL
caldav_path = subscription.caldav_path.lstrip("/")
target_url = f"{caldav_url}/api/v1.0/caldav/{caldav_path}?export"
caldav_path = channel.caldav_path.lstrip("/")
target_url = f"{caldav_url}/caldav/{caldav_path}?export"
rsps.add(
responses.GET,
@@ -193,24 +190,22 @@ END:VCALENDAR"""
content_type="text/calendar",
)
url = reverse("ical-export", kwargs={"token": subscription.token})
response = client.get(url)
response = client.get(self._ical_url(channel))
# Verify security headers
assert response["Cache-Control"] == "no-store, private"
assert response["Referrer-Policy"] == "no-referrer"
def test_export_uses_calendar_name_in_filename(self):
"""Test that the export filename uses the calendar_name."""
subscription = factories.CalendarSubscriptionTokenFactory(
calendar_name="My Test Calendar"
"""Test that the export filename uses the calendar_name from settings."""
channel = factories.ICalFeedChannelFactory(
settings={"role": "reader", "calendar_name": "My Test Calendar"}
)
client = APIClient()
with responses.RequestsMock() as rsps:
caldav_url = settings.CALDAV_URL
caldav_path = subscription.caldav_path.lstrip("/")
target_url = f"{caldav_url}/api/v1.0/caldav/{caldav_path}?export"
caldav_path = channel.caldav_path.lstrip("/")
target_url = f"{caldav_url}/caldav/{caldav_path}?export"
rsps.add(
responses.GET,
@@ -220,7 +215,15 @@ END:VCALENDAR"""
content_type="text/calendar",
)
url = reverse("ical-export", kwargs={"token": subscription.token})
response = client.get(url)
response = client.get(self._ical_url(channel))
assert "my-test-calendar.ics" in response["Content-Disposition"]
assert "My Test Calendar.ics" in response["Content-Disposition"]
def test_non_ical_feed_channel_returns_404(self):
"""Test that a valid token for a non-ical-feed channel returns 404."""
channel = factories.ChannelFactory() # type="caldav" (default)
token = channel.encrypted_settings["token"]
short_id = uuid_to_urlsafe(channel.pk)
client = APIClient()
response = client.get(f"/ical/{short_id}/{token}/calendar.ics")
assert response.status_code == HTTP_404_NOT_FOUND

View File

@@ -247,7 +247,7 @@ END:VCALENDAR"""
def _make_caldav_path(user):
"""Build a caldav_path string for a user (test helper)."""
return f"/calendars/{user.email}/{uuid.uuid4()}/"
return f"/calendars/users/{user.email}/{uuid.uuid4()}/"
def _make_sabredav_response( # noqa: PLR0913 # pylint: disable=too-many-arguments,too-many-positional-arguments
@@ -482,7 +482,7 @@ class TestICSImportService:
@patch("core.services.caldav_service.requests.request")
def test_import_passes_calendar_path(self, mock_post):
"""The import URL should include the caldav_path."""
"""The import URL should use the internal-api/import/ endpoint."""
mock_post.return_value = _make_sabredav_response(
total_events=1, imported_count=1
)
@@ -495,8 +495,11 @@ class TestICSImportService:
call_args = mock_post.call_args
url = call_args.args[0] if call_args.args else call_args.kwargs.get("url", "")
assert caldav_path in url
assert "?import" in url
assert "internal-api/import/" in url
# URL should contain the user email and calendar URI from the path
parts = caldav_path.strip("/").split("/")
assert parts[2] in url # user email
assert parts[3] in url # calendar URI
@patch("core.services.caldav_service.requests.request")
def test_import_sends_auth_headers(self, mock_post):
@@ -515,7 +518,7 @@ class TestICSImportService:
headers = call_kwargs["headers"]
assert headers["X-Api-Key"] == settings.CALDAV_OUTBOUND_API_KEY
assert headers["X-Forwarded-User"] == user.email
assert headers["X-Calendars-Import"] == settings.CALDAV_OUTBOUND_API_KEY
assert headers["X-Internal-Api-Key"] == settings.CALDAV_INTERNAL_API_KEY
assert headers["Content-Type"] == "text/calendar"
@patch("core.services.caldav_service.requests.request")
@@ -571,7 +574,7 @@ class TestImportEventsAPI:
"""Users cannot import to a calendar they don't own."""
owner = factories.UserFactory(email="owner@example.com")
other_user = factories.UserFactory(email="other@example.com")
caldav_path = f"/calendars/{owner.email}/some-uuid/"
caldav_path = f"/calendars/users/{owner.email}/some-uuid/"
client = APIClient()
client.force_login(other_user)
@@ -597,12 +600,12 @@ class TestImportEventsAPI:
)
response = client.post(self.IMPORT_URL, {"file": ics_file}, format="multipart")
assert response.status_code == 400
assert "caldav_path" in response.json()["error"]
assert "caldav_path" in response.json()["detail"]
def test_import_events_missing_file(self):
"""Request without a file should return 400."""
user = factories.UserFactory(email="nofile@example.com")
caldav_path = f"/calendars/{user.email}/some-uuid/"
caldav_path = f"/calendars/users/{user.email}/some-uuid/"
client = APIClient()
client.force_login(user)
@@ -613,12 +616,12 @@ class TestImportEventsAPI:
format="multipart",
)
assert response.status_code == 400
assert "No file provided" in response.json()["error"]
assert "No file provided" in response.json()["detail"]
def test_import_events_file_too_large(self):
"""Files exceeding MAX_FILE_SIZE should be rejected."""
user = factories.UserFactory(email="largefile@example.com")
caldav_path = f"/calendars/{user.email}/some-uuid/"
caldav_path = f"/calendars/users/{user.email}/some-uuid/"
client = APIClient()
client.force_login(user)
@@ -634,11 +637,11 @@ class TestImportEventsAPI:
format="multipart",
)
assert response.status_code == 400
assert "too large" in response.json()["error"]
assert "too large" in response.json()["detail"]
@patch.object(ICSImportService, "import_events")
def test_import_events_success(self, mock_import):
"""Successful import should return result data."""
def test_import_events_returns_task_id(self, mock_import):
"""Successful import should return a task_id for polling."""
mock_import.return_value = ImportResult(
total_events=3,
imported_count=3,
@@ -648,7 +651,7 @@ class TestImportEventsAPI:
)
user = factories.UserFactory(email="success@example.com")
caldav_path = f"/calendars/{user.email}/some-uuid/"
caldav_path = f"/calendars/users/{user.email}/some-uuid/"
client = APIClient()
client.force_login(user)
@@ -662,51 +665,20 @@ class TestImportEventsAPI:
format="multipart",
)
assert response.status_code == 200
assert response.status_code == 202
data = response.json()
assert data["total_events"] == 3
assert data["imported_count"] == 3
assert data["skipped_count"] == 0
assert "errors" not in data
assert "task_id" in data
@patch.object(ICSImportService, "import_events")
def test_import_events_partial_success(self, mock_import):
"""Partial success should include errors in response."""
mock_import.return_value = ImportResult(
total_events=3,
imported_count=2,
duplicate_count=0,
skipped_count=1,
errors=["Planning session"],
)
user = factories.UserFactory(email="partial@example.com")
caldav_path = f"/calendars/{user.email}/some-uuid/"
client = APIClient()
client.force_login(user)
ics_file = SimpleUploadedFile(
"events.ics", ICS_MULTIPLE_EVENTS, content_type="text/calendar"
)
response = client.post(
self.IMPORT_URL,
{"file": ics_file, "caldav_path": caldav_path},
format="multipart",
)
assert response.status_code == 200
data = response.json()
assert data["total_events"] == 3
assert data["imported_count"] == 2
assert data["skipped_count"] == 1
assert len(data["errors"]) == 1
# With EagerBroker, the task runs synchronously — poll for result
task_response = client.get(f"/api/v1.0/tasks/{data['task_id']}/")
assert task_response.status_code == 200
task_data = task_response.json()
assert task_data["status"] == "SUCCESS"
assert task_data["result"]["total_events"] == 3
assert task_data["result"]["imported_count"] == 3
@pytest.mark.skipif(
not settings.CALDAV_URL,
reason="CalDAV server URL not configured",
)
@pytest.mark.xdist_group("caldav")
class TestImportEventsE2E:
"""End-to-end tests that import ICS events through the real SabreDAV server."""
@@ -827,11 +799,16 @@ class TestImportEventsE2E:
format="multipart",
)
assert response.status_code == 200
data = response.json()
assert data["total_events"] == 3
assert data["imported_count"] == 3
assert data["skipped_count"] == 0
assert response.status_code == 202
task_id = response.json()["task_id"]
# With EagerBroker, poll for the synchronous result
task_response = client.get(f"/api/v1.0/tasks/{task_id}/")
assert task_response.status_code == 200
data = task_response.json()
assert data["status"] == "SUCCESS"
assert data["result"]["total_events"] == 3
assert data["result"]["imported_count"] == 3
# Verify events actually exist in SabreDAV
caldav = CalDAVClient()
@@ -955,7 +932,7 @@ class TestImportEventsE2E:
"""Fetch the raw ICS data of a single event from SabreDAV by UID."""
caldav_client = CalDAVClient()
client = caldav_client._get_client(user) # pylint: disable=protected-access
cal_url = f"{caldav_client.base_url}{caldav_path}"
cal_url = caldav_client._calendar_url(caldav_path) # pylint: disable=protected-access
cal = client.calendar(url=cal_url)
event = cal.event_by_uid(uid)
return event.data
@@ -1022,10 +999,7 @@ class TestImportEventsE2E:
assert "..." in raw
@pytest.mark.skipif(
not settings.CALDAV_URL,
reason="CalDAV server URL not configured",
)
@pytest.mark.xdist_group("caldav")
class TestCalendarSanitizerE2E:
"""E2E tests for CalendarSanitizerPlugin on normal CalDAV PUT operations."""
@@ -1038,7 +1012,7 @@ class TestCalendarSanitizerE2E:
"""Fetch the raw ICS data of a single event from SabreDAV by UID."""
caldav_client = CalDAVClient()
client = caldav_client._get_client(user) # pylint: disable=protected-access
cal_url = f"{caldav_client.base_url}{caldav_path}"
cal_url = caldav_client._calendar_url(caldav_path) # pylint: disable=protected-access
cal = client.calendar(url=cal_url)
event = cal.event_by_uid(uid)
return event.data

View File

@@ -0,0 +1,196 @@
"""Tests for the organizations feature."""
from django.test import override_settings
import pytest
from rest_framework.status import HTTP_200_OK
from rest_framework.test import APIClient
from core import factories
from core.authentication.backends import resolve_organization
from core.entitlements.factory import get_entitlements_backend
from core.models import Organization
# -- Organization model --
@pytest.mark.django_db
def test_organization_str_with_name():
"""Organization.__str__ returns the name when set."""
org = factories.OrganizationFactory(name="Acme Corp", external_id="acme")
assert str(org) == "Acme Corp"
@pytest.mark.django_db
def test_organization_str_without_name():
"""Organization.__str__ falls back to external_id when name is empty."""
org = factories.OrganizationFactory(name="", external_id="acme")
assert str(org) == "acme"
@pytest.mark.django_db
def test_organization_unique_external_id():
"""external_id must be unique."""
factories.OrganizationFactory(external_id="org-1")
with pytest.raises(Exception): # noqa: B017
factories.OrganizationFactory(external_id="org-1")
# -- Org resolution on login --
@pytest.mark.django_db
def test_resolve_org_from_email_domain():
"""Without OIDC_USERINFO_ORGANIZATION_CLAIM, org is derived from email domain."""
user = factories.UserFactory(email="alice@ministry.gouv.fr")
resolve_organization(user, claims={}, entitlements={})
user.refresh_from_db()
assert user.organization is not None
assert user.organization.external_id == "ministry.gouv.fr"
assert user.organization.name == "ministry.gouv.fr"
@pytest.mark.django_db
@override_settings(OIDC_USERINFO_ORGANIZATION_CLAIM="siret")
def test_resolve_org_from_oidc_claim():
"""With OIDC_USERINFO_ORGANIZATION_CLAIM, org is derived from the claim."""
user = factories.UserFactory(email="alice@ministry.gouv.fr")
resolve_organization(
user,
claims={"siret": "13002526500013"},
entitlements={"organization_name": "Ministere X"},
)
user.refresh_from_db()
assert user.organization is not None
assert user.organization.external_id == "13002526500013"
assert user.organization.name == "Ministere X"
@pytest.mark.django_db
def test_resolve_org_updates_name():
"""Org name is updated from entitlements on subsequent logins."""
org = factories.OrganizationFactory(external_id="example.com", name="Old Name")
user = factories.UserFactory(email="alice@example.com", organization=org)
resolve_organization(
user,
claims={},
entitlements={"organization_name": "New Name"},
)
org.refresh_from_db()
assert org.name == "New Name"
@pytest.mark.django_db
def test_resolve_org_reuses_existing():
"""Existing org is reused, not duplicated."""
org = factories.OrganizationFactory(external_id="example.com")
user = factories.UserFactory(email="bob@example.com")
resolve_organization(user, claims={}, entitlements={})
user.refresh_from_db()
assert user.organization_id == org.id
assert Organization.objects.filter(external_id="example.com").count() == 1
# -- User API: /users/me/ --
@pytest.mark.django_db
@override_settings(
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
)
def test_users_me_includes_organization():
"""GET /users/me/ includes the user's organization."""
get_entitlements_backend.cache_clear()
org = factories.OrganizationFactory(name="Test Org", external_id="test")
user = factories.UserFactory(organization=org)
client = APIClient()
client.force_authenticate(user=user)
response = client.get("/api/v1.0/users/me/")
assert response.status_code == HTTP_200_OK
data = response.json()
assert data["organization"]["id"] == str(org.id)
assert data["organization"]["name"] == "Test Org"
get_entitlements_backend.cache_clear()
@pytest.mark.django_db
@override_settings(
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
)
def test_users_me_includes_can_admin():
"""GET /users/me/ includes can_admin from entitlements."""
get_entitlements_backend.cache_clear()
user = factories.UserFactory()
client = APIClient()
client.force_authenticate(user=user)
response = client.get("/api/v1.0/users/me/")
assert response.status_code == HTTP_200_OK
data = response.json()
assert "can_admin" in data
# Local backend returns True for can_admin
assert data["can_admin"] is True
get_entitlements_backend.cache_clear()
# -- User list scoping --
@pytest.mark.django_db
@override_settings(
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
)
def test_user_list_scoped_by_org():
"""User list only returns users from the same org."""
get_entitlements_backend.cache_clear()
org_a = factories.OrganizationFactory(external_id="org-a")
org_b = factories.OrganizationFactory(external_id="org-b")
alice = factories.UserFactory(email="alice@example.com", organization=org_a)
factories.UserFactory(email="bob@other.com", organization=org_b)
client = APIClient()
client.force_authenticate(user=alice)
response = client.get("/api/v1.0/users/?q=bob@other.com")
assert response.status_code == HTTP_200_OK
# Bob should NOT be visible (different org)
assert len(response.json()["results"]) == 0
get_entitlements_backend.cache_clear()
@pytest.mark.django_db
@override_settings(
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
)
def test_user_list_same_org_visible():
"""User list returns users from the same org."""
get_entitlements_backend.cache_clear()
org = factories.OrganizationFactory(external_id="org-a")
alice = factories.UserFactory(email="alice@example.com", organization=org)
factories.UserFactory(email="carol@example.com", organization=org)
client = APIClient()
client.force_authenticate(user=alice)
response = client.get("/api/v1.0/users/?q=carol@example.com")
assert response.status_code == HTTP_200_OK
data = response.json()["results"]
assert len(data) == 1
assert data[0]["email"] == "carol@example.com"
get_entitlements_backend.cache_clear()

View File

@@ -0,0 +1,382 @@
"""Tests for permissions, access control, and security edge cases."""
from unittest import mock
from django.conf import settings
from django.test import override_settings
import pytest
import responses
from rest_framework.status import (
HTTP_200_OK,
HTTP_201_CREATED,
HTTP_207_MULTI_STATUS,
HTTP_403_FORBIDDEN,
)
from rest_framework.test import APIClient
from core import factories
from core.entitlements import EntitlementsUnavailableError
from core.entitlements.factory import get_entitlements_backend
from core.services.caldav_service import (
CALDAV_PATH_PATTERN,
normalize_caldav_path,
verify_caldav_access,
)
# ---------------------------------------------------------------------------
# verify_caldav_access — resource paths
# ---------------------------------------------------------------------------
class TestVerifyCaldavAccessResourcePaths:
"""Tests for verify_caldav_access() with resource calendar paths."""
def _make_user(self, email="alice@example.com", org_id=None):
"""Create a mock user object."""
user = mock.Mock()
user.email = email
user.organization_id = org_id
return user
def test_resource_path_allowed_when_user_has_org(self):
"""Users with an organization can access resource calendars.
Fine-grained org-to-resource authorization is enforced by SabreDAV
via the X-CalDAV-Organization header, not here.
"""
user = self._make_user(org_id="some-org-uuid")
path = "/calendars/resources/abc-123/default/"
assert verify_caldav_access(user, path) is True
def test_user_path_allowed_for_own_email(self):
"""Users can access their own calendar paths."""
user = self._make_user(email="alice@example.com")
path = "/calendars/users/alice@example.com/cal-uuid/"
assert verify_caldav_access(user, path) is True
def test_user_path_denied_for_other_email(self):
"""Users cannot access another user's calendar path."""
user = self._make_user(email="alice@example.com")
path = "/calendars/users/bob@example.com/cal-uuid/"
assert verify_caldav_access(user, path) is False
def test_invalid_path_denied(self):
"""Paths that don't match the expected pattern are rejected."""
user = self._make_user(org_id="some-org")
assert verify_caldav_access(user, "/etc/passwd") is False
assert verify_caldav_access(user, "/calendars/") is False
assert verify_caldav_access(user, "/calendars/unknown/x/y/") is False
def test_path_traversal_denied(self):
"""Path traversal attempts are rejected."""
user = self._make_user(email="alice@example.com")
path = "/calendars/users/alice@example.com/../../../etc/passwd/"
assert verify_caldav_access(user, path) is False
def test_resource_path_pattern_matches(self):
"""The CALDAV_PATH_PATTERN regex matches resource paths."""
assert CALDAV_PATH_PATTERN.match("/calendars/resources/abc-123/default/")
assert CALDAV_PATH_PATTERN.match(
"/calendars/resources/a1b2c3d4-e5f6-7890-abcd-ef1234567890/default/"
)
def test_resource_path_denied_when_user_has_no_org(self):
"""Users without an organization cannot access resource calendars."""
user = self._make_user(org_id=None)
path = "/calendars/resources/abc-123/default/"
assert verify_caldav_access(user, path) is False
def test_user_path_case_insensitive(self):
"""Email comparison in user paths should be case-insensitive."""
user = self._make_user(email="Alice@Example.COM")
path = "/calendars/users/alice@example.com/cal-uuid/"
assert verify_caldav_access(user, path) is True
# ---------------------------------------------------------------------------
# IsOrgAdmin permission
# ---------------------------------------------------------------------------
@pytest.mark.django_db
class TestIsOrgAdminPermission:
"""Tests for the IsOrgAdmin permission class on the ResourceViewSet."""
@override_settings(
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
CALDAV_INTERNAL_API_KEY="test-internal-key",
)
def test_admin_user_can_create_resource(self):
"""Users with can_admin=True can create resources."""
get_entitlements_backend.cache_clear()
org = factories.OrganizationFactory(external_id="test-org")
admin = factories.UserFactory(organization=org)
client = APIClient()
client.force_authenticate(user=admin)
with mock.patch(
"core.services.caldav_service.requests.request"
) as mock_request:
mock_response = mock.Mock()
mock_response.status_code = 201
mock_response.text = '{"principal_uri": "principals/resources/x"}'
mock_request.return_value = mock_response
response = client.post(
"/api/v1.0/resources/",
{"name": "Room 1"},
format="json",
)
assert response.status_code == HTTP_201_CREATED
get_entitlements_backend.cache_clear()
def test_non_admin_user_denied_resource_creation(self):
"""Users with can_admin=False are denied by IsOrgAdmin."""
org = factories.OrganizationFactory(external_id="test-org")
user = factories.UserFactory(organization=org)
client = APIClient()
client.force_authenticate(user=user)
# Patch where IsOrgAdmin actually looks up get_user_entitlements
with mock.patch(
"core.api.permissions.get_user_entitlements",
return_value={"can_access": True, "can_admin": False},
):
response = client.post(
"/api/v1.0/resources/",
{"name": "Room 1"},
format="json",
)
assert response.status_code == HTTP_403_FORBIDDEN
def test_entitlements_unavailable_denies_access(self):
"""IsOrgAdmin is fail-closed: denies when entitlements service is down."""
org = factories.OrganizationFactory(external_id="test-org")
user = factories.UserFactory(organization=org)
client = APIClient()
client.force_authenticate(user=user)
with mock.patch(
"core.api.permissions.get_user_entitlements",
side_effect=EntitlementsUnavailableError("Service unavailable"),
):
response = client.post(
"/api/v1.0/resources/",
{"name": "Room 1"},
format="json",
)
assert response.status_code == HTTP_403_FORBIDDEN
def test_unauthenticated_user_denied(self):
"""Unauthenticated users are denied by IsOrgAdmin (inherits IsAuthenticated)."""
client = APIClient()
response = client.post(
"/api/v1.0/resources/",
{"name": "Room 1"},
format="json",
)
# 401 or 403 depending on DRF config
assert response.status_code in (401, 403)
def test_non_admin_user_denied_resource_deletion(self):
"""Users with can_admin=False cannot delete resources either."""
org = factories.OrganizationFactory(external_id="test-org")
user = factories.UserFactory(organization=org)
client = APIClient()
client.force_authenticate(user=user)
with mock.patch(
"core.api.permissions.get_user_entitlements",
return_value={"can_access": True, "can_admin": False},
):
response = client.delete("/api/v1.0/resources/some-uuid/")
assert response.status_code == HTTP_403_FORBIDDEN
# ---------------------------------------------------------------------------
# CalDAV proxy — X-CalDAV-Organization header forwarding
# ---------------------------------------------------------------------------
@pytest.mark.django_db
@pytest.mark.xdist_group("caldav")
class TestCalDAVProxyOrgHeader:
"""Tests that the CalDAV proxy forwards the org header correctly."""
@responses.activate
def test_proxy_sends_org_header(self):
"""CalDAV proxy sends X-CalDAV-Organization for users with an org."""
org = factories.OrganizationFactory(external_id="org-alpha")
user = factories.UserFactory(email="alice@example.com", organization=org)
client = APIClient()
client.force_login(user)
caldav_url = settings.CALDAV_URL
responses.add(
responses.Response(
method="PROPFIND",
url=f"{caldav_url}/caldav/principals/resources/",
status=HTTP_207_MULTI_STATUS,
body='<?xml version="1.0"?><multistatus xmlns="DAV:"></multistatus>',
headers={"Content-Type": "application/xml"},
)
)
client.generic("PROPFIND", "/caldav/principals/resources/")
assert len(responses.calls) == 1
request = responses.calls[0].request
assert request.headers["X-CalDAV-Organization"] == str(org.id)
@responses.activate
def test_proxy_cannot_spoof_org_header(self):
"""Client-sent X-CalDAV-Organization is overwritten by the proxy."""
org = factories.OrganizationFactory(external_id="real-org")
user = factories.UserFactory(email="alice@example.com", organization=org)
client = APIClient()
client.force_login(user)
caldav_url = settings.CALDAV_URL
responses.add(
responses.Response(
method="PROPFIND",
url=f"{caldav_url}/caldav/principals/resources/",
status=HTTP_207_MULTI_STATUS,
body='<?xml version="1.0"?><multistatus xmlns="DAV:"></multistatus>',
headers={"Content-Type": "application/xml"},
)
)
# Attempt to spoof the org header
client.generic(
"PROPFIND",
"/caldav/principals/resources/",
HTTP_X_CALDAV_ORGANIZATION="spoofed-org-id",
)
assert len(responses.calls) == 1
request = responses.calls[0].request
# The proxy should use the user's real org ID, not the spoofed one
assert request.headers["X-CalDAV-Organization"] == str(org.id)
assert request.headers["X-CalDAV-Organization"] != "spoofed-org-id"
# ---------------------------------------------------------------------------
# IsEntitledToAccess permission — fail-closed
# ---------------------------------------------------------------------------
@pytest.mark.django_db
class TestIsEntitledToAccessFailClosed:
"""Tests that IsEntitledToAccess permission is fail-closed."""
def test_import_denied_when_entitlements_unavailable(self):
"""ICS import should be denied when entitlements service is down."""
user = factories.UserFactory(email="alice@example.com")
client = APIClient()
client.force_authenticate(user=user)
with mock.patch(
"core.api.permissions.get_user_entitlements",
side_effect=EntitlementsUnavailableError("Service unavailable"),
):
response = client.post(
"/api/v1.0/calendars/import-events/",
format="multipart",
)
assert response.status_code == HTTP_403_FORBIDDEN
def test_import_denied_when_can_access_false(self):
"""ICS import should be denied when can_access=False."""
user = factories.UserFactory(email="alice@example.com")
client = APIClient()
client.force_authenticate(user=user)
with mock.patch(
"core.api.permissions.get_user_entitlements",
return_value={"can_access": False, "can_admin": False},
):
response = client.post(
"/api/v1.0/calendars/import-events/",
format="multipart",
)
assert response.status_code == HTTP_403_FORBIDDEN
# ---------------------------------------------------------------------------
# normalize_caldav_path
# ---------------------------------------------------------------------------
class TestNormalizeCaldavPath:
"""Tests for normalize_caldav_path helper."""
def test_strips_old_api_prefix(self):
"""Should strip any prefix before /calendars/."""
result = normalize_caldav_path(
"/api/v1.0/caldav/calendars/users/user@ex.com/uuid/"
)
assert result == "/calendars/users/user@ex.com/uuid/"
def test_strips_new_prefix(self):
"""Should strip /caldav prefix."""
result = normalize_caldav_path("/caldav/calendars/users/user@ex.com/uuid/")
assert result == "/calendars/users/user@ex.com/uuid/"
def test_adds_leading_slash(self):
"""Should add a leading slash if missing."""
result = normalize_caldav_path("calendars/users/user@ex.com/uuid/")
assert result == "/calendars/users/user@ex.com/uuid/"
def test_adds_trailing_slash(self):
"""Should add a trailing slash if missing."""
result = normalize_caldav_path("/calendars/users/user@ex.com/uuid")
assert result == "/calendars/users/user@ex.com/uuid/"
def test_resource_path_unchanged(self):
"""Resource paths should pass through unchanged."""
result = normalize_caldav_path("/calendars/resources/abc-123/default/")
assert result == "/calendars/resources/abc-123/default/"
# ---------------------------------------------------------------------------
# UserViewSet — user without org gets empty results
# ---------------------------------------------------------------------------
@pytest.mark.django_db
class TestUserViewSetCrossOrg:
"""Tests that users from a different org see no users in the list."""
def test_user_from_other_org_gets_empty_list(self):
"""A user from a different org should get an empty user list."""
org_a = factories.OrganizationFactory(external_id="org-a")
org_b = factories.OrganizationFactory(external_id="org-b")
factories.UserFactory(email="orguser@example.com", organization=org_a)
other_org_user = factories.UserFactory(
email="other@example.com", organization=org_b
)
client = APIClient()
client.force_login(other_org_user)
response = client.get("/api/v1.0/users/", {"q": "orguser@example.com"})
assert response.status_code == HTTP_200_OK
assert response.json()["count"] == 0

View File

@@ -0,0 +1,309 @@
"""Tests for the resource provisioning API."""
import json
from unittest import mock
from django.test import override_settings
import pytest
from rest_framework.status import (
HTTP_201_CREATED,
HTTP_204_NO_CONTENT,
HTTP_400_BAD_REQUEST,
HTTP_401_UNAUTHORIZED,
)
from rest_framework.test import APIClient
from core import factories
from core.entitlements.factory import get_entitlements_backend
from core.services.resource_service import ResourceProvisioningError, ResourceService
# -- Permission checks --
@pytest.mark.django_db
@override_settings(
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
)
def test_create_resource_requires_auth():
"""POST /resources/ requires authentication."""
get_entitlements_backend.cache_clear()
client = APIClient()
response = client.post(
"/api/v1.0/resources/",
{"name": "Room 1"},
format="json",
)
assert response.status_code == HTTP_401_UNAUTHORIZED
get_entitlements_backend.cache_clear()
@pytest.mark.django_db
@override_settings(
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
CALDAV_INTERNAL_API_KEY="test-internal-key",
)
def test_create_resource_success():
"""POST /resources/ creates a resource principal via the internal API."""
get_entitlements_backend.cache_clear()
org = factories.OrganizationFactory(external_id="test-org")
admin = factories.UserFactory(organization=org)
client = APIClient()
client.force_authenticate(user=admin)
with mock.patch("core.services.caldav_service.requests.request") as mock_request:
# Mock the internal API response for resource creation
mock_response = mock.Mock()
mock_response.status_code = 201
mock_response.json.return_value = {
"principal_uri": "principals/resources/some-uuid",
"email": "c_test@resource.calendar.localhost",
}
mock_response.text = '{"principal_uri": "principals/resources/some-uuid"}'
mock_request.return_value = mock_response
response = client.post(
"/api/v1.0/resources/",
{"name": "Room 101", "resource_type": "ROOM"},
format="json",
)
assert response.status_code == HTTP_201_CREATED
data = response.json()
assert data["name"] == "Room 101"
assert data["resource_type"] == "ROOM"
assert "email" in data
assert "id" in data
# Principal URI uses the opaque UUID, not the slug
assert data["principal_uri"].startswith("principals/resources/")
assert data["principal_uri"] == f"principals/resources/{data['id']}"
# Verify the HTTP call went to the internal API
mock_request.assert_called_once()
call_kwargs = mock_request.call_args
url = call_kwargs.kwargs.get("url", "") or (
call_kwargs.args[1] if len(call_kwargs.args) > 1 else ""
)
headers = call_kwargs.kwargs.get("headers", {})
assert "internal-api/resources" in url
assert "X-Internal-Api-Key" in headers
get_entitlements_backend.cache_clear()
@pytest.mark.django_db
@override_settings(
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
CALDAV_INTERNAL_API_KEY="test-internal-key",
)
def test_delete_resource():
"""DELETE /resources/{resource_id}/ deletes the resource via internal API."""
get_entitlements_backend.cache_clear()
org = factories.OrganizationFactory(external_id="test-org")
admin = factories.UserFactory(organization=org)
client = APIClient()
client.force_authenticate(user=admin)
resource_id = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
with mock.patch("core.services.caldav_service.requests.request") as mock_request:
mock_response = mock.Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"deleted": True}
mock_response.text = '{"deleted": true}'
mock_request.return_value = mock_response
response = client.delete(f"/api/v1.0/resources/{resource_id}/")
assert response.status_code == HTTP_204_NO_CONTENT
# Verify the HTTP call went to the internal API
mock_request.assert_called_once()
call_kwargs = mock_request.call_args
url = call_kwargs.kwargs.get("url", "") or (
call_kwargs.args[1] if len(call_kwargs.args) > 1 else ""
)
headers = call_kwargs.kwargs.get("headers", {})
assert f"internal-api/resources/{resource_id}" in url
assert "X-Internal-Api-Key" in headers
get_entitlements_backend.cache_clear()
@pytest.mark.django_db
@override_settings(
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
CALDAV_INTERNAL_API_KEY="test-internal-key",
)
def test_delete_resource_cross_org_blocked():
"""Cannot delete a resource from another organization."""
get_entitlements_backend.cache_clear()
org_a = factories.OrganizationFactory(external_id="org-a")
admin = factories.UserFactory(organization=org_a)
client = APIClient()
client.force_authenticate(user=admin)
with mock.patch("core.services.caldav_service.requests.request") as mock_request:
mock_response = mock.Mock()
mock_response.status_code = 403
mock_response.json.return_value = {
"error": "Cannot delete a resource from a different organization."
}
mock_response.text = (
'{"error": "Cannot delete a resource from a different organization."}'
)
mock_request.return_value = mock_response
response = client.delete(
"/api/v1.0/resources/b1b2c3d4-e5f6-7890-abcd-ef1234567890/"
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert "different organization" in response.json()["detail"]
get_entitlements_backend.cache_clear()
# -- Lateral access tests --
@pytest.mark.django_db
@override_settings(
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
CALDAV_INTERNAL_API_KEY="test-internal-key",
)
def test_create_resource_sends_user_org_id():
"""Create resource always sends the authenticated user's org_id, not a caller-supplied one."""
get_entitlements_backend.cache_clear()
org = factories.OrganizationFactory(external_id="org-alpha")
admin = factories.UserFactory(organization=org)
client = APIClient()
client.force_authenticate(user=admin)
with mock.patch("core.services.caldav_service.requests.request") as mock_request:
mock_response = mock.Mock()
mock_response.status_code = 201
mock_response.text = '{"principal_uri": "principals/resources/x"}'
mock_request.return_value = mock_response
response = client.post(
"/api/v1.0/resources/",
{"name": "Room 1"},
format="json",
)
assert response.status_code == HTTP_201_CREATED
# Verify the JSON body sent to internal API contains the user's org
call_kwargs = mock_request.call_args
body = json.loads(call_kwargs.kwargs.get("data", b"{}"))
assert body["org_id"] == str(org.id)
get_entitlements_backend.cache_clear()
@pytest.mark.django_db
@override_settings(
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
CALDAV_INTERNAL_API_KEY="test-internal-key",
)
def test_delete_resource_sends_user_org_id():
"""Delete resource sends the authenticated user's org_id in the header."""
get_entitlements_backend.cache_clear()
org = factories.OrganizationFactory(external_id="org-beta")
admin = factories.UserFactory(organization=org)
client = APIClient()
client.force_authenticate(user=admin)
resource_id = "a1b2c3d4-0000-0000-0000-000000000001"
with mock.patch("core.services.caldav_service.requests.request") as mock_request:
mock_response = mock.Mock()
mock_response.status_code = 200
mock_response.text = '{"deleted": true}'
mock_request.return_value = mock_response
response = client.delete(f"/api/v1.0/resources/{resource_id}/")
assert response.status_code == HTTP_204_NO_CONTENT
call_kwargs = mock_request.call_args
headers = call_kwargs.kwargs.get("headers", {})
assert headers.get("X-CalDAV-Organization") == str(org.id)
get_entitlements_backend.cache_clear()
# -- Path traversal tests --
class TestResourceIdValidation:
"""Tests that resource_id is validated as a UUID to prevent path traversal."""
@pytest.fixture(autouse=True)
def _internal_api_key(self, settings):
settings.CALDAV_INTERNAL_API_KEY = "test-internal-key"
def test_delete_rejects_path_traversal(self):
"""A malicious resource_id like ../../users/victim is rejected."""
user = mock.Mock()
user.organization_id = "some-org"
service = ResourceService()
with pytest.raises(ResourceProvisioningError, match="Invalid resource ID"):
service.delete_resource(user, "../../users/victim@example.com")
def test_delete_rejects_non_uuid_string(self):
"""A non-UUID resource_id is rejected."""
user = mock.Mock()
user.organization_id = "some-org"
service = ResourceService()
with pytest.raises(ResourceProvisioningError, match="Invalid resource ID"):
service.delete_resource(user, "not-a-uuid")
def test_delete_accepts_valid_uuid(self):
"""A valid UUID resource_id passes validation."""
user = mock.Mock()
user.email = "admin@example.com"
user.organization_id = "some-org"
service = ResourceService()
with mock.patch(
"core.services.caldav_service.requests.request"
) as mock_request:
mock_response = mock.Mock()
mock_response.status_code = 200
mock_request.return_value = mock_response
# Should not raise
service.delete_resource(user, "a1b2c3d4-e5f6-7890-abcd-ef1234567890")
def test_create_resource_rejects_missing_api_key(self, settings):
"""create_resource raises when CALDAV_INTERNAL_API_KEY is empty."""
settings.CALDAV_INTERNAL_API_KEY = ""
user = mock.Mock()
user.organization_id = "some-org"
service = ResourceService()
with pytest.raises(ResourceProvisioningError, match="CALDAV_INTERNAL_API_KEY"):
service.create_resource(user, "Room 1", "ROOM")
def test_delete_resource_rejects_missing_api_key(self, settings):
"""delete_resource raises when CALDAV_INTERNAL_API_KEY is empty."""
settings.CALDAV_INTERNAL_API_KEY = ""
user = mock.Mock()
user.organization_id = "some-org"
service = ResourceService()
with pytest.raises(ResourceProvisioningError, match="CALDAV_INTERNAL_API_KEY"):
service.delete_resource(user, "a1b2c3d4-e5f6-7890-abcd-ef1234567890")

View File

@@ -8,7 +8,7 @@ from unittest.mock import patch
from urllib.parse import parse_qs, urlparse
from django.core import mail
from django.core.signing import BadSignature, Signer
from django.core.signing import BadSignature, SignatureExpired, TimestampSigner
from django.template.loader import render_to_string
from django.test import RequestFactory, TestCase, override_settings
from django.utils import timezone
@@ -16,7 +16,8 @@ from django.utils import timezone
import icalendar
import pytest
from core.api.viewsets_rsvp import RSVPView
from core import factories
from core.api.viewsets_rsvp import RSVPConfirmView, RSVPProcessView
from core.services.caldav_service import CalDAVHTTPClient
from core.services.calendar_invitation_service import (
CalendarInvitationService,
@@ -56,10 +57,10 @@ SAMPLE_CALDAV_RESPONSE = """\
<?xml version="1.0" encoding="UTF-8"?>
<d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav">
<d:response>
<d:href>/api/v1.0/caldav/calendars/alice%40example.com/cal-uuid/test-uid-123.ics</d:href>
<d:href>/caldav/calendars/users/alice%40example.com/cal-uuid/test-uid-123.ics</d:href>
<d:propstat>
<d:prop>
<d:gethref>/api/v1.0/caldav/calendars/alice%40example.com/cal-uuid/test-uid-123.ics</d:gethref>
<d:gethref>/caldav/calendars/users/alice%40example.com/cal-uuid/test-uid-123.ics</d:gethref>
<cal:calendar-data>{ics_data}</cal:calendar-data>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
@@ -71,8 +72,8 @@ SAMPLE_CALDAV_RESPONSE = """\
def _make_token(
uid="test-uid-123", email="bob@example.com", organizer="alice@example.com"
):
"""Create a valid signed RSVP token."""
signer = Signer(salt="rsvp")
"""Create a valid signed RSVP token using TimestampSigner."""
signer = TimestampSigner(salt="rsvp")
return signer.sign_object(
{
"uid": uid,
@@ -88,7 +89,7 @@ class TestRSVPTokenGeneration:
def test_token_roundtrip(self):
"""A generated token can be unsigned to recover the payload."""
token = _make_token()
signer = Signer(salt="rsvp")
signer = TimestampSigner(salt="rsvp")
payload = signer.unsign_object(token)
assert payload["uid"] == "test-uid-123"
assert payload["email"] == "bob@example.com"
@@ -97,7 +98,7 @@ class TestRSVPTokenGeneration:
def test_tampered_token_fails(self):
"""A tampered token raises BadSignature."""
token = _make_token() + "tampered"
signer = Signer(salt="rsvp")
signer = TimestampSigner(salt="rsvp")
with pytest.raises(BadSignature):
signer.unsign_object(token)
@@ -194,7 +195,7 @@ class TestRSVPEmailTemplateRendering:
class TestUpdateAttendeePartstat:
"""Tests for the _update_attendee_partstat function."""
"""Tests for the update_attendee_partstat function."""
def test_update_existing_partstat(self):
result = CalDAVHTTPClient.update_attendee_partstat(
@@ -232,18 +233,50 @@ class TestUpdateAttendeePartstat:
assert "CN=Bob" in result
assert "mailto:bob@example.com" in result
def test_substring_email_does_not_match(self):
"""Emails that are substrings of the target should NOT match."""
# Create ICS with a similar-but-different email
ics = (
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"PRODID:-//Test//EN\r\n"
"BEGIN:VEVENT\r\n"
"UID:test-substring\r\n"
"DTSTART:20260401T100000Z\r\n"
"DTEND:20260401T110000Z\r\n"
"SUMMARY:Test\r\n"
"ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:notbob@example.com\r\n"
"END:VEVENT\r\n"
"END:VCALENDAR"
)
# "bob@example.com" should NOT match "notbob@example.com"
result = CalDAVHTTPClient.update_attendee_partstat(
ics, "bob@example.com", "ACCEPTED"
)
assert result is None
@override_settings(
CALDAV_URL="http://caldav:80",
CALDAV_OUTBOUND_API_KEY="test-api-key",
APP_URL="http://localhost:8921",
APP_URL="http://localhost:8931",
API_VERSION="v1.0",
)
class TestRSVPView(TestCase):
"""Tests for the RSVPView."""
class TestRSVPConfirmView(TestCase):
"""Tests for the RSVPConfirmView (GET handler)."""
def setUp(self):
self.factory = RequestFactory()
self.view = RSVPView.as_view()
self.view = RSVPConfirmView.as_view()
def test_valid_token_renders_confirm_page(self):
token = _make_token()
request = self.factory.get("/rsvp/", {"token": token, "action": "accepted"})
response = self.view(request)
assert response.status_code == 200
content = response.content.decode()
assert 'method="post"' in content
assert token in content
def test_invalid_action_returns_400(self):
token = _make_token()
@@ -251,12 +284,6 @@ class TestRSVPView(TestCase):
response = self.view(request)
assert response.status_code == 400
def test_missing_action_returns_400(self):
token = _make_token()
request = self.factory.get("/rsvp/", {"token": token})
response = self.view(request)
assert response.status_code == 400
def test_invalid_token_returns_400(self):
request = self.factory.get(
"/rsvp/", {"token": "bad-token", "action": "accepted"}
@@ -264,8 +291,46 @@ class TestRSVPView(TestCase):
response = self.view(request)
assert response.status_code == 400
@override_settings(
CALDAV_URL="http://caldav:80",
CALDAV_OUTBOUND_API_KEY="test-api-key",
APP_URL="http://localhost:8931",
API_VERSION="v1.0",
)
class TestRSVPProcessView(TestCase):
"""Tests for the RSVPProcessView (POST handler)."""
def setUp(self):
self.factory = RequestFactory()
self.view = RSVPProcessView.as_view()
# RSVP view looks up organizer from DB
self.organizer = factories.UserFactory(email="alice@example.com")
def _post(self, token, action):
request = self.factory.post(
"/api/v1.0/rsvp/",
{"token": token, "action": action},
)
return self.view(request)
def test_invalid_action_returns_400(self):
token = _make_token()
response = self._post(token, "invalid")
assert response.status_code == 400
def test_missing_action_returns_400(self):
token = _make_token()
request = self.factory.post("/api/v1.0/rsvp/", {"token": token})
response = self.view(request)
assert response.status_code == 400
def test_invalid_token_returns_400(self):
response = self._post("bad-token", "accepted")
assert response.status_code == 400
def test_missing_token_returns_400(self):
request = self.factory.get("/rsvp/", {"action": "accepted"})
request = self.factory.post("/api/v1.0/rsvp/", {"action": "accepted"})
response = self.view(request)
assert response.status_code == 400
@@ -275,33 +340,37 @@ class TestRSVPView(TestCase):
"""Full accept flow: find event, update partstat, put back."""
mock_find.return_value = (
SAMPLE_ICS,
"/api/v1.0/caldav/calendars/alice%40example.com/cal/event.ics",
"/caldav/calendars/users/alice%40example.com/cal/event.ics",
'"etag-123"',
)
mock_put.return_value = True
token = _make_token()
request = self.factory.get("/rsvp/", {"token": token, "action": "accepted"})
response = self.view(request)
response = self._post(token, "accepted")
assert response.status_code == 200
assert "accepted the invitation" in response.content.decode()
# Verify CalDAV calls
mock_find.assert_called_once_with("alice@example.com", "test-uid-123")
mock_find.assert_called_once()
find_args = mock_find.call_args[0]
assert find_args[0].email == "alice@example.com"
assert find_args[1] == "test-uid-123"
mock_put.assert_called_once()
# Check the updated data contains ACCEPTED
put_args = mock_put.call_args
assert "PARTSTAT=ACCEPTED" in put_args[0][2]
# Check ETag is passed
assert put_args[1]["etag"] == '"etag-123"'
@patch.object(CalDAVHTTPClient, "put_event")
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
def test_decline_flow(self, mock_find, mock_put):
mock_find.return_value = (SAMPLE_ICS, "/path/to/event.ics")
mock_find.return_value = (SAMPLE_ICS, "/path/to/event.ics", None)
mock_put.return_value = True
token = _make_token()
request = self.factory.get("/rsvp/", {"token": token, "action": "declined"})
response = self.view(request)
response = self._post(token, "declined")
assert response.status_code == 200
assert "declined the invitation" in response.content.decode()
@@ -311,12 +380,11 @@ class TestRSVPView(TestCase):
@patch.object(CalDAVHTTPClient, "put_event")
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
def test_tentative_flow(self, mock_find, mock_put):
mock_find.return_value = (SAMPLE_ICS, "/path/to/event.ics")
mock_find.return_value = (SAMPLE_ICS, "/path/to/event.ics", None)
mock_put.return_value = True
token = _make_token()
request = self.factory.get("/rsvp/", {"token": token, "action": "tentative"})
response = self.view(request)
response = self._post(token, "tentative")
assert response.status_code == 200
content = response.content.decode()
@@ -326,11 +394,10 @@ class TestRSVPView(TestCase):
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
def test_event_not_found_returns_400(self, mock_find):
mock_find.return_value = (None, None)
mock_find.return_value = (None, None, None)
token = _make_token()
request = self.factory.get("/rsvp/", {"token": token, "action": "accepted"})
response = self.view(request)
response = self._post(token, "accepted")
assert response.status_code == 400
assert "not found" in response.content.decode().lower()
@@ -338,12 +405,11 @@ class TestRSVPView(TestCase):
@patch.object(CalDAVHTTPClient, "put_event")
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
def test_put_failure_returns_400(self, mock_find, mock_put):
mock_find.return_value = (SAMPLE_ICS, "/path/to/event.ics")
mock_find.return_value = (SAMPLE_ICS, "/path/to/event.ics", None)
mock_put.return_value = False
token = _make_token()
request = self.factory.get("/rsvp/", {"token": token, "action": "accepted"})
response = self.view(request)
response = self._post(token, "accepted")
assert response.status_code == 400
assert "error occurred" in response.content.decode().lower()
@@ -351,12 +417,11 @@ class TestRSVPView(TestCase):
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
def test_attendee_not_in_event_returns_400(self, mock_find):
"""If the attendee email is not in the event, return error."""
mock_find.return_value = (SAMPLE_ICS, "/path/to/event.ics")
mock_find.return_value = (SAMPLE_ICS, "/path/to/event.ics", None)
# Token with an email that's not in the event
token = _make_token(email="stranger@example.com")
request = self.factory.get("/rsvp/", {"token": token, "action": "accepted"})
response = self.view(request)
response = self._post(token, "accepted")
assert response.status_code == 400
assert "not listed" in response.content.decode().lower()
@@ -364,11 +429,10 @@ class TestRSVPView(TestCase):
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
def test_past_event_returns_400(self, mock_find):
"""Cannot RSVP to an event that has already ended."""
mock_find.return_value = (SAMPLE_ICS_PAST, "/path/to/event.ics")
mock_find.return_value = (SAMPLE_ICS_PAST, "/path/to/event.ics", None)
token = _make_token(uid="test-uid-past")
request = self.factory.get("/rsvp/", {"token": token, "action": "accepted"})
response = self.view(request)
response = self._post(token, "accepted")
assert response.status_code == 400
assert "already passed" in response.content.decode().lower()
@@ -430,28 +494,29 @@ class TestItipSetting:
CALDAV_URL="http://caldav:80",
CALDAV_OUTBOUND_API_KEY="test-api-key",
CALDAV_INBOUND_API_KEY="test-inbound-key",
APP_URL="http://localhost:8921",
APP_URL="http://localhost:8931",
API_VERSION="v1.0",
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
)
class TestRSVPEndToEndFlow(TestCase):
"""
Integration test: scheduling callback sends email extract RSVP links
follow link verify event is updated.
This tests the full flow from CalDAV scheduling callback to RSVP response,
using Django's in-memory email backend to intercept sent emails.
Integration test: scheduling callback sends email -> extract RSVP links
-> follow link (GET confirm -> POST process) -> verify event is updated.
"""
def setUp(self):
self.factory = RequestFactory()
self.rsvp_view = RSVPView.as_view()
self.confirm_view = RSVPConfirmView.as_view()
self.process_view = RSVPProcessView.as_view()
self.organizer = factories.UserFactory(email="alice@example.com")
def test_email_to_rsvp_accept_flow(self):
"""
1. CalDAV scheduling callback sends an invitation email
2. Extract RSVP accept link from the email HTML
3. Follow the RSVP link
4. Verify the event PARTSTAT is updated to ACCEPTED
3. GET the RSVP link (renders auto-submit form)
4. POST to process the RSVP
5. Verify the event PARTSTAT is updated to ACCEPTED
"""
# Step 1: Send invitation via the CalendarInvitationService
service = CalendarInvitationService()
@@ -488,22 +553,32 @@ class TestRSVPEndToEndFlow(TestCase):
assert "token" in params
assert params["action"] == ["accepted"]
# Step 4: Follow the RSVP link (mock CalDAV interactions)
# Step 3b: GET the confirm page
request = self.factory.get(
"/rsvp/",
{"token": params["token"][0], "action": "accepted"},
)
response = self.confirm_view(request)
assert response.status_code == 200
assert 'method="post"' in response.content.decode()
# Step 4: POST to process the RSVP (mock CalDAV interactions)
with (
patch.object(CalDAVHTTPClient, "find_event_by_uid") as mock_find,
patch.object(CalDAVHTTPClient, "put_event") as mock_put,
):
mock_find.return_value = (
SAMPLE_ICS,
"/api/v1.0/caldav/calendars/alice%40example.com/cal/event.ics",
"/caldav/calendars/users/alice%40example.com/cal/event.ics",
'"etag-abc"',
)
mock_put.return_value = True
request = self.factory.get(
"/rsvp/",
request = self.factory.post(
"/api/v1.0/rsvp/",
{"token": params["token"][0], "action": "accepted"},
)
response = self.rsvp_view(request)
response = self.process_view(request)
# Step 5: Verify success
assert response.status_code == 200
@@ -511,7 +586,10 @@ class TestRSVPEndToEndFlow(TestCase):
assert "accepted the invitation" in content
# Verify CalDAV was called with the right data
mock_find.assert_called_once_with("alice@example.com", "test-uid-123")
mock_find.assert_called_once()
find_args = mock_find.call_args[0]
assert find_args[0].email == "alice@example.com"
assert find_args[1] == "test-uid-123"
mock_put.assert_called_once()
put_data = mock_put.call_args[0][2]
assert "PARTSTAT=ACCEPTED" in put_data
@@ -542,14 +620,14 @@ class TestRSVPEndToEndFlow(TestCase):
patch.object(CalDAVHTTPClient, "find_event_by_uid") as mock_find,
patch.object(CalDAVHTTPClient, "put_event") as mock_put,
):
mock_find.return_value = (SAMPLE_ICS, "/path/to/event.ics")
mock_find.return_value = (SAMPLE_ICS, "/path/to/event.ics", None)
mock_put.return_value = True
request = self.factory.get(
"/rsvp/",
request = self.factory.post(
"/api/v1.0/rsvp/",
{"token": params["token"][0], "action": "declined"},
)
response = self.rsvp_view(request)
response = self.process_view(request)
assert response.status_code == 200
assert "declined the invitation" in response.content.decode()
@@ -612,13 +690,134 @@ class TestRSVPEndToEndFlow(TestCase):
params = parse_qs(parsed.query)
# The event is in the past
mock_find.return_value = (SAMPLE_ICS_PAST, "/path/to/event.ics")
mock_find.return_value = (SAMPLE_ICS_PAST, "/path/to/event.ics", None)
request = self.factory.get(
"/rsvp/",
request = self.factory.post(
"/api/v1.0/rsvp/",
{"token": params["token"][0], "action": "accepted"},
)
response = self.rsvp_view(request)
response = self.process_view(request)
assert response.status_code == 400
assert "already passed" in response.content.decode().lower()
def _make_recurring_ics(
uid="recurring-uid-1", summary="Weekly Standup", days_from_now=30
):
"""Build a recurring ICS string with an RRULE."""
dt = timezone.now() + timedelta(days=days_from_now)
dtstart = dt.strftime("%Y%m%dT%H%M%SZ")
dtend = (dt + timedelta(hours=1)).strftime("%Y%m%dT%H%M%SZ")
return (
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"PRODID:-//Test//EN\r\n"
"BEGIN:VEVENT\r\n"
f"UID:{uid}\r\n"
f"DTSTART:{dtstart}\r\n"
f"DTEND:{dtend}\r\n"
f"SUMMARY:{summary}\r\n"
"RRULE:FREQ=WEEKLY;COUNT=52\r\n"
"ORGANIZER;CN=Alice:mailto:alice@example.com\r\n"
"ATTENDEE;CN=Bob;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:bob@example.com\r\n"
"SEQUENCE:0\r\n"
"END:VEVENT\r\n"
"END:VCALENDAR"
)
@override_settings(
CALDAV_URL="http://caldav:80",
CALDAV_OUTBOUND_API_KEY="test-api-key",
APP_URL="http://localhost:8931",
API_VERSION="v1.0",
RSVP_TOKEN_MAX_AGE_RECURRING=7776000, # 90 days
)
class TestRSVPRecurringTokenExpiry(TestCase):
"""Tests for RSVP token max_age enforcement on recurring events.
Tokens are signed with TimestampSigner so that the max_age check
works correctly for recurring events.
"""
def setUp(self):
self.factory = RequestFactory()
self.view = RSVPProcessView.as_view()
self.organizer = factories.UserFactory(email="alice@example.com")
def _post(self, token, action):
request = self.factory.post(
"/api/v1.0/rsvp/",
{"token": token, "action": action},
)
return self.view(request)
@patch.object(CalDAVHTTPClient, "put_event")
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
def test_recurring_event_with_fresh_token_succeeds(self, mock_find, mock_put):
"""A fresh token for a recurring event should be accepted."""
ics = _make_recurring_ics()
mock_find.return_value = (ics, "/path/to/event.ics", None)
mock_put.return_value = True
token = _make_token(uid="recurring-uid-1")
response = self._post(token, "accepted")
assert response.status_code == 200
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
def test_recurring_event_with_expired_token_rejected(self, mock_find):
"""An expired token for a recurring event should be rejected."""
ics = _make_recurring_ics()
mock_find.return_value = (ics, "/path/to/event.ics", None)
token = _make_token(uid="recurring-uid-1")
# Simulate time passing beyond max_age by patching unsign_object
# to raise SignatureExpired on the second call (with max_age)
original_unsign = TimestampSigner.unsign_object
def side_effect(value, **kwargs):
if kwargs.get("max_age") is not None:
raise SignatureExpired("Signature age exceeds max_age")
return original_unsign(TimestampSigner(), value, **kwargs)
with patch.object(TimestampSigner, "unsign_object", side_effect=side_effect):
response = self._post(token, "accepted")
assert response.status_code == 400
content = response.content.decode().lower()
assert "expired" in content or "new invitation" in content
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
def test_recurring_event_token_expired_via_freeze_time(self, mock_find):
"""Token created now should be rejected after max_age seconds."""
from freezegun import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
freeze_time,
)
ics = _make_recurring_ics()
mock_find.return_value = (ics, "/path/to/event.ics", None)
token = _make_token(uid="recurring-uid-1")
# Advance time beyond RSVP_TOKEN_MAX_AGE_RECURRING (90 days = 7776000s)
with freeze_time(timezone.now() + timedelta(days=91)):
response = self._post(token, "accepted")
assert response.status_code == 400
content = response.content.decode().lower()
assert "expired" in content or "new invitation" in content
@patch.object(CalDAVHTTPClient, "put_event")
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
def test_non_recurring_event_ignores_token_age(self, mock_find, mock_put):
"""Non-recurring events should not enforce token max_age."""
mock_find.return_value = (SAMPLE_ICS, "/path/to/event.ics", None)
mock_put.return_value = True
token = _make_token()
response = self._post(token, "accepted")
assert response.status_code == 200

View File

@@ -0,0 +1,205 @@
"""Tests for Django signals (CalDAV cleanup on user/org deletion)."""
import json
from unittest import mock
from django.test import TestCase, override_settings
import pytest
from core import factories
from core.models import Organization, User
# Signal tests need real signal handlers (they mock at the requests.request
# level), so they must be in the caldav xdist group to skip the conftest
# fixture that disconnects signals for non-CalDAV tests.
pytestmark = pytest.mark.xdist_group("caldav")
@override_settings(
CALDAV_URL="http://caldav:80",
CALDAV_INTERNAL_API_KEY="test-internal-key",
CALDAV_OUTBOUND_API_KEY="test-api-key",
)
class TestDeleteUserCaldavData(TestCase):
"""Tests for the delete_user_caldav_data pre_delete signal."""
def test_deleting_user_calls_internal_api(self):
"""Deleting a user triggers a POST to the SabreDAV internal API."""
user = factories.UserFactory(email="alice@example.com")
with mock.patch(
"core.services.caldav_service.requests.request"
) as mock_request:
mock_response = mock.Mock()
mock_response.status_code = 200
mock_request.return_value = mock_response
with self.captureOnCommitCallbacks(execute=True):
user.delete()
# Verify the internal API was called to clean up CalDAV data
mock_request.assert_called_once()
call_kwargs = mock_request.call_args
assert call_kwargs.kwargs["method"] == "POST"
url = call_kwargs.kwargs.get("url", "")
assert "internal-api/users/delete" in url
body = json.loads(call_kwargs.kwargs.get("data", b"{}"))
assert body["email"] == "alice@example.com"
headers = call_kwargs.kwargs.get("headers", {})
assert headers.get("X-Internal-Api-Key") == "test-internal-key"
def test_deleting_user_without_email_skips_cleanup(self):
"""Users without an email don't trigger CalDAV cleanup."""
user = factories.UserFactory(email="")
with mock.patch(
"core.services.caldav_service.requests.request"
) as mock_request:
user.delete()
mock_request.assert_not_called()
@override_settings(CALDAV_INTERNAL_API_KEY="")
def test_deleting_user_without_api_key_skips_cleanup(self):
"""When CALDAV_INTERNAL_API_KEY is empty, cleanup is skipped."""
user = factories.UserFactory(email="alice@example.com")
with mock.patch(
"core.services.caldav_service.requests.request"
) as mock_request:
user.delete()
mock_request.assert_not_called()
def test_deleting_user_handles_http_error_gracefully(self):
"""HTTP errors during cleanup don't prevent user deletion."""
user = factories.UserFactory(email="alice@example.com")
with mock.patch(
"core.services.caldav_service.requests.request",
side_effect=Exception("Connection refused"),
):
# Should not raise — the signal catches exceptions
with self.captureOnCommitCallbacks(execute=True):
user.delete()
assert not User.objects.filter(email="alice@example.com").exists()
@override_settings(
CALDAV_URL="http://caldav:80",
CALDAV_INTERNAL_API_KEY="test-internal-key",
CALDAV_OUTBOUND_API_KEY="test-api-key",
)
class TestDeleteOrganizationCaldavData(TestCase):
"""Tests for the delete_organization_caldav_data pre_delete signal."""
def test_deleting_org_cleans_up_all_members(self):
"""Deleting an org triggers CalDAV cleanup for every member.
cleanup_organization_caldav_data calls DELETE for each member,
then members.delete() triggers the user pre_delete signal which
schedules on_commit callbacks. So we expect 2 calls from org
cleanup + 2 from user signals = 4 total.
"""
org = factories.OrganizationFactory(external_id="doomed-org")
factories.UserFactory(email="alice@example.com", organization=org)
factories.UserFactory(email="bob@example.com", organization=org)
with mock.patch(
"core.services.caldav_service.requests.request"
) as mock_request:
mock_response = mock.Mock()
mock_response.status_code = 200
mock_request.return_value = mock_response
with self.captureOnCommitCallbacks(execute=True):
org.delete()
# 2 members x 2 POST calls each (org cleanup + user signal on_commit)
assert mock_request.call_count == 4
bodies = [
json.loads(call.kwargs.get("data", b"{}"))
for call in mock_request.call_args_list
]
emails = [b.get("email", "") for b in bodies]
assert "alice@example.com" in emails
assert "bob@example.com" in emails
def test_deleting_org_deletes_member_users(self):
"""Deleting an org also deletes member Django User objects."""
org = factories.OrganizationFactory(external_id="doomed-org")
factories.UserFactory(email="alice@example.com", organization=org)
factories.UserFactory(email="bob@example.com", organization=org)
with mock.patch(
"core.services.caldav_service.requests.request"
) as mock_request:
mock_response = mock.Mock()
mock_response.status_code = 200
mock_request.return_value = mock_response
with self.captureOnCommitCallbacks(execute=True):
org.delete()
assert not User.objects.filter(email="alice@example.com").exists()
assert not User.objects.filter(email="bob@example.com").exists()
assert not Organization.objects.filter(external_id="doomed-org").exists()
def test_deleting_org_with_no_members(self):
"""Deleting an org with no members succeeds without errors."""
org = factories.OrganizationFactory(external_id="empty-org")
with mock.patch(
"core.services.caldav_service.requests.request"
) as mock_request:
org.delete()
mock_request.assert_not_called()
def test_deleting_org_continues_after_member_cleanup_failure(self):
"""If CalDAV cleanup fails for one member, other members still cleaned up."""
org = factories.OrganizationFactory(external_id="doomed-org")
factories.UserFactory(email="alice@example.com", organization=org)
factories.UserFactory(email="bob@example.com", organization=org)
call_count = 0
def side_effect(**kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
raise Exception("Network error") # pylint: disable=broad-exception-raised
resp = mock.Mock()
resp.status_code = 200
return resp
with mock.patch(
"core.services.caldav_service.requests.request",
side_effect=side_effect,
):
with self.captureOnCommitCallbacks(execute=True):
org.delete()
# Org cleanup: 2 calls (1 fails, 1 succeeds), then user signal: 2 more
assert call_count == 4
assert not Organization.objects.filter(external_id="doomed-org").exists()
@override_settings(CALDAV_INTERNAL_API_KEY="")
def test_deleting_org_without_api_key_skips_caldav_cleanup(self):
"""When CALDAV_INTERNAL_API_KEY is empty, CalDAV cleanup is skipped."""
org = factories.OrganizationFactory(external_id="org-nokey")
factories.UserFactory(email="alice@example.com", organization=org)
with mock.patch(
"core.services.caldav_service.requests.request"
) as mock_request:
# Without the API key, the signal skips CalDAV cleanup but
# also doesn't delete members, so PROTECT FK blocks deletion.
try:
org.delete()
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
pass
mock_request.assert_not_called()

View File

@@ -0,0 +1,422 @@
"""Tests for the task system: task functions, polling endpoint, and async dispatch."""
# pylint: disable=import-outside-toplevel
import uuid
from unittest.mock import MagicMock, patch
from django.core.cache import cache
import pytest
from rest_framework.test import APIClient
from core import factories
from core.services.import_service import ImportResult
pytestmark = pytest.mark.django_db
# ---------------------------------------------------------------------------
# Test import_events_task function directly
# ---------------------------------------------------------------------------
class TestImportEventsTask:
"""Test the import_events_task function itself (via EagerBroker)."""
@patch("core.tasks.ICSImportService")
def test_task_returns_success_result(self, mock_service_cls):
"""Task should return a SUCCESS dict with import results."""
from core.tasks import import_events_task # noqa: PLC0415
mock_service = mock_service_cls.return_value
mock_service.import_events.return_value = ImportResult(
total_events=3,
imported_count=3,
duplicate_count=0,
skipped_count=0,
errors=[],
)
user = factories.UserFactory()
ics_data = b"BEGIN:VCALENDAR\r\nEND:VCALENDAR"
caldav_path = f"/calendars/users/{user.email}/{uuid.uuid4()}/"
result = import_events_task(str(user.id), caldav_path, ics_data.hex())
assert result["status"] == "SUCCESS"
assert result["result"]["total_events"] == 3
assert result["result"]["imported_count"] == 3
assert result["error"] is None
mock_service.import_events.assert_called_once_with(user, caldav_path, ics_data)
@patch("core.tasks.ICSImportService")
def test_task_user_not_found(self, mock_service_cls):
"""Task should return FAILURE if user does not exist."""
from core.tasks import import_events_task # noqa: PLC0415
result = import_events_task(
str(uuid.uuid4()), # non-existent user
"/calendars/users/nobody@example.com/cal/",
b"dummy".hex(),
)
assert result["status"] == "FAILURE"
assert "User not found" in result["error"]
mock_service_cls.return_value.import_events.assert_not_called()
@patch("core.tasks.set_task_progress")
@patch("core.tasks.ICSImportService")
def test_task_reports_progress(self, mock_service_cls, mock_progress):
"""Task should call set_task_progress at 0%, 10%, and 100%."""
from core.tasks import import_events_task # noqa: PLC0415
mock_service_cls.return_value.import_events.return_value = ImportResult(
total_events=1,
imported_count=1,
duplicate_count=0,
skipped_count=0,
errors=[],
)
user = factories.UserFactory()
import_events_task(
str(user.id),
f"/calendars/users/{user.email}/{uuid.uuid4()}/",
b"data".hex(),
)
progress_values = [call.args[0] for call in mock_progress.call_args_list]
assert progress_values == [0, 10, 100]
@patch("core.tasks.ICSImportService")
def test_task_via_delay(self, mock_service_cls):
"""Calling .delay() should dispatch and return a Task with an id."""
from core.tasks import import_events_task # noqa: PLC0415
mock_service_cls.return_value.import_events.return_value = ImportResult(
total_events=1,
imported_count=1,
duplicate_count=0,
skipped_count=0,
errors=[],
)
user = factories.UserFactory()
task = import_events_task.delay(
str(user.id),
f"/calendars/users/{user.email}/{uuid.uuid4()}/",
b"data".hex(),
)
assert task.id is not None
assert isinstance(task.id, str)
# ---------------------------------------------------------------------------
# Test TaskDetailView polling endpoint
# ---------------------------------------------------------------------------
class TestTaskDetailView:
"""Test the /api/v1.0/tasks/<task_id>/ polling endpoint."""
TASK_URL = "/api/v1.0/tasks/{task_id}/"
def test_requires_authentication(self):
"""Unauthenticated requests should be rejected."""
client = APIClient()
response = client.get(self.TASK_URL.format(task_id="some-id"))
assert response.status_code == 401
def test_task_not_found(self):
"""Unknown task_id should return 404."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.get(self.TASK_URL.format(task_id=str(uuid.uuid4())))
assert response.status_code == 404
assert response.json()["status"] == "FAILURE"
def test_task_forbidden_for_other_user(self):
"""Users cannot poll tasks they don't own."""
owner = factories.UserFactory()
other = factories.UserFactory()
# Simulate a tracked task owned by `owner`
task_id = str(uuid.uuid4())
import json # noqa: PLC0415
cache.set(
f"task_tracking:{task_id}",
json.dumps(
{
"owner": str(owner.id),
"actor_name": "import_events_task",
"queue_name": "import",
}
),
)
client = APIClient()
client.force_login(other)
response = client.get(self.TASK_URL.format(task_id=task_id))
assert response.status_code == 403
@patch("core.tasks.ICSImportService")
def test_poll_completed_task(self, mock_service_cls):
"""Polling a completed task should return SUCCESS with results."""
from core.tasks import import_events_task # noqa: PLC0415
expected_result = ImportResult(
total_events=5,
imported_count=4,
duplicate_count=1,
skipped_count=0,
errors=[],
)
mock_service_cls.return_value.import_events.return_value = expected_result
user = factories.UserFactory()
caldav_path = f"/calendars/users/{user.email}/{uuid.uuid4()}/"
# Dispatch via .delay() — EagerBroker runs it synchronously
task = import_events_task.delay(str(user.id), caldav_path, b"data".hex())
task.track_owner(user.id)
client = APIClient()
client.force_login(user)
response = client.get(self.TASK_URL.format(task_id=task.id))
assert response.status_code == 200
data = response.json()
assert data["status"] == "SUCCESS"
assert data["result"]["total_events"] == 5
assert data["result"]["imported_count"] == 4
assert data["error"] is None
@patch("core.tasks.ICSImportService")
def test_poll_task_owner_matches(self, mock_service_cls):
"""Only the task owner can poll the task."""
from core.tasks import import_events_task # noqa: PLC0415
mock_service_cls.return_value.import_events.return_value = ImportResult(
total_events=1,
imported_count=1,
duplicate_count=0,
skipped_count=0,
errors=[],
)
owner = factories.UserFactory()
other = factories.UserFactory()
caldav_path = f"/calendars/users/{owner.email}/{uuid.uuid4()}/"
task = import_events_task.delay(str(owner.id), caldav_path, b"data".hex())
task.track_owner(owner.id)
client = APIClient()
# Other user gets 403
client.force_login(other)
response = client.get(self.TASK_URL.format(task_id=task.id))
assert response.status_code == 403
# Owner gets 200
client.force_login(owner)
response = client.get(self.TASK_URL.format(task_id=task.id))
assert response.status_code == 200
assert response.json()["status"] == "SUCCESS"
# ---------------------------------------------------------------------------
# Test API → task dispatch integration
# ---------------------------------------------------------------------------
class TestImportAPITaskDispatch:
"""Test that the import API correctly dispatches a task."""
IMPORT_URL = "/api/v1.0/calendars/import-events/"
@patch("core.tasks.import_events_task")
def test_api_calls_delay_with_correct_args(self, mock_task):
"""The API should call .delay() with user_id, caldav_path, ics_hex."""
mock_message = MagicMock()
mock_message.id = str(uuid.uuid4())
mock_task.delay.return_value = mock_message
user = factories.UserFactory(email="dispatch@example.com")
caldav_path = f"/calendars/users/{user.email}/some-uuid/"
client = APIClient()
client.force_login(user)
from django.core.files.uploadedfile import ( # noqa: PLC0415
SimpleUploadedFile,
)
ics_file = SimpleUploadedFile(
"events.ics",
b"BEGIN:VCALENDAR\r\nEND:VCALENDAR",
content_type="text/calendar",
)
response = client.post(
self.IMPORT_URL,
{"file": ics_file, "caldav_path": caldav_path},
format="multipart",
)
assert response.status_code == 202
assert response.json()["task_id"] == mock_message.id
mock_task.delay.assert_called_once()
call_args = mock_task.delay.call_args.args
assert call_args[0] == str(user.id)
assert call_args[1] == caldav_path
# Third arg is ics_data as hex
assert bytes.fromhex(call_args[2]) == b"BEGIN:VCALENDAR\r\nEND:VCALENDAR"
@patch("core.tasks.import_events_task")
def test_api_returns_202_with_task_id(self, mock_task):
"""Successful dispatch should return HTTP 202 with task_id."""
mock_message = MagicMock()
mock_message.id = "test-task-id-123"
mock_task.delay.return_value = mock_message
user = factories.UserFactory(email="dispatch202@example.com")
caldav_path = f"/calendars/users/{user.email}/cal-uuid/"
client = APIClient()
client.force_login(user)
from django.core.files.uploadedfile import ( # noqa: PLC0415
SimpleUploadedFile,
)
ics_file = SimpleUploadedFile(
"events.ics",
b"BEGIN:VCALENDAR\r\nEND:VCALENDAR",
content_type="text/calendar",
)
response = client.post(
self.IMPORT_URL,
{"file": ics_file, "caldav_path": caldav_path},
format="multipart",
)
assert response.status_code == 202
data = response.json()
assert data["task_id"] == "test-task-id-123"
# ---------------------------------------------------------------------------
# Full round-trip: API dispatch → EagerBroker → poll result
# ---------------------------------------------------------------------------
class TestImportFullRoundTrip:
"""Full integration: POST import → task runs (EagerBroker) → poll result."""
IMPORT_URL = "/api/v1.0/calendars/import-events/"
TASK_URL = "/api/v1.0/tasks/{task_id}/"
@patch.object(
__import__(
"core.services.import_service", fromlist=["ICSImportService"]
).ICSImportService,
"import_events",
)
def test_full_round_trip(self, mock_import):
"""POST import → EagerBroker runs task → poll returns SUCCESS."""
mock_import.return_value = ImportResult(
total_events=2,
imported_count=2,
duplicate_count=0,
skipped_count=0,
errors=[],
)
user = factories.UserFactory(email="roundtrip@example.com")
caldav_path = f"/calendars/users/{user.email}/cal-uuid/"
client = APIClient()
client.force_login(user)
from django.core.files.uploadedfile import ( # noqa: PLC0415
SimpleUploadedFile,
)
ics_file = SimpleUploadedFile(
"events.ics",
b"BEGIN:VCALENDAR\r\nEND:VCALENDAR",
content_type="text/calendar",
)
# Step 1: POST triggers task dispatch
response = client.post(
self.IMPORT_URL,
{"file": ics_file, "caldav_path": caldav_path},
format="multipart",
)
assert response.status_code == 202
task_id = response.json()["task_id"]
# Step 2: Poll for result
poll_response = client.get(self.TASK_URL.format(task_id=task_id))
assert poll_response.status_code == 200
data = poll_response.json()
assert data["status"] == "SUCCESS"
assert data["result"]["total_events"] == 2
assert data["result"]["imported_count"] == 2
assert data["error"] is None
@patch.object(
__import__(
"core.services.import_service", fromlist=["ICSImportService"]
).ICSImportService,
"import_events",
)
def test_full_round_trip_with_errors(self, mock_import):
"""Task that returns partial failure should surface errors via poll."""
mock_import.return_value = ImportResult(
total_events=3,
imported_count=1,
duplicate_count=0,
skipped_count=2,
errors=["Event A", "Event B"],
)
user = factories.UserFactory(email="roundtrip-err@example.com")
caldav_path = f"/calendars/users/{user.email}/cal-uuid/"
client = APIClient()
client.force_login(user)
from django.core.files.uploadedfile import ( # noqa: PLC0415
SimpleUploadedFile,
)
ics_file = SimpleUploadedFile(
"events.ics",
b"BEGIN:VCALENDAR\r\nEND:VCALENDAR",
content_type="text/calendar",
)
response = client.post(
self.IMPORT_URL,
{"file": ics_file, "caldav_path": caldav_path},
format="multipart",
)
assert response.status_code == 202
task_id = response.json()["task_id"]
poll_response = client.get(self.TASK_URL.format(task_id=task_id))
assert poll_response.status_code == 200
data = poll_response.json()
assert data["status"] == "SUCCESS"
assert data["result"]["skipped_count"] == 2
assert data["result"]["errors"] == ["Event A", "Event B"]

View File

@@ -8,19 +8,18 @@ from rest_framework.routers import DefaultRouter
from core.api import viewsets
from core.api.viewsets_caldav import CalDAVProxyView, CalDAVSchedulingCallbackView
from core.api.viewsets_channels import ChannelViewSet
from core.api.viewsets_ical import ICalExportView
from core.api.viewsets_rsvp import RSVPView
from core.api.viewsets_rsvp import RSVPConfirmView, RSVPProcessView
from core.api.viewsets_task import TaskDetailView
from core.external_api import viewsets as external_api_viewsets
# - Main endpoints
router = DefaultRouter()
router.register("users", viewsets.UserViewSet, basename="users")
router.register("calendars", viewsets.CalendarViewSet, basename="calendars")
router.register(
"subscription-tokens",
viewsets.SubscriptionTokenViewSet,
basename="subscription-tokens",
)
router.register("resources", viewsets.ResourceViewSet, basename="resources")
router.register("channels", ChannelViewSet, basename="channels")
urlpatterns = [
path(
@@ -29,35 +28,46 @@ urlpatterns = [
[
*router.urls,
*oidc_urls,
# CalDAV proxy - root path (must come before catch-all to match /caldav exactly)
path("caldav", CalDAVProxyView.as_view(), name="caldav-root"),
path("caldav/", CalDAVProxyView.as_view(), name="caldav-root-slash"),
# CalDAV proxy - catch all paths with content
re_path(
r"^caldav/(?P<path>.+)$",
CalDAVProxyView.as_view(),
name="caldav-proxy",
),
# CalDAV scheduling callback endpoint (separate from caldav proxy)
# CalDAV scheduling callback endpoint
path(
"caldav-scheduling-callback/",
CalDAVSchedulingCallbackView.as_view(),
name="caldav-scheduling-callback",
),
# RSVP POST endpoint (state-changing, with DRF throttling)
path(
"rsvp/",
RSVPProcessView.as_view(),
name="rsvp-process",
),
# Task status polling endpoint
path(
"tasks/<str:task_id>/",
TaskDetailView.as_view(),
name="task-detail",
),
]
),
),
path(f"api/{settings.API_VERSION}/config/", viewsets.ConfigView.as_view()),
# CalDAV proxy - top-level stable path (not versioned)
path("caldav", CalDAVProxyView.as_view(), name="caldav-root"),
path("caldav/", CalDAVProxyView.as_view(), name="caldav-root-slash"),
re_path(
r"^caldav/(?P<path>.+)$",
CalDAVProxyView.as_view(),
name="caldav-proxy",
),
# Public iCal export endpoint (no authentication required)
# Token in URL acts as authentication
path(
"ical/<uuid:token>.ics",
# base64url channel ID for lookup, base64url token for auth, filename cosmetic
re_path(
r"^ical/(?P<short_id>[A-Za-z0-9_-]+)/(?P<token>[A-Za-z0-9_-]+)/[^/]+\.ics$",
ICalExportView.as_view(),
name="ical-export",
),
# RSVP endpoint (no authentication required)
# RSVP GET endpoint (renders auto-submitting confirmation page)
# Signed token in query string acts as authentication
path("rsvp/", RSVPView.as_view(), name="rsvp"),
path("rsvp/", RSVPConfirmView.as_view(), name="rsvp"),
]

View File

@@ -27,11 +27,15 @@ class UserAuthViewSet(drf.viewsets.ViewSet):
serializer.is_valid(raise_exception=True)
# Create user if doesn't exist
user = models.User.objects.filter(
email=serializer.validated_data["email"]
).first()
email = serializer.validated_data["email"]
user = models.User.objects.filter(email=email).first()
if not user:
user = models.User(email=serializer.validated_data["email"])
domain = email.split("@")[-1] if "@" in email else "e2e"
org, _ = models.Organization.objects.get_or_create(
external_id=domain,
defaults={"name": domain},
)
user = models.User(email=email, organization=org)
user.set_unusable_password()
user.save()

View File

@@ -1,455 +0,0 @@
msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-05-19 15:10+0000\n"
"PO-Revision-Date: 2025-01-27 09:27\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de_DE\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: lasuite-docs\n"
"X-Crowdin-Project-ID: 754523\n"
"X-Crowdin-Language: de\n"
"X-Crowdin-File: backend-calendars.pot\n"
"X-Crowdin-File-ID: 18\n"
#: core/admin.py:26
msgid "Personal info"
msgstr "Persönliche Daten"
#: core/admin.py:39 core/admin.py:119
msgid "Permissions"
msgstr "Berechtigungen"
#: core/admin.py:51
msgid "Important dates"
msgstr "Wichtige Daten"
#: core/admin.py:129
msgid "Tree structure"
msgstr ""
#: core/api/filters.py:16
msgid "Title"
msgstr "Titel"
#: core/api/filters.py:28
msgid "Creator is me"
msgstr "Ersteller bin ich"
#: core/api/filters.py:31
msgid "Favorite"
msgstr "Favorit"
#: core/api/serializers.py:304
msgid "An item with this title already exists in the current path."
msgstr ""
#: core/api/serializers.py:397
msgid "This field is required for files."
msgstr ""
#: core/api/serializers.py:409
msgid "This field is required for folders."
msgstr ""
#: core/models.py:53 core/models.py:60
msgid "Reader"
msgstr "Lesen"
#: core/models.py:54 core/models.py:61
msgid "Editor"
msgstr "Bearbeiten"
#: core/models.py:62
msgid "Administrator"
msgstr ""
#: core/models.py:63
msgid "Owner"
msgstr "Besitzer"
#: core/models.py:74
msgid "Restricted"
msgstr "Beschränkt"
#: core/models.py:78
msgid "Authenticated"
msgstr "Authentifiziert"
#: core/models.py:80
msgid "Public"
msgstr "Öffentlich"
#: core/models.py:86
msgid "Folder"
msgstr ""
#: core/models.py:87
msgid "File"
msgstr ""
#: core/models.py:93
msgid "Pending"
msgstr ""
#: core/models.py:94
msgid "Uploaded"
msgstr ""
#: core/models.py:116
msgid "id"
msgstr ""
#: core/models.py:117
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:123
msgid "created on"
msgstr "Erstellt"
#: core/models.py:124
msgid "date and time at which a record was created"
msgstr "Datum und Uhrzeit, an dem ein Datensatz erstellt wurde"
#: core/models.py:129
msgid "updated on"
msgstr "Aktualisiert"
#: core/models.py:130
msgid "date and time at which a record was last updated"
msgstr "Datum und Uhrzeit, an dem zuletzt aktualisiert wurde"
#: core/models.py:166
msgid ""
"We couldn't find a user with this sub but the email is already associated "
"with a registered user."
msgstr ""
#: core/models.py:179
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_/: characters."
msgstr ""
"Geben Sie eine gültige Unterseite ein. Dieser Wert darf nur Buchstaben, "
"Zahlen und die @/./+/-/_/: Zeichen enthalten."
#: core/models.py:185
msgid "sub"
msgstr "unter"
#: core/models.py:187
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: "
"characters only."
msgstr ""
"Erforderlich. 255 Zeichen oder weniger. Buchstaben, Zahlen und die Zeichen "
"@/./+/-/_/:"
#: core/models.py:196
msgid "full name"
msgstr "Name"
#: core/models.py:197
msgid "short name"
msgstr "Kurzbezeichnung"
#: core/models.py:199
msgid "identity email address"
msgstr "Identitäts-E-Mail-Adresse"
#: core/models.py:204
msgid "admin email address"
msgstr "Admin E-Mail-Adresse"
#: core/models.py:211
msgid "language"
msgstr "Sprache"
#: core/models.py:212
msgid "The language in which the user wants to see the interface."
msgstr "Die Sprache, in der der Benutzer die Benutzeroberfläche sehen möchte."
#: core/models.py:218
msgid "The timezone in which the user wants to see times."
msgstr "Die Zeitzone, in der der Nutzer Zeiten sehen möchte."
#: core/models.py:221
msgid "device"
msgstr "Gerät"
#: core/models.py:223
msgid "Whether the user is a device or a real user."
msgstr "Ob der Benutzer ein Gerät oder ein echter Benutzer ist."
#: core/models.py:226
msgid "staff status"
msgstr "Status des Teammitgliedes"
#: core/models.py:228
msgid "Whether the user can log into this admin site."
msgstr "Gibt an, ob der Benutzer sich in diese Admin-Seite einloggen kann."
#: core/models.py:231
msgid "active"
msgstr "aktiviert"
#: core/models.py:234
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
"Ob dieser Benutzer als aktiviert behandelt werden soll. Deaktivieren Sie "
"diese Option, anstatt Konten zu löschen."
#: core/models.py:246
msgid "user"
msgstr "Benutzer"
#: core/models.py:247
msgid "users"
msgstr "Benutzer"
#: core/models.py:269
msgid "Workspace"
msgstr ""
#: core/models.py:457
msgid "Only folders can have children."
msgstr ""
#: core/models.py:470
#, fuzzy
#| msgid "This team is already in this item."
msgid "title already exists in this folder."
msgstr "Dieses Team befindet sich bereits in diesem Dokument."
#: core/models.py:504
msgid "title"
msgstr "Titel"
#: core/models.py:549
msgid "Item"
msgstr ""
#: core/models.py:550
#, fuzzy
#| msgid "items"
msgid "Items"
msgstr "Dokumente"
#: core/models.py:815
#, fuzzy, python-brace-format
#| msgid "{name} shared a item with you!"
msgid "{name} shared an item with you!"
msgstr "{name} hat ein Dokument mit Ihnen geteilt!"
#: core/models.py:817
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following item:"
msgstr ""
"{name} hat Sie mit der Rolle \"{role}\" zu folgendem Dokument eingeladen:"
#: core/models.py:820
#, fuzzy, python-brace-format
#| msgid "{name} shared a item with you: {title}"
msgid "{name} shared an item with you: {title}"
msgstr "{name} hat ein Dokument mit Ihnen geteilt: {title}"
#: core/models.py:872
#, fuzzy
#| msgid "This team is already in this template."
msgid "This item is already hard deleted."
msgstr "Dieses Team ist bereits in diesem Template."
#: core/models.py:882
msgid "To hard delete an item, it must first be soft deleted."
msgstr ""
#: core/models.py:902
#, fuzzy
#| msgid "This team is already in this template."
msgid "This item is not deleted."
msgstr "Dieses Team ist bereits in diesem Template."
#: core/models.py:918
msgid "This item was permanently deleted and cannot be restored."
msgstr ""
#: core/models.py:968
msgid "Only folders can be targeted when moving an item"
msgstr ""
#: core/models.py:1021
#, fuzzy
#| msgid "item/user link trace"
msgid "Item/user link trace"
msgstr "Dokument/Benutzer Linkverfolgung"
#: core/models.py:1022
#, fuzzy
#| msgid "item/user link traces"
msgid "Item/user link traces"
msgstr "Dokument/Benutzer Linkverfolgung"
#: core/models.py:1028
msgid "A link trace already exists for this item/user."
msgstr ""
#: core/models.py:1051
#, fuzzy
#| msgid "item favorite"
msgid "Item favorite"
msgstr "Dokumentenfavorit"
#: core/models.py:1052
#, fuzzy
#| msgid "item favorites"
msgid "Item favorites"
msgstr "Dokumentfavoriten"
#: core/models.py:1058
msgid ""
"This item is already targeted by a favorite relation instance for the same "
"user."
msgstr ""
"Dieses Dokument ist bereits durch den gleichen Benutzer favorisiert worden."
#: core/models.py:1080
#, fuzzy
#| msgid "item/user relation"
msgid "Item/user relation"
msgstr "Dokument/Benutzerbeziehung"
#: core/models.py:1081
#, fuzzy
#| msgid "item/user relations"
msgid "Item/user relations"
msgstr "Dokument/Benutzerbeziehungen"
#: core/models.py:1087
msgid "This user is already in this item."
msgstr "Dieser Benutzer befindet sich bereits in diesem Dokument."
#: core/models.py:1093
msgid "This team is already in this item."
msgstr "Dieses Team befindet sich bereits in diesem Dokument."
#: core/models.py:1099
msgid "Either user or team must be set, not both."
msgstr "Benutzer oder Team müssen gesetzt werden, nicht beides."
#: core/models.py:1126
msgid "email address"
msgstr "E-Mail-Adresse"
#: core/models.py:1145
#, fuzzy
#| msgid "item invitation"
msgid "Item invitation"
msgstr "Einladung zum Dokument"
#: core/models.py:1146
#, fuzzy
#| msgid "item invitations"
msgid "Item invitations"
msgstr "Dokumenteinladungen"
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
msgid "Logo email"
msgstr ""
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
msgid "Open"
msgstr ""
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid ""
" Calendars, your new essential tool for organizing, sharing and collaborating as "
"a team. "
msgstr ""
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr ""
#: calendars/settings.py:250
msgid "English"
msgstr "Englisch"
#: calendars/settings.py:251
msgid "French"
msgstr "Französisch"
#: calendars/settings.py:252
msgid "German"
msgstr "Deutsch"
#~ msgid "Invalid response format or token verification failed"
#~ msgstr "Ungültiges Antwortformat oder Token-Verifizierung fehlgeschlagen"
#~ msgid "User account is disabled"
#~ msgstr "Benutzerkonto ist deaktiviert"
#, fuzzy
#~| msgid "Untitled item"
#~ msgid "Untitled Item"
#~ msgstr "Unbenanntes Dokument"
#~ msgid "This email is already associated to a registered user."
#~ msgstr "Diese E-Mail ist bereits einem registrierten Benutzer zugeordnet."
#~ msgid "A new item was created on your behalf!"
#~ msgstr "Ein neues Dokument wurde in Ihrem Namen erstellt!"
#~ msgid "You have been granted ownership of a new item:"
#~ msgstr "Sie sind Besitzer eines neuen Dokuments:"
#~ msgid "Body"
#~ msgstr "Inhalt"
#~ msgid "Body type"
#~ msgstr "Typ"
#~ msgid "item"
#~ msgstr "Dokument"
#~ msgid "description"
#~ msgstr "Beschreibung"
#~ msgid "code"
#~ msgstr "Code"
#~ msgid "css"
#~ msgstr "CSS"
#~ msgid "public"
#~ msgstr "öffentlich"
#~ msgid "Whether this template is public for anyone to use."
#~ msgstr "Ob diese Vorlage für jedermann öffentlich ist."
#~ msgid "Template"
#~ msgstr "Vorlage"
#~ msgid "Templates"
#~ msgstr "Vorlagen"
#~ msgid "Template/user relation"
#~ msgstr "Vorlage/Benutzer-Beziehung"
#~ msgid "Template/user relations"
#~ msgstr "Vorlage/Benutzerbeziehungen"
#~ msgid "This user is already in this template."
#~ msgstr "Dieser Benutzer ist bereits in dieser Vorlage."

View File

@@ -1,362 +0,0 @@
msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-05-19 15:10+0000\n"
"PO-Revision-Date: 2025-01-27 09:27\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: lasuite-docs\n"
"X-Crowdin-Project-ID: 754523\n"
"X-Crowdin-Language: en\n"
"X-Crowdin-File: backend-calendars.pot\n"
"X-Crowdin-File-ID: 18\n"
#: core/admin.py:26
msgid "Personal info"
msgstr ""
#: core/admin.py:39 core/admin.py:119
msgid "Permissions"
msgstr ""
#: core/admin.py:51
msgid "Important dates"
msgstr ""
#: core/admin.py:129
msgid "Tree structure"
msgstr ""
#: core/api/filters.py:16
msgid "Title"
msgstr ""
#: core/api/filters.py:28
msgid "Creator is me"
msgstr ""
#: core/api/filters.py:31
msgid "Favorite"
msgstr ""
#: core/api/serializers.py:304
msgid "An item with this title already exists in the current path."
msgstr ""
#: core/api/serializers.py:397
msgid "This field is required for files."
msgstr ""
#: core/api/serializers.py:409
msgid "This field is required for folders."
msgstr ""
#: core/models.py:53 core/models.py:60
msgid "Reader"
msgstr ""
#: core/models.py:54 core/models.py:61
msgid "Editor"
msgstr ""
#: core/models.py:62
msgid "Administrator"
msgstr ""
#: core/models.py:63
msgid "Owner"
msgstr ""
#: core/models.py:74
msgid "Restricted"
msgstr ""
#: core/models.py:78
msgid "Authenticated"
msgstr ""
#: core/models.py:80
msgid "Public"
msgstr ""
#: core/models.py:86
msgid "Folder"
msgstr ""
#: core/models.py:87
msgid "File"
msgstr ""
#: core/models.py:93
msgid "Pending"
msgstr ""
#: core/models.py:94
msgid "Uploaded"
msgstr ""
#: core/models.py:116
msgid "id"
msgstr ""
#: core/models.py:117
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:123
msgid "created on"
msgstr ""
#: core/models.py:124
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:129
msgid "updated on"
msgstr ""
#: core/models.py:130
msgid "date and time at which a record was last updated"
msgstr ""
#: core/models.py:166
msgid ""
"We couldn't find a user with this sub but the email is already associated "
"with a registered user."
msgstr ""
#: core/models.py:179
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_/: characters."
msgstr ""
#: core/models.py:185
msgid "sub"
msgstr ""
#: core/models.py:187
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: "
"characters only."
msgstr ""
#: core/models.py:196
msgid "full name"
msgstr ""
#: core/models.py:197
msgid "short name"
msgstr ""
#: core/models.py:199
msgid "identity email address"
msgstr ""
#: core/models.py:204
msgid "admin email address"
msgstr ""
#: core/models.py:211
msgid "language"
msgstr ""
#: core/models.py:212
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:218
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:221
msgid "device"
msgstr ""
#: core/models.py:223
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:226
msgid "staff status"
msgstr ""
#: core/models.py:228
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:231
msgid "active"
msgstr ""
#: core/models.py:234
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
#: core/models.py:246
msgid "user"
msgstr ""
#: core/models.py:247
msgid "users"
msgstr ""
#: core/models.py:269
msgid "Workspace"
msgstr ""
#: core/models.py:457
msgid "Only folders can have children."
msgstr ""
#: core/models.py:470
msgid "title already exists in this folder."
msgstr ""
#: core/models.py:504
msgid "title"
msgstr ""
#: core/models.py:549
msgid "Item"
msgstr ""
#: core/models.py:550
msgid "Items"
msgstr ""
#: core/models.py:815
#, python-brace-format
msgid "{name} shared an item with you!"
msgstr ""
#: core/models.py:817
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following item:"
msgstr ""
#: core/models.py:820
#, python-brace-format
msgid "{name} shared an item with you: {title}"
msgstr ""
#: core/models.py:872
msgid "This item is already hard deleted."
msgstr ""
#: core/models.py:882
msgid "To hard delete an item, it must first be soft deleted."
msgstr ""
#: core/models.py:902
msgid "This item is not deleted."
msgstr ""
#: core/models.py:918
msgid "This item was permanently deleted and cannot be restored."
msgstr ""
#: core/models.py:968
msgid "Only folders can be targeted when moving an item"
msgstr ""
#: core/models.py:1021
msgid "Item/user link trace"
msgstr ""
#: core/models.py:1022
msgid "Item/user link traces"
msgstr ""
#: core/models.py:1028
msgid "A link trace already exists for this item/user."
msgstr ""
#: core/models.py:1051
msgid "Item favorite"
msgstr ""
#: core/models.py:1052
msgid "Item favorites"
msgstr ""
#: core/models.py:1058
msgid ""
"This item is already targeted by a favorite relation instance for the same "
"user."
msgstr ""
#: core/models.py:1080
msgid "Item/user relation"
msgstr ""
#: core/models.py:1081
msgid "Item/user relations"
msgstr ""
#: core/models.py:1087
msgid "This user is already in this item."
msgstr ""
#: core/models.py:1093
msgid "This team is already in this item."
msgstr ""
#: core/models.py:1099
msgid "Either user or team must be set, not both."
msgstr ""
#: core/models.py:1126
msgid "email address"
msgstr ""
#: core/models.py:1145
msgid "Item invitation"
msgstr ""
#: core/models.py:1146
msgid "Item invitations"
msgstr ""
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
msgid "Logo email"
msgstr ""
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
msgid "Open"
msgstr ""
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid ""
" Calendars, your new essential tool for organizing, sharing and collaborating as "
"a team. "
msgstr ""
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr ""
#: calendars/settings.py:250
msgid "English"
msgstr ""
#: calendars/settings.py:251
msgid "French"
msgstr ""
#: calendars/settings.py:252
msgid "German"
msgstr ""

View File

@@ -1,371 +0,0 @@
msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-05-19 15:10+0000\n"
"PO-Revision-Date: 2025-01-27 09:27\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
"X-Crowdin-Project: lasuite-docs\n"
"X-Crowdin-Project-ID: 754523\n"
"X-Crowdin-Language: fr\n"
"X-Crowdin-File: backend-calendars.pot\n"
"X-Crowdin-File-ID: 18\n"
#: core/admin.py:26
msgid "Personal info"
msgstr "Infos Personnelles"
#: core/admin.py:39 core/admin.py:119
msgid "Permissions"
msgstr ""
#: core/admin.py:51
msgid "Important dates"
msgstr "Dates importantes"
#: core/admin.py:129
msgid "Tree structure"
msgstr ""
#: core/api/filters.py:16
msgid "Title"
msgstr ""
#: core/api/filters.py:28
msgid "Creator is me"
msgstr ""
#: core/api/filters.py:31
msgid "Favorite"
msgstr ""
#: core/api/serializers.py:304
msgid "An item with this title already exists in the current path."
msgstr ""
#: core/api/serializers.py:397
msgid "This field is required for files."
msgstr ""
#: core/api/serializers.py:409
msgid "This field is required for folders."
msgstr ""
#: core/models.py:53 core/models.py:60
msgid "Reader"
msgstr "Lecteur"
#: core/models.py:54 core/models.py:61
msgid "Editor"
msgstr "Éditeur"
#: core/models.py:62
msgid "Administrator"
msgstr "Administrateur"
#: core/models.py:63
msgid "Owner"
msgstr "Propriétaire"
#: core/models.py:74
msgid "Restricted"
msgstr "Restreint"
#: core/models.py:78
msgid "Authenticated"
msgstr "Authentifié"
#: core/models.py:80
msgid "Public"
msgstr ""
#: core/models.py:86
msgid "Folder"
msgstr ""
#: core/models.py:87
msgid "File"
msgstr ""
#: core/models.py:93
msgid "Pending"
msgstr ""
#: core/models.py:94
msgid "Uploaded"
msgstr ""
#: core/models.py:116
msgid "id"
msgstr ""
#: core/models.py:117
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:123
msgid "created on"
msgstr ""
#: core/models.py:124
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:129
msgid "updated on"
msgstr ""
#: core/models.py:130
msgid "date and time at which a record was last updated"
msgstr ""
#: core/models.py:166
msgid ""
"We couldn't find a user with this sub but the email is already associated "
"with a registered user."
msgstr ""
#: core/models.py:179
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_/: characters."
msgstr ""
#: core/models.py:185
msgid "sub"
msgstr ""
#: core/models.py:187
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: "
"characters only."
msgstr ""
#: core/models.py:196
msgid "full name"
msgstr ""
#: core/models.py:197
msgid "short name"
msgstr ""
#: core/models.py:199
msgid "identity email address"
msgstr ""
#: core/models.py:204
msgid "admin email address"
msgstr ""
#: core/models.py:211
msgid "language"
msgstr ""
#: core/models.py:212
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:218
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:221
msgid "device"
msgstr ""
#: core/models.py:223
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:226
msgid "staff status"
msgstr ""
#: core/models.py:228
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:231
msgid "active"
msgstr ""
#: core/models.py:234
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
#: core/models.py:246
msgid "user"
msgstr ""
#: core/models.py:247
msgid "users"
msgstr ""
#: core/models.py:269
msgid "Workspace"
msgstr ""
#: core/models.py:457
msgid "Only folders can have children."
msgstr ""
#: core/models.py:470
msgid "title already exists in this folder."
msgstr ""
#: core/models.py:504
msgid "title"
msgstr ""
#: core/models.py:549
msgid "Item"
msgstr ""
#: core/models.py:550
msgid "Items"
msgstr ""
#: core/models.py:815
#, python-brace-format
msgid "{name} shared an item with you!"
msgstr "{name} a partagé un item avec vous!"
#: core/models.py:817
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following item:"
msgstr "{name} vous a invité avec le rôle \"{role}\" sur le item suivant:"
#: core/models.py:820
#, python-brace-format
#| msgid "{name} shared an item with you: {title}"
msgid "{name} shared an item with you: {title}"
msgstr "{name} a partagé un item avec vous: {title}"
#: core/models.py:872
msgid "This item is already hard deleted."
msgstr ""
#: core/models.py:882
msgid "To hard delete an item, it must first be soft deleted."
msgstr ""
#: core/models.py:902
msgid "This item is not deleted."
msgstr ""
#: core/models.py:918
msgid "This item was permanently deleted and cannot be restored."
msgstr ""
#: core/models.py:968
msgid "Only folders can be targeted when moving an item"
msgstr ""
#: core/models.py:1021
msgid "Item/user link trace"
msgstr ""
#: core/models.py:1022
msgid "Item/user link traces"
msgstr ""
#: core/models.py:1028
msgid "A link trace already exists for this item/user."
msgstr ""
#: core/models.py:1051
msgid "Item favorite"
msgstr ""
#: core/models.py:1052
msgid "Item favorites"
msgstr ""
#: core/models.py:1058
msgid ""
"This item is already targeted by a favorite relation instance for the same "
"user."
msgstr ""
#: core/models.py:1080
msgid "Item/user relation"
msgstr ""
#: core/models.py:1081
msgid "Item/user relations"
msgstr ""
#: core/models.py:1087
msgid "This user is already in this item."
msgstr ""
#: core/models.py:1093
msgid "This team is already in this item."
msgstr ""
#: core/models.py:1099
msgid "Either user or team must be set, not both."
msgstr ""
#: core/models.py:1126
msgid "email address"
msgstr ""
#: core/models.py:1145
msgid "Item invitation"
msgstr ""
#: core/models.py:1146
msgid "Item invitations"
msgstr ""
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
msgid "Logo email"
msgstr ""
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
msgid "Open"
msgstr "Ouvrir"
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid ""
" Calendars, your new essential tool for organizing, sharing and collaborating as "
"a team. "
msgstr ""
"Fichiers, votre outil essentiel pour organiser, partager et collaborer en "
"équipe."
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr " Proposé par %(brandname)s "
#: calendars/settings.py:250
msgid "English"
msgstr ""
#: calendars/settings.py:251
msgid "French"
msgstr ""
#: calendars/settings.py:252
msgid "German"
msgstr ""
#~ msgid "A new item was created on your behalf!"
#~ msgstr "Un nouveau item a été créé pour vous !"
#~ msgid "You have been granted ownership of a new item:"
#~ msgstr "Vous avez été déclaré propriétaire d'un nouveau item :"

View File

@@ -1,369 +0,0 @@
msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-05-19 15:10+0000\n"
"PO-Revision-Date: 2025-01-27 09:27\n"
"Last-Translator: \n"
"Language-Team: Dutch\n"
"Language: nl_NL\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: lasuite-docs\n"
"X-Crowdin-Project-ID: 754523\n"
"X-Crowdin-Language: nl\n"
"X-Crowdin-File: backend-calendars.pot\n"
"X-Crowdin-File-ID: 18\n"
#: core/admin.py:26
msgid "Personal info"
msgstr "Persoonlijke gegevens"
#: core/admin.py:39 core/admin.py:119
msgid "Permissions"
msgstr "Machtigingen"
#: core/admin.py:51
msgid "Important dates"
msgstr "Belangrijke data"
#: core/admin.py:129
msgid "Tree structure"
msgstr "Boomstructuur"
#: core/api/filters.py:16
msgid "Title"
msgstr "Titel"
#: core/api/filters.py:28
msgid "Creator is me"
msgstr "Ik ben eigenaar"
#: core/api/filters.py:31
msgid "Favorite"
msgstr "Favoriet"
#: core/api/serializers.py:304
msgid "An item with this title already exists in the current path."
msgstr "Er bestaat al een item met deze titel in het huidige pad."
#: core/api/serializers.py:397
msgid "This field is required for files."
msgstr "Dit veld is verplicht voor bestanden"
#: core/api/serializers.py:409
msgid "This field is required for folders."
msgstr "Dit veld is verplicht voor mappen."
#: core/models.py:53 core/models.py:60
msgid "Reader"
msgstr "Lezer"
#: core/models.py:54 core/models.py:61
msgid "Editor"
msgstr "Redacteur"
#: core/models.py:62
msgid "Administrator"
msgstr "Beheerder"
#: core/models.py:63
msgid "Owner"
msgstr "Eigenaar"
#: core/models.py:74
msgid "Restricted"
msgstr "Beperkt"
#: core/models.py:78
msgid "Authenticated"
msgstr "Ingelogd"
#: core/models.py:80
msgid "Public"
msgstr "Openbaar"
#: core/models.py:86
msgid "Folder"
msgstr "Map"
#: core/models.py:87
msgid "File"
msgstr "Bestand"
#: core/models.py:93
msgid "Pending"
msgstr "In afwachting"
#: core/models.py:94
msgid "Uploaded"
msgstr "Geüpload"
#: core/models.py:116
msgid "id"
msgstr "id"
#: core/models.py:117
msgid "primary key for the record as UUID"
msgstr "primaire sleutel voor het record als UUID"
#: core/models.py:123
msgid "created on"
msgstr "gecreëerd op"
#: core/models.py:124
msgid "date and time at which a record was created"
msgstr "datum en tijd waarop een record is gemaakt"
#: core/models.py:129
msgid "updated on"
msgstr "bijgewerkt op"
#: core/models.py:130
msgid "date and time at which a record was last updated"
msgstr "datum en tijd waarop een record voor het laatst is bijgewerkt"
#: core/models.py:166
msgid ""
"We couldn't find a user with this sub but the email is already associated "
"with a registered user."
msgstr "We konden geen gebruiker vinden met deze id, maar het e-mailadres is al gekoppeld aan een geregistreerde gebruiker."
#: core/models.py:179
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_/: characters."
msgstr "Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/_/: "
"tekens bevatten."
#: core/models.py:185
msgid "sub"
msgstr "id"
#: core/models.py:187
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: "
"characters only."
msgstr "Verplicht. 255 tekens of minder. Alleen letters, cijfers en @/./+/-/_/: tekens."
#: core/models.py:196
msgid "full name"
msgstr "volledige naam"
#: core/models.py:197
msgid "short name"
msgstr "gebruikersnaam"
#: core/models.py:199
msgid "identity email address"
msgstr "identiteits e-mailadres"
#: core/models.py:204
msgid "admin email address"
msgstr "admin e-mailadres"
#: core/models.py:211
msgid "language"
msgstr "taal"
#: core/models.py:212
msgid "The language in which the user wants to see the interface."
msgstr "De taal waarin de gebruiker de interface wil zien."
#: core/models.py:218
msgid "The timezone in which the user wants to see times."
msgstr "Tijdzone waarin de gebruiker de tijd wil zien."
#: core/models.py:221
msgid "device"
msgstr "apparaat"
#: core/models.py:223
msgid "Whether the user is a device or a real user."
msgstr "Of de gebruiker een apparaat of een echte gebruiker is."
#: core/models.py:226
msgid "staff status"
msgstr "personeelsstatus"
#: core/models.py:228
msgid "Whether the user can log into this admin site."
msgstr "Of de gebruiker kan inloggen op deze beheer site."
#: core/models.py:231
msgid "active"
msgstr "actief"
#: core/models.py:234
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr "Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in plaats van "
"accounts te verwijderen."
#: core/models.py:246
msgid "user"
msgstr "gebruiker"
#: core/models.py:247
msgid "users"
msgstr "gebruikers"
#: core/models.py:269
msgid "Workspace"
msgstr "Werkruimte"
#: core/models.py:457
msgid "Only folders can have children."
msgstr "Alleen mappen kunnen subitems hebben."
#: core/models.py:470
msgid "title already exists in this folder."
msgstr "titel bestaat al in deze map."
#: core/models.py:504
msgid "title"
msgstr "titel"
#: core/models.py:549
msgid "Item"
msgstr "Item"
#: core/models.py:550
msgid "Items"
msgstr "Items"
#: core/models.py:815
#, python-brace-format
msgid "{name} shared an item with you!"
msgstr "{name} heeft een item met je gedeeld!"
#: core/models.py:817
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following item:"
msgstr "{name} heeft je uitgenodigd met de rol \"{role}\" voor het volgende item:"
#: core/models.py:820
#, python-brace-format
msgid "{name} shared an item with you: {title}"
msgstr "{name} heeft een item met je gedeeld: {title}"
#: core/models.py:872
msgid "This item is already hard deleted."
msgstr "Dit item is al permanent verwijderd."
#: core/models.py:882
msgid "To hard delete an item, it must first be soft deleted."
msgstr "Om een item permanent te verwijderen, moet het eerst tijdelijk worden verwijderd."
#: core/models.py:902
msgid "This item is not deleted."
msgstr "Dit item is niet verwijderd."
#: core/models.py:918
msgid "This item was permanently deleted and cannot be restored."
msgstr "Dit item is permanent verwijderd en kan niet worden hersteld."
#: core/models.py:968
msgid "Only folders can be targeted when moving an item"
msgstr "Alleen mappen kunnen worden geselecteerd bij het verplaatsen van een item."
#: core/models.py:1021
msgid "Item/user link trace"
msgstr "Item/gebruiker link "
#: core/models.py:1022
msgid "Item/user link traces"
msgstr "Item/gebruiker link"
#: core/models.py:1028
msgid "A link trace already exists for this item/user."
msgstr "Er bestaat al een link trace voor dit item/gebruiker."
#: core/models.py:1051
msgid "Item favorite"
msgstr "Item favoriet"
#: core/models.py:1052
msgid "Item favorites"
msgstr "Item favorieten"
#: core/models.py:1058
msgid ""
"This item is already targeted by a favorite relation instance for the same "
"user."
msgstr "Dit item is al het doel van een favorietrelatie voor dezelfde "
"gebruiker."
#: core/models.py:1080
msgid "Item/user relation"
msgstr "Item/gebruiker relatie"
#: core/models.py:1081
msgid "Item/user relations"
msgstr "Item/gebruiker relaties"
#: core/models.py:1087
msgid "This user is already in this item."
msgstr "Deze gebruiker bestaat al in dit item."
#: core/models.py:1093
msgid "This team is already in this item."
msgstr "Dit team bestaat al in dit item."
#: core/models.py:1099
msgid "Either user or team must be set, not both."
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
#: core/models.py:1126
msgid "email address"
msgstr "e-mailadres"
#: core/models.py:1145
msgid "Item invitation"
msgstr "Item uitnodiging"
#: core/models.py:1146
msgid "Item invitations"
msgstr "Item uitnodigingen"
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
msgid "Logo email"
msgstr "Logo e-mail"
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
msgid "Open"
msgstr "Open"
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid ""
" Calendars, your new essential tool for organizing, sharing and collaborating as "
"a team. "
msgstr " Calendars, jouw nieuwe essentiële tool voor het organiseren, delen en samenwerken als team"
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr " Aangeboden door %(brandname)s "
#: calendars/settings.py:250
msgid "English"
msgstr "Engels"
#: calendars/settings.py:251
msgid "French"
msgstr "Frans"
#: calendars/settings.py:252
msgid "German"
msgstr "Duits"
#: calendars/settings.py:253
msgid "Dutch"
msgstr "Nederlands"

View File

@@ -28,10 +28,11 @@ dependencies = [
"Brotli==1.2.0",
"dj-database-url==3.0.1",
"caldav==2.2.3",
"celery[redis]==5.6.0",
"dramatiq[redis]==1.17.1",
"django==5.2.9",
"django-celery-beat==2.8.1",
"django-dramatiq==0.12.0",
"django-configurations==2.5.1",
"django-fernet-encrypted-fields>=0.2",
"django-cors-headers==4.9.0",
"django-countries==8.2.0",
"django-filter==25.2",
@@ -138,6 +139,9 @@ addopts = [
"term-missing",
# Allow test files to have the same name in different directories.
"--import-mode=importlib",
# Group CalDAV E2E tests on a single worker to avoid concurrent
# access issues with the shared SabreDAV server.
"--dist=loadgroup",
]
python_files = [
"test_*.py",

110
src/backend/uv.lock generated
View File

@@ -101,13 +101,13 @@ source = { editable = "." }
dependencies = [
{ name = "brotli" },
{ name = "caldav" },
{ name = "celery", extra = ["redis"] },
{ name = "dj-database-url" },
{ name = "django" },
{ name = "django-celery-beat" },
{ name = "django-configurations" },
{ name = "django-cors-headers" },
{ name = "django-countries" },
{ name = "django-dramatiq" },
{ name = "django-fernet-encrypted-fields" },
{ name = "django-filter" },
{ name = "django-lasuite", extra = ["all"] },
{ name = "django-parler" },
@@ -115,6 +115,7 @@ dependencies = [
{ name = "django-timezone-field" },
{ name = "djangorestframework" },
{ name = "djangorestframework-api-key" },
{ name = "dramatiq", extra = ["redis"] },
{ name = "drf-spectacular" },
{ name = "drf-standardized-errors" },
{ name = "factory-boy" },
@@ -157,15 +158,15 @@ dev = [
requires-dist = [
{ name = "brotli", specifier = "==1.2.0" },
{ name = "caldav", specifier = "==2.2.3" },
{ name = "celery", extras = ["redis"], specifier = "==5.6.0" },
{ name = "dj-database-url", specifier = "==3.0.1" },
{ name = "django", specifier = "==5.2.9" },
{ name = "django-celery-beat", specifier = "==2.8.1" },
{ name = "django-configurations", specifier = "==2.5.1" },
{ name = "django-cors-headers", specifier = "==4.9.0" },
{ name = "django-countries", specifier = "==8.2.0" },
{ name = "django-debug-toolbar", marker = "extra == 'dev'", specifier = "==6.1.0" },
{ name = "django-dramatiq", specifier = "==0.12.0" },
{ name = "django-extensions", marker = "extra == 'dev'", specifier = "==4.1" },
{ name = "django-fernet-encrypted-fields", specifier = ">=0.2" },
{ name = "django-filter", specifier = "==25.2" },
{ name = "django-lasuite", extras = ["all"], specifier = "==0.0.21" },
{ name = "django-parler", specifier = "==2.3" },
@@ -173,6 +174,7 @@ requires-dist = [
{ name = "django-timezone-field", specifier = ">=5.1" },
{ name = "djangorestframework", specifier = "==3.16.1" },
{ name = "djangorestframework-api-key", specifier = "==3.1.0" },
{ name = "dramatiq", extras = ["redis"], specifier = "==1.17.1" },
{ name = "drf-spectacular", specifier = "==0.29.0" },
{ name = "drf-spectacular-sidecar", marker = "extra == 'dev'", specifier = "==2025.12.1" },
{ name = "drf-standardized-errors", specifier = "==0.15.0" },
@@ -227,11 +229,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/01/4e/53a125038d6a814491a0ae3457435c13cf8821eb602292cf9db37ce35f62/celery-5.6.0-py3-none-any.whl", hash = "sha256:33cf01477b175017fc8f22c5ee8a65157591043ba8ca78a443fe703aa910f581", size = 444561, upload-time = "2025-11-30T17:39:44.314Z" },
]
[package.optional-dependencies]
redis = [
{ name = "kombu", extra = ["redis"] },
]
[[package]]
name = "certifi"
version = "2025.11.12"
@@ -382,18 +379,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" },
]
[[package]]
name = "cron-descriptor"
version = "2.0.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7c/31/0b21d1599656b2ffa6043e51ca01041cd1c0f6dacf5a3e2b620ed120e7d8/cron_descriptor-2.0.6.tar.gz", hash = "sha256:e39d2848e1d8913cfb6e3452e701b5eec662ee18bea8cc5aa53ee1a7bb217157", size = 49456, upload-time = "2025-09-03T16:30:22.434Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/21/cc/361326a54ad92e2e12845ad15e335a4e14b8953665007fb514d3393dfb0f/cron_descriptor-2.0.6-py3-none-any.whl", hash = "sha256:3a1c0d837c0e5a32e415f821b36cf758eb92d510e6beff8fbfe4fa16573d93d6", size = 74446, upload-time = "2025-09-03T16:30:21.397Z" },
]
[[package]]
name = "cryptography"
version = "46.0.3"
@@ -479,23 +464,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/17/b0/7f42bfc38b8f19b78546d47147e083ed06e12fc29c42da95655e0962c6c2/django-5.2.9-py3-none-any.whl", hash = "sha256:3a4ea88a70370557ab1930b332fd2887a9f48654261cdffda663fef5976bb00a", size = 8290652, upload-time = "2025-12-02T14:01:03.485Z" },
]
[[package]]
name = "django-celery-beat"
version = "2.8.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "celery" },
{ name = "cron-descriptor" },
{ name = "django" },
{ name = "django-timezone-field" },
{ name = "python-crontab" },
{ name = "tzdata" },
]
sdist = { url = "https://files.pythonhosted.org/packages/aa/11/0c8b412869b4fda72828572068312b10aafe7ccef7b41af3633af31f9d4b/django_celery_beat-2.8.1.tar.gz", hash = "sha256:dfad0201c0ac50c91a34700ef8fa0a10ee098cc7f3375fe5debed79f2204f80a", size = 175802, upload-time = "2025-05-13T06:58:29.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/e5/3a0167044773dee989b498e9a851fc1663bea9ab879f1179f7b8a827ac10/django_celery_beat-2.8.1-py3-none-any.whl", hash = "sha256:da2b1c6939495c05a551717509d6e3b79444e114a027f7b77bf3727c2a39d171", size = 104833, upload-time = "2025-05-13T06:58:27.309Z" },
]
[[package]]
name = "django-configurations"
version = "2.5.1"
@@ -547,6 +515,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/72/685c978af45ad08257e2c69687a873eda6b6531c79b6e6091794c41c5ff6/django_debug_toolbar-6.1.0-py3-none-any.whl", hash = "sha256:e214dea4494087e7cebdcea84223819c5eb97f9de3110a3665ad673f0ba98413", size = 269069, upload-time = "2025-10-30T19:50:37.71Z" },
]
[[package]]
name = "django-dramatiq"
version = "0.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "dramatiq" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a1/32/cd8b1394be24a5a1c0fb213c3ee3e575414159d03f3d09c3699b09162dc6/django_dramatiq-0.12.0.tar.gz", hash = "sha256:d4f4a6ecccb104b10b2b743052a703b7749cd671d492a0f6f2a7e13e846923a8", size = 14262, upload-time = "2024-12-29T12:46:56.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cd/26/bc518333e9c62bc9c6c11994342b07933537adcb2dbdf6b4fb7ac0a9d88a/django_dramatiq-0.12.0-py3-none-any.whl", hash = "sha256:f9b8fff9510e7e780e993fcd3b8fd31c503c3caa09a535cbead284fb4b636262", size = 12082, upload-time = "2024-12-29T12:46:55.093Z" },
]
[[package]]
name = "django-extensions"
version = "4.1"
@@ -559,6 +540,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/64/96/d967ca440d6a8e3861120f51985d8e5aec79b9a8bdda16041206adfe7adc/django_extensions-4.1-py3-none-any.whl", hash = "sha256:0699a7af28f2523bf8db309a80278519362cd4b6e1fd0a8cd4bf063e1e023336", size = 232980, upload-time = "2025-04-11T01:15:37.701Z" },
]
[[package]]
name = "django-fernet-encrypted-fields"
version = "0.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1a/aa/529af3888215b8a660fc3897d6d63eaf1de9aa0699c633ca0ec483d4361c/django_fernet_encrypted_fields-0.3.1.tar.gz", hash = "sha256:5ed328c7f9cc7f2d452bb2e125f3ea2bea3563a259fa943e5a1c626175889a71", size = 5265, upload-time = "2025-11-10T08:39:57.398Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/52/7f/4e0b7ed8413fa58e7a77017342e8ab0e977d41cfc376ab9180ae75f216ec/django_fernet_encrypted_fields-0.3.1-py3-none-any.whl", hash = "sha256:3bd2abab02556dc6e15a58a61161ee6c5cdf45a50a8a52d9e035009eb54c6442", size = 5484, upload-time = "2025-11-10T08:39:55.866Z" },
]
[[package]]
name = "django-filter"
version = "25.2"
@@ -664,6 +658,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
]
[[package]]
name = "dramatiq"
version = "1.17.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "prometheus-client" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c6/7a/6792ddc64a77d22bfd97261b751a7a76cf2f9d62edc59aafb679ac48b77d/dramatiq-1.17.1.tar.gz", hash = "sha256:2675d2f57e0d82db3a7d2a60f1f9c536365349db78c7f8d80a63e4c54697647a", size = 99071, upload-time = "2024-10-26T05:09:28.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/36/925c7afd5db4f1a3f00676b9c3c58f31ff7ae29a347282d86c8d429280a5/dramatiq-1.17.1-py3-none-any.whl", hash = "sha256:951cdc334478dff8e5150bb02a6f7a947d215ee24b5aedaf738eff20e17913df", size = 120382, upload-time = "2024-10-26T05:09:26.436Z" },
]
[package.optional-dependencies]
redis = [
{ name = "redis" },
]
[[package]]
name = "drf-spectacular"
version = "0.29.0"
@@ -1024,11 +1035,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/14/d6/943cf84117cd9ddecf6e1707a3f712a49fc64abdb8ac31b19132871af1dd/kombu-5.6.1-py3-none-any.whl", hash = "sha256:b69e3f5527ec32fc5196028a36376501682973e9620d6175d1c3d4eaf7e95409", size = 214141, upload-time = "2025-11-25T11:07:31.54Z" },
]
[package.optional-dependencies]
redis = [
{ name = "redis" },
]
[[package]]
name = "lxml"
version = "6.0.2"
@@ -1201,6 +1207,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/d1/e4ed95fdd3ef13b78630280d9e9e240aeb65cc7c544ec57106149c3942fb/pprintpp-0.4.0-py2.py3-none-any.whl", hash = "sha256:b6b4dcdd0c0c0d75e4d7b2f21a9e933e5b2ce62b26e1a54537f9651ae5a5c01d", size = 16952, upload-time = "2018-07-01T01:42:36.496Z" },
]
[[package]]
name = "prometheus-client"
version = "0.24.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f0/58/a794d23feb6b00fc0c72787d7e87d872a6730dd9ed7c7b3e954637d8f280/prometheus_client-0.24.1.tar.gz", hash = "sha256:7e0ced7fbbd40f7b84962d5d2ab6f17ef88a72504dcf7c0b40737b43b2a461f9", size = 85616, upload-time = "2026-01-14T15:26:26.965Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/74/c3/24a2f845e3917201628ecaba4f18bab4d18a337834c1df2a159ee9d22a42/prometheus_client-0.24.1-py3-none-any.whl", hash = "sha256:150db128af71a5c2482b36e588fc8a6b95e498750da4b17065947c16070f4055", size = 64057, upload-time = "2026-01-14T15:26:24.42Z" },
]
[[package]]
name = "prompt-toolkit"
version = "3.0.52"
@@ -1414,15 +1429,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" },
]
[[package]]
name = "python-crontab"
version = "3.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/99/7f/c54fb7e70b59844526aa4ae321e927a167678660ab51dda979955eafb89a/python_crontab-3.3.0.tar.gz", hash = "sha256:007c8aee68dddf3e04ec4dce0fac124b93bd68be7470fc95d2a9617a15de291b", size = 57626, upload-time = "2025-07-13T20:05:35.535Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/47/42/bb4afa5b088f64092036221843fc989b7db9d9d302494c1f8b024ee78a46/python_crontab-3.3.0-py3-none-any.whl", hash = "sha256:739a778b1a771379b75654e53fd4df58e5c63a9279a63b5dfe44c0fcc3ee7884", size = 27533, upload-time = "2025-07-13T20:05:34.266Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"

204
src/backend/worker.py Normal file
View File

@@ -0,0 +1,204 @@
#!/usr/bin/env python
# pylint: disable=import-outside-toplevel
"""
Background task worker with sensible queue defaults.
Usage:
python worker.py # Process all queues
python worker.py --queues=import,default # Process only specific queues
python worker.py --exclude=sync # Process all except sync
python worker.py --concurrency=4 # Set worker concurrency
python worker.py -v 2 # Verbose logging
Queue priority order (highest to lowest):
1. default - General / high-priority tasks
2. import - File import processing
3. sync - Background sync tasks
"""
import argparse
import logging
import multiprocessing
import os
import sys
# Workaround for Dramatiq + Python 3.14: forkserver (the new default) breaks
# Dramatiq's Canteen shared-memory mechanism, causing worker processes to never
# consume messages. See https://github.com/Bogdanp/dramatiq/issues/701
# Must be set before dramatiq.cli.main() spawns worker processes.
multiprocessing.set_start_method("fork", force=True)
# Setup Django before importing the task runner
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "calendars.settings")
os.environ.setdefault("DJANGO_CONFIGURATION", "Development")
# Override $APP if set by the host (e.g. Scalingo)
os.environ.pop("APP", None)
from configurations.importer import install # pylint: disable=wrong-import-position
install(check_options=True)
import django # pylint: disable=wrong-import-position
django.setup()
# Queue definitions in priority order
ALL_QUEUES = ["default", "import", "sync"]
DEFAULT_QUEUES = ALL_QUEUES
def get_default_concurrency():
"""Get default concurrency from environment variables."""
env_value = os.environ.get("WORKER_CONCURRENCY")
if env_value:
try:
return int(env_value)
except ValueError:
return None
return None
def discover_tasks_modules():
"""Discover task modules the same way django_dramatiq does."""
import importlib # noqa: PLC0415 # pylint: disable=wrong-import-position
from django.apps import ( # noqa: PLC0415 # pylint: disable=wrong-import-position
apps,
)
from django.conf import ( # noqa: PLC0415 # pylint: disable=wrong-import-position
settings,
)
from django.utils.module_loading import ( # noqa: PLC0415 # pylint: disable=wrong-import-position
module_has_submodule,
)
task_module_names = settings.DRAMATIQ_AUTODISCOVER_MODULES
modules = ["django_dramatiq.setup"]
for conf in apps.get_app_configs():
if conf.name == "django_dramatiq":
module = conf.name + ".tasks"
importlib.import_module(module)
logging.getLogger(__name__).info("Discovered tasks module: %r", module)
modules.append(module)
else:
for task_module in task_module_names:
if module_has_submodule(conf.module, task_module):
module = conf.name + "." + task_module
importlib.import_module(module)
logging.getLogger(__name__).info(
"Discovered tasks module: %r", module
)
modules.append(module)
return modules
def parse_args():
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(
description="Start a background task worker.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument(
"--queues",
"-Q",
type=str,
default=None,
help=(
"Comma-separated list of queues to process. "
f"Default: {','.join(DEFAULT_QUEUES)}"
),
)
parser.add_argument(
"--exclude",
"-X",
type=str,
default=None,
help="Comma-separated list of queues to exclude.",
)
parser.add_argument(
"--concurrency",
"-c",
type=int,
default=get_default_concurrency(),
help="Number of worker processes. Default: WORKER_CONCURRENCY env var.",
)
parser.add_argument(
"--verbosity",
"-v",
type=int,
default=1,
help="Verbosity level (0=minimal, 1=normal, 2=verbose). Default: 1",
)
return parser.parse_args()
def main():
"""Start the background task worker."""
logger = logging.getLogger(__name__)
args = parse_args()
# Determine which queues to process
if args.queues:
queues = [q.strip() for q in args.queues.split(",")]
invalid = set(queues) - set(ALL_QUEUES)
if invalid:
sys.stderr.write(f"Error: Unknown queues: {', '.join(invalid)}\n")
sys.stderr.write(f"Valid queues are: {', '.join(ALL_QUEUES)}\n")
sys.exit(1)
else:
queues = DEFAULT_QUEUES.copy()
# Apply exclusions
if args.exclude:
exclude = [q.strip() for q in args.exclude.split(",")]
invalid_exclude = set(exclude) - set(ALL_QUEUES)
if invalid_exclude:
sys.stderr.write(
f"Error: Unknown queues to exclude: {', '.join(invalid_exclude)}\n"
)
sys.stderr.write(f"Valid queues are: {', '.join(ALL_QUEUES)}\n")
sys.exit(1)
queues = [q for q in queues if q not in exclude]
if not queues:
sys.stderr.write("Error: No queues to process after exclusions.\n")
sys.exit(1)
# Discover task modules
tasks_modules = discover_tasks_modules()
# Build dramatiq CLI arguments and call main() directly.
# This avoids rundramatiq's os.execvp which replaces the process and
# discards our multiprocessing.set_start_method("fork") workaround.
dramatiq_args = [
"dramatiq",
"--path",
".",
"--processes",
str(args.concurrency or 4),
"--threads",
"1",
"--worker-shutdown-timeout",
"600000",
]
if args.verbosity > 1:
dramatiq_args.append("-v")
dramatiq_args.extend(tasks_modules)
dramatiq_args.extend(["--queues", *queues])
logger.info("Starting worker with queues: %s", ", ".join(queues))
import dramatiq.cli # noqa: PLC0415 # pylint: disable=wrong-import-position
sys.argv = dramatiq_args
dramatiq.cli.main()
if __name__ == "__main__":
main()

61
src/caldav/Dockerfile Normal file
View File

@@ -0,0 +1,61 @@
# sabre/dav CalDAV Server
# Based on Debian with Apache and PHP
FROM php:8.2-apache-bookworm
ENV DEBIAN_FRONTEND=noninteractive
# Install dependencies
RUN apt-get update && apt-get install -y \
libpq-dev \
postgresql-client \
git \
unzip \
&& rm -rf /var/lib/apt/lists/*
# Install PHP extensions
RUN docker-php-ext-install pdo pdo_pgsql
# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Create application directory
WORKDIR /var/www/sabredav
# Copy composer files and install dependencies
COPY composer.json ./
RUN composer install --no-dev --optimize-autoloader --no-interaction
# Copy server configuration
COPY server.php ./
COPY sabredav.conf /etc/apache2/sites-available/sabredav.conf
COPY init-database.sh /usr/local/bin/init-database.sh
# Copy SQL schema files for database initialization
COPY sql/ ./sql/
# Copy custom principal backend
COPY src/ ./src/
# Enable Apache modules and site
RUN a2enmod rewrite headers \
&& a2dissite 000-default \
&& a2ensite sabredav \
&& chmod +x /usr/local/bin/init-database.sh
# Configure PHP error logging to stderr for Docker logs
# This ensures all error_log() calls and PHP errors are visible in docker logs
# display_errors = Off prevents errors from appearing in HTTP responses (security/UX)
# but errors are still logged to stderr (Docker logs) via log_errors = On
RUN echo "log_errors = On" >> /usr/local/etc/php/conf.d/error-logging.ini \
&& echo "error_log = /proc/self/fd/2" >> /usr/local/etc/php/conf.d/error-logging.ini \
&& echo "display_errors = Off" >> /usr/local/etc/php/conf.d/error-logging.ini \
&& echo "display_startup_errors = Off" >> /usr/local/etc/php/conf.d/error-logging.ini \
&& echo "memory_limit = 512M" >> /usr/local/etc/php/conf.d/error-logging.ini
# Set permissions
RUN chown -R www-data:www-data /var/www/sabredav \
&& chmod -R 755 /var/www/sabredav
EXPOSE 80
CMD ["apache2-foreground"]

22
src/caldav/composer.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "calendars/sabredav-server",
"description": "sabre/dav CalDAV server for calendars",
"type": "project",
"repositories": [
{
"type": "vcs",
"url": "https://github.com/sylvinus/dav"
}
],
"require": {
"php": ">=8.1",
"sabre/dav": "dev-master#1000fc028469c240fe13459e36648959f1519d09",
"ext-pdo": "*",
"ext-pdo_pgsql": "*"
},
"autoload": {
"psr-4": {
"Calendars\\SabreDav\\": "src/"
}
}
}

87
src/caldav/init-database.sh Executable file
View File

@@ -0,0 +1,87 @@
#!/bin/bash
###
# Initialize sabre/dav database schema in PostgreSQL
# This script creates all necessary tables for sabre/dav to work
###
set -e
if [ -z ${PGHOST+x} ]; then
echo "PGHOST must be set"
exit 1
fi
if [ -z ${PGDATABASE+x} ]; then
echo "PGDATABASE must be set"
exit 1
fi
if [ -z ${PGUSER+x} ]; then
echo "PGUSER must be set"
exit 1
fi
if [ -z ${PGPASSWORD+x} ]; then
echo "PGPASSWORD must be set"
exit 1
fi
export PGHOST
export PGPORT=${PGPORT:-5432}
export PGDATABASE
export PGUSER
export PGPASSWORD
# Wait for PostgreSQL to be ready
retries=30
until pg_isready -q -h "$PGHOST" -p "$PGPORT" -U "$PGUSER"; do
[[ retries -eq 0 ]] && echo "Could not connect to Postgres" && exit 1
echo "Waiting for Postgres to be available..."
retries=$((retries-1))
sleep 1
done
echo "PostgreSQL is ready. Initializing sabre/dav database schema..."
# SQL files directory (configurable for Scalingo, defaults to Docker path)
SQL_DIR="${SQL_DIR:-/var/www/sabredav/sql}"
# Check if tables already exist
TABLES_EXIST=$(psql -tAc "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_name IN ('users', 'principals', 'calendars')" 2>/dev/null || echo "0")
if [ "$TABLES_EXIST" -gt "0" ]; then
echo "sabre/dav tables already exist, skipping table creation"
exit 0
fi
# Create tables
echo "Creating sabre/dav tables..."
if [ -f "$SQL_DIR/pgsql.users.sql" ]; then
psql -f "$SQL_DIR/pgsql.users.sql"
echo "Created users table"
fi
if [ -f "$SQL_DIR/pgsql.principals.sql" ]; then
psql -f "$SQL_DIR/pgsql.principals.sql"
echo "Created principals table"
fi
if [ -f "$SQL_DIR/pgsql.calendars.sql" ]; then
psql -f "$SQL_DIR/pgsql.calendars.sql"
echo "Created calendars table"
fi
if [ -f "$SQL_DIR/pgsql.addressbooks.sql" ]; then
psql -f "$SQL_DIR/pgsql.addressbooks.sql"
echo "Created addressbooks table"
fi
if [ -f "$SQL_DIR/pgsql.locks.sql" ]; then
psql -f "$SQL_DIR/pgsql.locks.sql"
echo "Created locks table"
fi
if [ -f "$SQL_DIR/pgsql.propertystorage.sql" ]; then
psql -f "$SQL_DIR/pgsql.propertystorage.sql"
echo "Created propertystorage table"
fi
echo "sabre/dav database schema initialized successfully!"

25
src/caldav/php-fpm.conf Normal file
View File

@@ -0,0 +1,25 @@
[global]
daemonize = no
error_log = /dev/stderr
pid = /tmp/php-fpm.pid
[www]
listen = /tmp/php-fpm.sock
listen.mode = 0660
; When running as non-root, user/group settings are ignored
user = www-data
group = www-data
pm = dynamic
pm.max_children = 5
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3
; Pass all env vars to PHP workers (for PGHOST, CALDAV_* keys, etc.)
clear_env = no
; Logging
catch_workers_output = yes
decorate_workers_output = no

23
src/caldav/sabredav.conf Normal file
View File

@@ -0,0 +1,23 @@
<VirtualHost *:80>
ServerName localhost
DocumentRoot /var/www/sabredav
<Directory /var/www/sabredav>
AllowOverride All
Require all granted
Options -Indexes +FollowSymLinks
</Directory>
# Rewrite rules for CalDAV
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ server.php [QSA,L]
# Well-known CalDAV discovery
RewriteRule ^\.well-known/caldav / [R=301,L]
# Write errors to stderr for Docker logs
ErrorLog /proc/self/fd/2
# CustomLog /proc/self/fd/1 combined
</VirtualHost>

192
src/caldav/server.php Normal file
View File

@@ -0,0 +1,192 @@
<?php
/**
* sabre/dav CalDAV Server
* Configured to use PostgreSQL backend and custom header-based authentication
*/
use Sabre\DAV\Auth;
use Sabre\DAVACL;
use Sabre\CalDAV;
use Sabre\CardDAV;
use Sabre\DAV;
use Calendars\SabreDav\AutoCreatePrincipalBackend;
use Calendars\SabreDav\HttpCallbackIMipPlugin;
use Calendars\SabreDav\ApiKeyAuthBackend;
use Calendars\SabreDav\CalendarSanitizerPlugin;
use Calendars\SabreDav\AttendeeNormalizerPlugin;
use Calendars\SabreDav\InternalApiPlugin;
use Calendars\SabreDav\ResourceAutoSchedulePlugin;
use Calendars\SabreDav\ResourceMkCalendarBlockPlugin;
use Calendars\SabreDav\CalendarsRoot;
use Calendars\SabreDav\CustomCalDAVPlugin;
use Calendars\SabreDav\PrincipalsRoot;
// Composer autoloader
require_once __DIR__ . '/vendor/autoload.php';
// Get base URI from environment variable (set by compose.yaml)
// This ensures sabre/dav generates URLs with the correct proxy path
$baseUri = getenv('CALDAV_BASE_URI') ?: '/';
// Database connection from environment variables
$dbHost = getenv('PGHOST') ?: 'postgresql';
$dbPort = getenv('PGPORT') ?: '5432';
$dbName = getenv('PGDATABASE') ?: 'calendars';
$dbUser = getenv('PGUSER') ?: 'pgroot';
$dbPass = getenv('PGPASSWORD') ?: 'pass';
// Create PDO connection
$pdo = new PDO(
"pgsql:host={$dbHost};port={$dbPort};dbname={$dbName}",
$dbUser,
$dbPass,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
// Create custom authentication backend
// Requires API key authentication and X-Forwarded-User header
$apiKey = getenv('CALDAV_OUTBOUND_API_KEY');
if (!$apiKey) {
error_log("[sabre/dav] CALDAV_OUTBOUND_API_KEY environment variable is required");
exit(1);
}
$authBackend = new ApiKeyAuthBackend($apiKey);
// Create authentication plugin
$authPlugin = new Auth\Plugin($authBackend);
// Create CalDAV backend
$caldavBackend = new CalDAV\Backend\PDO($pdo);
// Create CardDAV backend (optional, for future use)
$carddavBackend = new CardDAV\Backend\PDO($pdo);
// Create principal backend with auto-creation support
$principalBackend = new AutoCreatePrincipalBackend($pdo);
// Create directory tree
// Principal collections: principals/users/ and principals/resources/
// Calendar collections: calendars/users/ and calendars/resources/
$nodes = [
new PrincipalsRoot($principalBackend),
new CalendarsRoot($principalBackend, $caldavBackend),
new CardDAV\AddressBookRoot($principalBackend, $carddavBackend),
];
// Create server
$server = new DAV\Server($nodes);
$server->setBaseUri($baseUri);
// Give the principal backend a reference to the server
// so it can read X-CalDAV-Organization from the HTTP request
$principalBackend->setServer($server);
// Add plugins
$server->addPlugin($authPlugin);
$server->addPlugin(new CustomCalDAVPlugin());
$server->addPlugin(new CardDAV\Plugin());
$server->addPlugin(new DAVACL\Plugin());
$server->addPlugin(new DAV\Browser\Plugin());
// Add ICS export plugin for iCal subscription URLs
// Allows exporting calendars as .ics files via ?export query parameter
// See https://sabre.io/dav/ics-export-plugin/
$server->addPlugin(new CalDAV\ICSExportPlugin());
// Add sharing support
// See https://sabre.io/dav/caldav-sharing/
// Note: Order matters! CalDAV\SharingPlugin must come after DAV\Sharing\Plugin
$server->addPlugin(new DAV\Sharing\Plugin());
$server->addPlugin(new CalDAV\SharingPlugin());
// Debug logging for POST requests - commented out to avoid PII in logs
// Uncomment for local debugging only, never in production.
// $server->on('method:POST', function($request) {
// $contentType = $request->getHeader('Content-Type');
// $path = $request->getPath();
// $body = $request->getBodyAsString();
// error_log("[sabre/dav] POST request received:");
// error_log("[sabre/dav] Path: " . $path);
// error_log("[sabre/dav] Content-Type: " . $contentType);
// error_log("[sabre/dav] Body: " . substr($body, 0, 1000));
// $request->setBody($body);
// }, 50);
//
// $server->on('afterMethod:POST', function($request, $response) {
// error_log("[sabre/dav] POST response status: " . $response->getStatus());
// $body = $response->getBodyAsString();
// if ($body) {
// error_log("[sabre/dav] POST response body: " . substr($body, 0, 500));
// }
// }, 50);
// Log unhandled exceptions
$server->on('exception', function($e) {
error_log("[sabre/dav] Exception: " . get_class($e) . " - " . $e->getMessage());
error_log("[sabre/dav] Exception trace: " . $e->getTraceAsString());
}, 50);
// Add calendar sanitizer plugin (priority 85, runs before all other calendar plugins)
// Strips inline binary attachments (Outlook/Exchange base64 images) and truncates
// oversized DESCRIPTION fields. Applies to ALL CalDAV writes (PUT from any client).
$sanitizerStripAttachments = getenv('SANITIZER_STRIP_BINARY_ATTACHMENTS') !== 'false';
$sanitizerMaxDescBytes = getenv('SANITIZER_MAX_DESCRIPTION_BYTES');
$sanitizerMaxDescBytes = ($sanitizerMaxDescBytes !== false) ? (int)$sanitizerMaxDescBytes : 102400;
$sanitizerMaxResourceSize = getenv('SANITIZER_MAX_RESOURCE_SIZE');
$sanitizerMaxResourceSize = ($sanitizerMaxResourceSize !== false) ? (int)$sanitizerMaxResourceSize : 1048576;
$server->addPlugin(new CalendarSanitizerPlugin(
$sanitizerStripAttachments,
$sanitizerMaxDescBytes,
$sanitizerMaxResourceSize
));
// Add attendee normalizer plugin to fix duplicate attendees issue
// This plugin normalizes attendee emails (lowercase) and deduplicates them
// when processing calendar objects, fixing issues with REPLY handling
$server->addPlugin(new AttendeeNormalizerPlugin());
// Add internal API plugin for resource provisioning and ICS import
// Gated by X-Internal-Api-Key header (separate from X-Api-Key used by proxy)
$internalApiKey = getenv('CALDAV_INTERNAL_API_KEY') ?: $apiKey;
$server->addPlugin(new InternalApiPlugin($pdo, $caldavBackend, $internalApiKey));
// Add custom IMipPlugin that forwards scheduling messages via HTTP callback
// This MUST be added BEFORE the Schedule\Plugin so that Schedule\Plugin finds it
// The callback URL can be provided per-request via X-CalDAV-Callback-URL header
// or via CALDAV_CALLBACK_URL environment variable as fallback
$callbackApiKey = getenv('CALDAV_INBOUND_API_KEY');
if (!$callbackApiKey) {
error_log("[sabre/dav] CALDAV_INBOUND_API_KEY environment variable is required for scheduling callback");
exit(1);
}
$defaultCallbackUrl = getenv('CALDAV_CALLBACK_URL') ?: null;
if ($defaultCallbackUrl) {
error_log("[sabre/dav] Using default callback URL for scheduling: {$defaultCallbackUrl}");
}
$imipPlugin = new HttpCallbackIMipPlugin($callbackApiKey, $defaultCallbackUrl);
$server->addPlugin($imipPlugin);
// Add CalDAV scheduling support
// See https://sabre.io/dav/scheduling/
// The Schedule\Plugin will automatically find and use the IMipPlugin we just added
// It looks for plugins that implement CalDAV\Schedule\IMipPlugin interface
$schedulePlugin = new CalDAV\Schedule\Plugin();
$server->addPlugin($schedulePlugin);
// Add resource auto-scheduling plugin
// Handles automatic accept/decline for resource principals based on availability
$server->addPlugin(new ResourceAutoSchedulePlugin($pdo, $caldavBackend));
// Block MKCALENDAR on resource principals (each resource has exactly one calendar)
$server->addPlugin(new ResourceMkCalendarBlockPlugin());
// Add property storage plugin for custom properties (resource metadata, etc.)
$server->addPlugin(new DAV\PropertyStorage\Plugin(
new DAV\PropertyStorage\Backend\PDO($pdo)
));
// Start server
$server->start();

View File

@@ -0,0 +1,44 @@
CREATE TABLE addressbooks (
id SERIAL NOT NULL,
principaluri VARCHAR(255),
displayname VARCHAR(255),
uri VARCHAR(200),
description TEXT,
synctoken INTEGER NOT NULL DEFAULT 1
);
ALTER TABLE ONLY addressbooks
ADD CONSTRAINT addressbooks_pkey PRIMARY KEY (id);
CREATE UNIQUE INDEX addressbooks_ukey
ON addressbooks USING btree (principaluri, uri);
CREATE TABLE cards (
id SERIAL NOT NULL,
addressbookid INTEGER NOT NULL,
carddata BYTEA,
uri VARCHAR(200),
lastmodified BIGINT,
etag VARCHAR(32),
size INTEGER NOT NULL
);
ALTER TABLE ONLY cards
ADD CONSTRAINT cards_pkey PRIMARY KEY (id);
CREATE UNIQUE INDEX cards_ukey
ON cards USING btree (addressbookid, uri);
CREATE TABLE addressbookchanges (
id SERIAL NOT NULL,
uri VARCHAR(200) NOT NULL,
synctoken INTEGER NOT NULL,
addressbookid INTEGER NOT NULL,
operation SMALLINT NOT NULL
);
ALTER TABLE ONLY addressbookchanges
ADD CONSTRAINT addressbookchanges_pkey PRIMARY KEY (id);
CREATE INDEX addressbookchanges_addressbookid_synctoken_ix
ON addressbookchanges USING btree (addressbookid, synctoken);

View File

@@ -0,0 +1,117 @@
CREATE TABLE calendarobjects (
id SERIAL NOT NULL,
calendardata BYTEA,
uri VARCHAR(200),
calendarid INTEGER NOT NULL,
lastmodified BIGINT,
etag VARCHAR(32),
size INTEGER NOT NULL,
componenttype VARCHAR(8),
firstoccurence BIGINT,
lastoccurence BIGINT,
uid VARCHAR(200)
);
ALTER TABLE ONLY calendarobjects
ADD CONSTRAINT calendarobjects_pkey PRIMARY KEY (id);
CREATE UNIQUE INDEX calendarobjects_ukey
ON calendarobjects USING btree (calendarid, uri);
CREATE TABLE calendars (
id SERIAL NOT NULL,
synctoken INTEGER NOT NULL DEFAULT 1,
components VARCHAR(21)
);
ALTER TABLE ONLY calendars
ADD CONSTRAINT calendars_pkey PRIMARY KEY (id);
CREATE TABLE calendarinstances (
id SERIAL NOT NULL,
calendarid INTEGER NOT NULL,
principaluri VARCHAR(255),
access SMALLINT NOT NULL DEFAULT '1', -- '1 = owner, 2 = read, 3 = readwrite'
displayname VARCHAR(255),
uri VARCHAR(200),
description TEXT,
calendarorder INTEGER NOT NULL DEFAULT 0,
calendarcolor VARCHAR(10),
timezone TEXT,
transparent SMALLINT NOT NULL DEFAULT '0',
share_href VARCHAR(255),
share_displayname VARCHAR(255),
share_invitestatus SMALLINT NOT NULL DEFAULT '2' -- '1 = noresponse, 2 = accepted, 3 = declined, 4 = invalid'
);
ALTER TABLE ONLY calendarinstances
ADD CONSTRAINT calendarinstances_pkey PRIMARY KEY (id);
CREATE UNIQUE INDEX calendarinstances_principaluri_uri
ON calendarinstances USING btree (principaluri, uri);
CREATE UNIQUE INDEX calendarinstances_principaluri_calendarid
ON calendarinstances USING btree (principaluri, calendarid);
-- Note: The original SabreDAV schema has a unique index on (principaluri, share_href),
-- but this prevents sharing multiple calendars with the same user.
-- We use a non-unique index instead for query performance.
CREATE INDEX calendarinstances_principaluri_share_href
ON calendarinstances USING btree (principaluri, share_href);
CREATE TABLE calendarsubscriptions (
id SERIAL NOT NULL,
uri VARCHAR(200) NOT NULL,
principaluri VARCHAR(255) NOT NULL,
source TEXT,
displayname VARCHAR(255),
refreshrate VARCHAR(10),
calendarorder INTEGER NOT NULL DEFAULT 0,
calendarcolor VARCHAR(10),
striptodos SMALLINT NULL,
stripalarms SMALLINT NULL,
stripattachments SMALLINT NULL,
lastmodified BIGINT
);
ALTER TABLE ONLY calendarsubscriptions
ADD CONSTRAINT calendarsubscriptions_pkey PRIMARY KEY (id);
CREATE UNIQUE INDEX calendarsubscriptions_ukey
ON calendarsubscriptions USING btree (principaluri, uri);
CREATE TABLE calendarchanges (
id SERIAL NOT NULL,
uri VARCHAR(200) NOT NULL,
synctoken INTEGER NOT NULL,
calendarid INTEGER NOT NULL,
operation SMALLINT NOT NULL DEFAULT 0
);
ALTER TABLE ONLY calendarchanges
ADD CONSTRAINT calendarchanges_pkey PRIMARY KEY (id);
CREATE INDEX calendarchanges_calendarid_synctoken_ix
ON calendarchanges USING btree (calendarid, synctoken);
CREATE TABLE schedulingobjects (
id SERIAL NOT NULL,
principaluri VARCHAR(255),
calendardata BYTEA,
uri VARCHAR(200),
lastmodified BIGINT,
etag VARCHAR(32),
size INTEGER NOT NULL
);
ALTER TABLE ONLY schedulingobjects
ADD CONSTRAINT schedulingobjects_pkey PRIMARY KEY (id);
CREATE UNIQUE INDEX schedulingobjects_ukey
ON schedulingobjects USING btree (principaluri, uri);
CREATE INDEX schedulingobjects_principaluri_ix
ON schedulingobjects USING btree (principaluri);

View File

@@ -0,0 +1,19 @@
CREATE TABLE locks (
id SERIAL NOT NULL,
owner VARCHAR(100),
timeout BIGINT,
created BIGINT,
token VARCHAR(100),
scope SMALLINT,
depth SMALLINT,
uri TEXT
);
ALTER TABLE ONLY locks
ADD CONSTRAINT locks_pkey PRIMARY KEY (id);
CREATE INDEX locks_token_ix
ON locks USING btree (token);
CREATE INDEX locks_uri_ix
ON locks USING btree (uri);

View File

@@ -0,0 +1,40 @@
CREATE TABLE principals (
id SERIAL NOT NULL,
uri VARCHAR(200) NOT NULL,
email VARCHAR(255),
displayname VARCHAR(255),
calendar_user_type VARCHAR(20) DEFAULT 'INDIVIDUAL',
org_id VARCHAR(200)
);
ALTER TABLE ONLY principals
ADD CONSTRAINT principals_pkey PRIMARY KEY (id);
CREATE UNIQUE INDEX principals_ukey
ON principals USING btree (uri);
CREATE INDEX idx_principals_org_id
ON principals (org_id)
WHERE org_id IS NOT NULL;
CREATE INDEX idx_principals_email
ON principals (email);
CREATE INDEX idx_principals_cutype
ON principals (calendar_user_type)
WHERE calendar_user_type IN ('ROOM', 'RESOURCE');
CREATE TABLE groupmembers (
id SERIAL NOT NULL,
principal_id INTEGER NOT NULL,
member_id INTEGER NOT NULL
);
ALTER TABLE ONLY groupmembers
ADD CONSTRAINT groupmembers_pkey PRIMARY KEY (id);
CREATE UNIQUE INDEX groupmembers_ukey
ON groupmembers USING btree (principal_id, member_id);
-- No seed data: principals are created via AutoCreatePrincipalBackend
-- (for users on first access) or InternalApiPlugin (for resources).

View File

@@ -0,0 +1,13 @@
CREATE TABLE propertystorage (
id SERIAL NOT NULL,
path VARCHAR(1024) NOT NULL,
name VARCHAR(100) NOT NULL,
valuetype INT,
value BYTEA
);
ALTER TABLE ONLY propertystorage
ADD CONSTRAINT propertystorage_pkey PRIMARY KEY (id);
CREATE UNIQUE INDEX propertystorage_ukey
ON propertystorage (path, name);

View File

@@ -0,0 +1,14 @@
CREATE TABLE users (
id SERIAL NOT NULL,
username VARCHAR(50),
digesta1 VARCHAR(32)
);
ALTER TABLE ONLY users
ADD CONSTRAINT users_pkey PRIMARY KEY (id);
CREATE UNIQUE INDEX users_ukey
ON users USING btree (username);
INSERT INTO users (username,digesta1) VALUES
('admin', '87fd274b7b6c01e48d7c2f965da8ddf7');

View File

@@ -0,0 +1,91 @@
<?php
/**
* Custom authentication backend that supports API key and header-based authentication.
*
* This backend authenticates users via:
* - API key authentication: X-Api-Key header and X-Forwarded-User header
*
* This allows Django to authenticate with CalDAV server using an API key.
*/
namespace Calendars\SabreDav;
use Sabre\DAV\Auth\Backend\BackendInterface;
use Sabre\HTTP\RequestInterface;
use Sabre\HTTP\ResponseInterface;
class ApiKeyAuthBackend implements BackendInterface
{
/**
* Expected API key for outbound authentication (from Django to CalDAV)
* @var string
*/
private $apiKey;
/**
* Constructor
*
* @param string $apiKey The expected API key for authentication
*/
public function __construct($apiKey)
{
$this->apiKey = $apiKey;
}
/**
* When this method is called, the backend must check if authentication was
* successful.
*
* The returned value must be one of the following:
*
* [true, "principals/username"] - authentication was successful, and a principal url is returned.
* [false, "reason for failure"] - authentication failed, reason is optional
* [null, null] - The backend cannot determine. The next backend will be queried.
*
* @param RequestInterface $request
* @param ResponseInterface $response
* @return array
*/
public function check(RequestInterface $request, ResponseInterface $response)
{
// Get user from X-Forwarded-User header (required)
$xForwardedUser = $request->getHeader('X-Forwarded-User');
if (!$xForwardedUser) {
return [false, 'X-Forwarded-User header is required'];
}
// API key is required
$apiKeyHeader = $request->getHeader('X-Api-Key');
if (!$apiKeyHeader) {
return [false, 'X-Api-Key header is required'];
}
// Validate API key
if (!hash_equals($this->apiKey, $apiKeyHeader)) {
return [false, 'Invalid API key'];
}
// Validate X-Forwarded-User to prevent path traversal
if (preg_match('/[\/\\\\]|\.\./', $xForwardedUser)) {
throw new \Sabre\DAV\Exception\NotAuthenticated('Invalid X-Forwarded-User header value');
}
// Authentication successful
return [true, 'principals/users/' . $xForwardedUser];
}
/**
* This method is called when a user could not be authenticated.
*
* This gives us a chance to set up authentication challenges (for example HTTP auth).
*
* @param RequestInterface $request
* @param ResponseInterface $response
* @return void
*/
public function challenge(RequestInterface $request, ResponseInterface $response)
{
// We don't use HTTP Basic/Digest auth, so no challenge needed
// The error message from check() will be returned
}
}

View File

@@ -0,0 +1,281 @@
<?php
/**
* AttendeeNormalizerPlugin - Normalizes and deduplicates attendees in CalDAV events.
*
* This plugin fixes a common issue with CalDAV scheduling where REPLY processing
* can create duplicate attendees due to email case sensitivity or format differences.
*
* The plugin:
* 1. Normalizes all attendee emails to lowercase
* 2. Deduplicates attendees by email, keeping the one with the most "advanced" status
*
* Status priority (most to least advanced): ACCEPTED > TENTATIVE > DECLINED > NEEDS-ACTION
*/
namespace Calendars\SabreDav;
use Sabre\DAV\Server;
use Sabre\DAV\ServerPlugin;
use Sabre\VObject\Reader;
use Sabre\VObject\Component\VCalendar;
class AttendeeNormalizerPlugin extends ServerPlugin
{
/**
* Reference to the DAV server instance
* @var Server
*/
protected $server;
/**
* Status priority map (higher = more definitive response)
* @var array
*/
private const STATUS_PRIORITY = [
'ACCEPTED' => 4,
'TENTATIVE' => 3,
'DECLINED' => 2,
'NEEDS-ACTION' => 1,
];
/**
* Returns a plugin name.
*
* @return string
*/
public function getPluginName()
{
return 'attendee-normalizer';
}
/**
* Initialize the plugin.
*
* @param Server $server
* @return void
*/
public function initialize(Server $server)
{
$this->server = $server;
// Hook into calendar object creation and updates
// Priority 90 to run before most other plugins but after authentication
// Note: beforeCreateFile and beforeWriteContent have different signatures
$server->on('beforeCreateFile', [$this, 'beforeCreateCalendarObject'], 90);
$server->on('beforeWriteContent', [$this, 'beforeUpdateCalendarObject'], 90);
}
/**
* Called before a calendar object is created.
* Signature: ($path, &$data, \Sabre\DAV\ICollection $parent, &$modified)
*
* @param string $path The path to the file
* @param resource|string $data The data being written
* @param \Sabre\DAV\ICollection $parentNode The parent collection
* @param bool $modified Whether the data was modified
* @return void
*/
public function beforeCreateCalendarObject($path, &$data, $parentNode = null, &$modified = false)
{
$this->processCalendarData($path, $data, $modified);
}
/**
* Called before a calendar object is updated.
* Signature: ($path, \Sabre\DAV\IFile $node, &$data, &$modified)
*
* @param string $path The path to the file
* @param \Sabre\DAV\IFile $node The existing file node
* @param resource|string $data The data being written
* @param bool $modified Whether the data was modified
* @return void
*/
public function beforeUpdateCalendarObject($path, $node, &$data, &$modified = false)
{
$this->processCalendarData($path, $data, $modified);
}
/**
* Process calendar data to normalize and deduplicate attendees.
*
* @param string $path The path to the file
* @param resource|string &$data The data being written (modified in place)
* @param bool &$modified Whether the data was modified
* @return void
*/
private function processCalendarData($path, &$data, &$modified)
{
// Only process .ics files in calendar collections
if (!preg_match('/\.ics$/i', $path)) {
return;
}
try {
// Get the data as string
if (is_resource($data)) {
$dataStr = stream_get_contents($data);
rewind($data);
} else {
$dataStr = $data;
}
// Parse the iCalendar data
$vcalendar = Reader::read($dataStr);
if (!$vcalendar instanceof VCalendar) {
return;
}
$wasModified = false;
// Process all VEVENT components
foreach ($vcalendar->VEVENT as $vevent) {
if ($this->normalizeAndDeduplicateAttendees($vevent)) {
$wasModified = true;
}
}
// If we made changes, update the data
if ($wasModified) {
$newData = $vcalendar->serialize();
$data = $newData;
$modified = true;
error_log("[AttendeeNormalizerPlugin] Normalized attendees in: " . $path);
}
} catch (\Exception $e) {
// Log error but don't block the request
error_log("[AttendeeNormalizerPlugin] Error processing calendar object: " . $e->getMessage());
}
}
/**
* Normalize and deduplicate attendees in a VEVENT component.
*
* @param \Sabre\VObject\Component\VEvent $vevent
* @return bool True if the component was modified
*/
private function normalizeAndDeduplicateAttendees($vevent)
{
if (!isset($vevent->ATTENDEE) || count($vevent->ATTENDEE) === 0) {
return false;
}
$attendees = [];
$attendeesByEmail = [];
$wasModified = false;
// First pass: collect and normalize all attendees
foreach ($vevent->ATTENDEE as $attendee) {
$email = $this->normalizeEmail((string)$attendee);
$status = isset($attendee['PARTSTAT']) ? strtoupper((string)$attendee['PARTSTAT']) : 'NEEDS-ACTION';
$priority = self::STATUS_PRIORITY[$status] ?? 0;
$attendeeData = [
'property' => $attendee,
'email' => $email,
'status' => $status,
'priority' => $priority,
];
if (!isset($attendeesByEmail[$email])) {
// First occurrence of this email
$attendeesByEmail[$email] = $attendeeData;
$attendees[] = $attendeeData;
} else {
// Duplicate found
$existing = $attendeesByEmail[$email];
if ($priority > $existing['priority']) {
// New attendee has higher priority - replace
// Find and replace in the array
foreach ($attendees as $i => $att) {
if ($att['email'] === $email) {
$attendees[$i] = $attendeeData;
$attendeesByEmail[$email] = $attendeeData;
break;
}
}
}
$wasModified = true;
error_log("[AttendeeNormalizerPlugin] Found duplicate attendee: {$email} (keeping status: " . $attendeesByEmail[$email]['status'] . ")");
}
}
// Also normalize the email in the value (lowercase the mailto: part)
foreach ($vevent->ATTENDEE as $attendee) {
$value = (string)$attendee;
$normalizedValue = $this->normalizeMailtoValue($value);
if ($value !== $normalizedValue) {
$attendee->setValue($normalizedValue);
$wasModified = true;
}
}
// If duplicates were found, rebuild the ATTENDEE list
if ($wasModified && count($attendees) < count($vevent->ATTENDEE)) {
// Remove all existing ATTENDEEs
unset($vevent->ATTENDEE);
// Add back the deduplicated attendees
foreach ($attendees as $attendeeData) {
$property = $attendeeData['property'];
// Clone the property to the vevent
$newAttendee = $vevent->add('ATTENDEE', $this->normalizeMailtoValue((string)$property));
// Copy all parameters
foreach ($property->parameters() as $param) {
$newAttendee[$param->name] = $param->getValue();
}
}
error_log("[AttendeeNormalizerPlugin] Reduced attendees from " . count($vevent->ATTENDEE) . " to " . count($attendees));
}
return $wasModified;
}
/**
* Normalize an email address extracted from a mailto: URI.
*
* @param string $value The ATTENDEE value (e.g., "mailto:User@Example.com")
* @return string Normalized email (lowercase)
*/
private function normalizeEmail($value)
{
// Remove mailto: prefix if present
$email = preg_replace('/^mailto:/i', '', $value);
// Lowercase and trim
return strtolower(trim($email));
}
/**
* Normalize the mailto: value to have lowercase email.
*
* @param string $value The ATTENDEE value (e.g., "mailto:User@Example.com")
* @return string Normalized value (e.g., "mailto:user@example.com")
*/
private function normalizeMailtoValue($value)
{
if (stripos($value, 'mailto:') === 0) {
$email = substr($value, 7);
return 'mailto:' . strtolower(trim($email));
}
return strtolower(trim($value));
}
/**
* Returns a list of features for the DAV: header.
*
* @return array
*/
public function getFeatures()
{
return [];
}
}

View File

@@ -0,0 +1,197 @@
<?php
/**
* Custom principal backend that auto-creates principals when they don't exist
* and supports org-scoped discovery.
*
* - Auto-creates principals on first access (for OIDC-authenticated users)
* - Stores org_id and calendar_user_type on principals
* - Filters searchPrincipals() and getPrincipalsByPrefix() by org_id
* - Does NOT filter getPrincipalByPath() (allows cross-org sharing)
*/
namespace Calendars\SabreDav;
use Sabre\DAVACL\PrincipalBackend\PDO as BasePDO;
use Sabre\DAV\MkCol;
class AutoCreatePrincipalBackend extends BasePDO
{
/**
* Extend the default field map to include calendar-user-type.
*
* SabreDAV's PDO principal backend uses $fieldMap to map WebDAV property
* names to database columns. The base class only maps displayname and email.
* The Schedule\Plugin hardcodes calendar-user-type to 'INDIVIDUAL' via a
* propFind handler, but that handler uses handle() which is a no-op when
* the property is already set. By adding calendar-user-type to the fieldMap,
* the Principal node exposes the real value from the DB via getProperties(),
* and the Schedule\Plugin's hardcoded 'INDIVIDUAL' only serves as a fallback
* for principals that don't have the column set.
*
* @see https://github.com/sabre-io/dav/blob/master/lib/DAVACL/PrincipalBackend/PDO.php
* @see https://github.com/sabre-io/dav/blob/master/lib/CalDAV/Schedule/Plugin.php
*/
protected $fieldMap = [
'{DAV:}displayname' => [
'dbField' => 'displayname',
],
'{http://sabredav.org/ns}email-address' => [
'dbField' => 'email',
],
'{urn:ietf:params:xml:ns:caldav}calendar-user-type' => [
'dbField' => 'calendar_user_type',
],
];
/**
* @var \Sabre\DAV\Server|null
*/
private $server = null;
/**
* Set the server reference (called from server.php after server creation).
*
* @param \Sabre\DAV\Server $server
*/
public function setServer(\Sabre\DAV\Server $server)
{
$this->server = $server;
}
/**
* Get the org_id from the current HTTP request's X-CalDAV-Organization header.
*
* @return string|null
*/
private function getRequestOrgId()
{
if ($this->server && $this->server->httpRequest) {
return $this->server->httpRequest->getHeader('X-CalDAV-Organization');
}
return null;
}
/**
* Returns a specific principal, specified by its path.
* Auto-creates the principal if it doesn't exist.
*
* NOT org-filtered: allows cross-org sharing and scheduling.
*
* @param string $path
* @return array|null
*/
public function getPrincipalByPath($path)
{
$principal = parent::getPrincipalByPath($path);
// If principal doesn't exist, create it automatically
// Only auto-create user principals (principals/users/*).
// Resource principals (principals/resources/*) are provisioned via Django.
if (!$principal && strpos($path, 'principals/users/') === 0) {
// Extract username from path
$username = substr($path, strlen('principals/users/'));
$pdo = $this->pdo;
$tableName = $this->tableName;
$orgId = $this->getRequestOrgId();
try {
$stmt = $pdo->prepare(
'INSERT INTO ' . $tableName
. ' (uri, email, displayname, calendar_user_type, org_id)'
. ' VALUES (?, ?, ?, ?, ?)'
. ' ON CONFLICT (uri) DO UPDATE SET org_id = COALESCE(EXCLUDED.org_id, '
. $tableName . '.org_id)'
);
$stmt->execute([$path, $username, $username, 'INDIVIDUAL', $orgId]);
// Retry getting the principal
$principal = parent::getPrincipalByPath($path);
} catch (\Exception $e) {
error_log("Failed to auto-create principal: " . $e->getMessage());
return null;
}
}
return $principal;
}
/**
* Returns a list of principals based on a prefix.
*
* Org-filtered: only returns principals from the requesting user's org.
*
* @param string $prefixPath
* @return array
*/
public function getPrincipalsByPrefix($prefixPath)
{
$principals = parent::getPrincipalsByPrefix($prefixPath);
$orgId = $this->getRequestOrgId();
if (!$orgId) {
return $principals;
}
// Filter by org_id
$filteredUris = $this->getOrgPrincipalUris($prefixPath, $orgId);
if ($filteredUris === null) {
return $principals;
}
return array_values(array_filter($principals, function ($principal) use ($filteredUris) {
return in_array($principal['uri'], $filteredUris, true);
}));
}
/**
* Search principals matching certain criteria.
*
* Org-filtered: only returns principals from the requesting user's org.
*
* @param string $prefixPath
* @param array $searchProperties
* @param string $test
* @return array
*/
public function searchPrincipals($prefixPath, array $searchProperties, $test = 'allof')
{
$results = parent::searchPrincipals($prefixPath, $searchProperties, $test);
$orgId = $this->getRequestOrgId();
if (!$orgId) {
return $results;
}
$filteredUris = $this->getOrgPrincipalUris($prefixPath, $orgId);
if ($filteredUris === null) {
return $results;
}
return array_values(array_filter($results, function ($uri) use ($filteredUris) {
return in_array($uri, $filteredUris, true);
}));
}
/**
* Get principal URIs for a given prefix and org_id.
*
* @param string $prefixPath
* @param string $orgId
* @return array|null
*/
private function getOrgPrincipalUris($prefixPath, $orgId)
{
try {
$stmt = $this->pdo->prepare(
'SELECT uri FROM ' . $this->tableName
. ' WHERE uri LIKE ? AND org_id = ?'
);
$stmt->execute([$prefixPath . '/%', $orgId]);
return $stmt->fetchAll(\PDO::FETCH_COLUMN, 0);
} catch (\Exception $e) {
error_log("Failed to query org principals: " . $e->getMessage());
return null;
}
}
}

View File

@@ -0,0 +1,234 @@
<?php
/**
* CalendarSanitizerPlugin - Sanitizes calendar data on all CalDAV writes.
*
* Applied to both new creates (PUT to new URI) and updates (PUT to existing URI).
* This covers events coming from any CalDAV client (Thunderbird, Apple Calendar,
* Outlook, etc.) as well as the bulk import plugin.
*
* Sanitizations:
* 1. Strip inline binary attachments (ATTACH;VALUE=BINARY / ENCODING=BASE64)
* These are typically Outlook/Exchange email signature images that bloat storage.
* URL-based attachments (e.g. Google Drive links) are preserved.
* 2. Truncate oversized text properties:
* - Long text fields (DESCRIPTION, X-ALT-DESC, COMMENT): configurable limit (default 100KB)
* - Short text fields (SUMMARY, LOCATION): fixed 1KB safety guardrail
* 3. Enforce max resource size (default 1MB) on the final serialized object.
* Returns HTTP 507 Insufficient Storage if exceeded after sanitization.
*
* Controlled by constructor parameters (read from env vars in server.php).
*/
namespace Calendars\SabreDav;
use Sabre\DAV\Server;
use Sabre\DAV\ServerPlugin;
use Sabre\DAV\Exception\InsufficientStorage;
use Sabre\VObject\Reader;
use Sabre\VObject\Component\VCalendar;
class CalendarSanitizerPlugin extends ServerPlugin
{
/** @var Server */
protected $server;
/** @var bool Whether to strip inline binary attachments */
private $stripBinaryAttachments;
/** @var int Max size in bytes for long text properties: DESCRIPTION, X-ALT-DESC, COMMENT (0 = no limit) */
private $maxDescriptionBytes;
/** @var int Max total resource size in bytes after sanitization (0 = no limit) */
private $maxResourceSize;
/** @var int Max size in bytes for short text properties: SUMMARY, LOCATION */
private const MAX_SHORT_TEXT_BYTES = 1024;
/** @var array Long text properties subject to $maxDescriptionBytes */
private const LONG_TEXT_PROPERTIES = ['DESCRIPTION', 'X-ALT-DESC', 'COMMENT'];
/** @var array Short text properties subject to MAX_SHORT_TEXT_BYTES */
private const SHORT_TEXT_PROPERTIES = ['SUMMARY', 'LOCATION'];
public function __construct(
bool $stripBinaryAttachments = true,
int $maxDescriptionBytes = 102400,
int $maxResourceSize = 1048576
) {
$this->stripBinaryAttachments = $stripBinaryAttachments;
$this->maxDescriptionBytes = $maxDescriptionBytes;
$this->maxResourceSize = $maxResourceSize;
}
public function getPluginName()
{
return 'calendar-sanitizer';
}
public function initialize(Server $server)
{
$this->server = $server;
// Priority 85: run before AttendeeNormalizerPlugin (90) and CalDAV validation (100)
$server->on('beforeCreateFile', [$this, 'beforeCreateCalendarObject'], 85);
$server->on('beforeWriteContent', [$this, 'beforeUpdateCalendarObject'], 85);
}
/**
* Called before a calendar object is created.
* Signature: ($path, &$data, \Sabre\DAV\ICollection $parent, &$modified)
*/
public function beforeCreateCalendarObject($path, &$data, $parentNode = null, &$modified = false)
{
$this->sanitizeCalendarData($path, $data, $modified);
}
/**
* Called before a calendar object is updated.
* Signature: ($path, \Sabre\DAV\IFile $node, &$data, &$modified)
*/
public function beforeUpdateCalendarObject($path, $node, &$data, &$modified = false)
{
$this->sanitizeCalendarData($path, $data, $modified);
}
/**
* Sanitize raw calendar data from a beforeCreateFile/beforeWriteContent hook.
*/
private function sanitizeCalendarData($path, &$data, &$modified)
{
// Only process .ics files
if (!preg_match('/\.ics$/i', $path)) {
return;
}
try {
// Get the data as string
if (is_resource($data)) {
$dataStr = stream_get_contents($data);
rewind($data);
} else {
$dataStr = $data;
}
$vcalendar = Reader::read($dataStr);
if (!$vcalendar instanceof VCalendar) {
return;
}
if ($this->sanitizeVCalendar($vcalendar)) {
$data = $vcalendar->serialize();
$modified = true;
}
// Enforce max resource size after sanitization
$finalSize = is_string($data) ? strlen($data) : strlen($dataStr);
if ($this->maxResourceSize > 0 && $finalSize > $this->maxResourceSize) {
throw new InsufficientStorage(
"Calendar object size ({$finalSize} bytes) exceeds limit ({$this->maxResourceSize} bytes)"
);
}
} catch (InsufficientStorage $e) {
// Re-throw size limit errors — these must reach the client as HTTP 507
throw $e;
} catch (\Exception $e) {
// Log other errors but don't block the request
error_log("[CalendarSanitizerPlugin] Error processing calendar object: " . $e->getMessage());
}
}
/**
* Sanitize a parsed VCalendar object in-place.
* Strips binary attachments and truncates oversized descriptions.
*
* Also called by InternalApiPlugin for direct DB writes that bypass
* the HTTP layer (and thus don't trigger beforeCreateFile hooks).
*
* @return bool True if the VCalendar was modified.
*/
public function sanitizeVCalendar(VCalendar $vcalendar)
{
$wasModified = false;
foreach ($vcalendar->getComponents() as $component) {
if ($component->name === 'VTIMEZONE') {
continue;
}
// Strip inline binary attachments
if ($this->stripBinaryAttachments && isset($component->ATTACH)) {
$toRemove = [];
foreach ($component->select('ATTACH') as $attach) {
$valueParam = $attach->offsetGet('VALUE');
$encodingParam = $attach->offsetGet('ENCODING');
if (
($valueParam && strtoupper((string)$valueParam) === 'BINARY') ||
($encodingParam && strtoupper((string)$encodingParam) === 'BASE64')
) {
$toRemove[] = $attach;
}
}
foreach ($toRemove as $attach) {
$component->remove($attach);
$wasModified = true;
}
}
// Truncate oversized long text properties (DESCRIPTION, X-ALT-DESC, COMMENT)
if ($this->maxDescriptionBytes > 0) {
foreach (self::LONG_TEXT_PROPERTIES as $prop) {
if (isset($component->{$prop})) {
$val = (string)$component->{$prop};
if (strlen($val) > $this->maxDescriptionBytes) {
$component->{$prop} = substr($val, 0, $this->maxDescriptionBytes) . '...';
$wasModified = true;
}
}
}
}
// Truncate oversized short text properties (SUMMARY, LOCATION)
foreach (self::SHORT_TEXT_PROPERTIES as $prop) {
if (isset($component->{$prop})) {
$val = (string)$component->{$prop};
if (strlen($val) > self::MAX_SHORT_TEXT_BYTES) {
$component->{$prop} = substr($val, 0, self::MAX_SHORT_TEXT_BYTES) . '...';
$wasModified = true;
}
}
}
}
return $wasModified;
}
/**
* Check that a VCalendar's serialized size is within the max resource limit.
* Called by InternalApiPlugin for the direct DB write path.
*
* @throws InsufficientStorage if the serialized size exceeds the limit.
*/
public function checkResourceSize(VCalendar $vcalendar)
{
if ($this->maxResourceSize <= 0) {
return;
}
$size = strlen($vcalendar->serialize());
if ($size > $this->maxResourceSize) {
throw new InsufficientStorage(
"Calendar object size ({$size} bytes) exceeds limit ({$this->maxResourceSize} bytes)"
);
}
}
public function getPluginInfo()
{
return [
'name' => $this->getPluginName(),
'description' => 'Sanitizes calendar data (strips binary attachments, truncates descriptions)',
];
}
}

View File

@@ -0,0 +1,86 @@
<?php
/**
* Custom root node for the /calendars/ collection.
*
* SabreDAV's built-in CalendarRoot maps calendars/{name} → principals/{name}
* using a flat, single-level principal prefix. This doesn't work for nested
* prefixes like principals/users/{email} and principals/resources/{id},
* because CalendarRoot.getChild('users') would look for a principal named
* 'principals/users' rather than a sub-collection.
*
* This node sits at /calendars/ and delegates to child CalendarRoot nodes:
* calendars/users/{email}/{cal} → CalendarRoot(prefix='principals/users')
* calendars/resources/{id}/{cal} → CalendarRoot(prefix='principals/resources')
*
*/
namespace Calendars\SabreDav;
use Sabre\CalDAV;
use Sabre\DAV;
use Sabre\DAVACL\PrincipalBackend\BackendInterface as PrincipalBackendInterface;
use Sabre\CalDAV\Backend\BackendInterface as CalDAVBackendInterface;
class CalendarsRoot extends DAV\Collection
{
/** @var DAV\INode[] */
private $children;
public function __construct(
PrincipalBackendInterface $principalBackend,
CalDAVBackendInterface $caldavBackend
) {
$this->children = [
new NamedCalendarRoot('users', $principalBackend, $caldavBackend, 'principals/users'),
new NamedCalendarRoot('resources', $principalBackend, $caldavBackend, 'principals/resources'),
];
}
public function getName()
{
return 'calendars';
}
public function getChild($name)
{
foreach ($this->children as $child) {
if ($child->getName() === $name) {
return $child;
}
}
throw new DAV\Exception\NotFound('Collection ' . $name . ' not found');
}
public function getChildren()
{
return $this->children;
}
}
/**
* A CalendarRoot whose getName() returns a custom value instead of 'calendars'.
*
* Used as a child of CalendarsRoot so that:
* calendars/users/ → NamedCalendarRoot('users', ..., 'principals/users')
* calendars/resources/ → NamedCalendarRoot('resources', ..., 'principals/resources')
*/
class NamedCalendarRoot extends CalDAV\CalendarRoot
{
/** @var string */
private $nodeName;
public function __construct(
string $nodeName,
PrincipalBackendInterface $principalBackend,
CalDAVBackendInterface $caldavBackend,
string $principalPrefix
) {
parent::__construct($principalBackend, $caldavBackend, $principalPrefix);
$this->nodeName = $nodeName;
}
public function getName()
{
return $this->nodeName;
}
}

View File

@@ -0,0 +1,52 @@
<?php
/**
* Custom CalDAV plugin that handles nested principal prefixes.
*
* SabreDAV's built-in CalDAV\Plugin assumes principals are 2-part:
* principals/{name} → calendars/{name}
*
* We use 3-part principals:
* principals/users/{email} → calendars/users/{email}
* principals/resources/{id} → calendars/resources/{id}
*
* This subclass overrides getCalendarHomeForPrincipal() to handle
* the nested structure.
*/
namespace Calendars\SabreDav;
use Sabre\CalDAV;
class CustomCalDAVPlugin extends CalDAV\Plugin
{
/**
* Returns the path to a principal's calendar home.
*
* Handles both 2-part (principals/{name}) and 3-part
* (principals/{type}/{name}) principal URLs.
*
* @param string $principalUrl
* @return string|null
*/
public function getCalendarHomeForPrincipal($principalUrl)
{
$parts = explode('/', trim($principalUrl, '/'));
if (count($parts) < 2 || 'principals' !== $parts[0]) {
return null;
}
// Standard 2-part: principals/{name} → calendars/{name}
if (count($parts) === 2) {
return self::CALENDAR_ROOT . '/' . $parts[1];
}
// 3-part: principals/users/{email} → calendars/users/{email}
// principals/resources/{id} → calendars/resources/{id}
if (count($parts) === 3 && in_array($parts[1], ['users', 'resources'], true)) {
return self::CALENDAR_ROOT . '/' . $parts[1] . '/' . $parts[2];
}
return null;
}
}

View File

@@ -0,0 +1,164 @@
<?php
/**
* Custom IMipPlugin that forwards scheduling messages via HTTP callback instead of sending emails.
*
* This plugin extends sabre/dav's IMipPlugin but instead of sending emails via PHP's mail()
* function, it forwards the scheduling messages to an HTTP callback endpoint secured by API key.
*
* @see https://sabre.io/dav/scheduling/
*/
namespace Calendars\SabreDav;
use Sabre\CalDAV\Schedule\IMipPlugin;
use Sabre\DAV\Server;
use Sabre\VObject\ITip\Message;
class HttpCallbackIMipPlugin extends IMipPlugin
{
/**
* API key for authenticating with the callback endpoint
* @var string
*/
private $apiKey;
/**
* Reference to the DAV server instance
* @var Server
*/
private $server;
/**
* Default callback URL (fallback if header is not provided)
* @var string|null
*/
private $defaultCallbackUrl;
/**
* Constructor
*
* @param string $apiKey The API key for authenticating with the callback endpoint
* @param string|null $defaultCallbackUrl Optional default callback URL
*/
public function __construct($apiKey, $defaultCallbackUrl = null)
{
// Call parent constructor with empty email (we won't use it)
parent::__construct('');
$this->apiKey = $apiKey;
$this->defaultCallbackUrl = $defaultCallbackUrl;
}
/**
* Initialize the plugin.
*
* @param Server $server
* @return void
*/
public function initialize(Server $server)
{
parent::initialize($server);
$this->server = $server;
}
/**
* Event handler for the 'schedule' event.
*
* This overrides the parent's schedule() method to forward messages via HTTP callback
* instead of sending emails via PHP's mail() function.
*
* @param Message $iTipMessage The iTip message
* @return void
*/
public function schedule(Message $iTipMessage)
{
// Not sending any messages if the system considers the update insignificant.
if (!$iTipMessage->significantChange) {
if (!$iTipMessage->scheduleStatus) {
$iTipMessage->scheduleStatus = '1.0;We got the message, but it\'s not significant enough to warrant delivery';
}
return;
}
// Only handle mailto: recipients (external attendees)
if ('mailto' !== parse_url($iTipMessage->sender, PHP_URL_SCHEME)) {
return;
}
if ('mailto' !== parse_url($iTipMessage->recipient, PHP_URL_SCHEME)) {
return;
}
// Get callback URL from the HTTP request header or use default
$callbackUrl = null;
if ($this->server && $this->server->httpRequest) {
$callbackUrl = $this->server->httpRequest->getHeader('X-CalDAV-Callback-URL');
}
// Fall back to default callback URL if header is not provided
if (!$callbackUrl && $this->defaultCallbackUrl) {
$callbackUrl = $this->defaultCallbackUrl;
error_log("[HttpCallbackIMipPlugin] Using default callback URL: {$callbackUrl}");
}
if (!$callbackUrl) {
error_log("[HttpCallbackIMipPlugin] ERROR: X-CalDAV-Callback-URL header or default URL is required");
$iTipMessage->scheduleStatus = '5.4;X-CalDAV-Callback-URL header or default URL is required';
return;
}
// Ensure URL ends with trailing slash for Django's APPEND_SLASH middleware
$callbackUrl = rtrim($callbackUrl, '/') . '/';
// Serialize the iCalendar message
$vcalendar = $iTipMessage->message ? $iTipMessage->message->serialize() : '';
// Prepare headers
// Trim API key to remove any whitespace from environment variable
$apiKey = trim($this->apiKey);
$headers = [
'Content-Type: text/calendar',
'X-Api-Key: ' . $apiKey,
'X-CalDAV-Sender: ' . $iTipMessage->sender,
'X-CalDAV-Recipient: ' . $iTipMessage->recipient,
'X-CalDAV-Method: ' . $iTipMessage->method,
];
// Make HTTP POST request to Django callback endpoint
$ch = curl_init($callbackUrl);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $vcalendar,
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($curlError) {
error_log(sprintf(
"[HttpCallbackIMipPlugin] ERROR: cURL failed: %s",
$curlError
));
$iTipMessage->scheduleStatus = '5.4;Failed to forward scheduling message via HTTP callback';
return;
}
if ($httpCode >= 400) {
error_log(sprintf(
"[HttpCallbackIMipPlugin] ERROR: HTTP %d - %s",
$httpCode,
substr($response, 0, 200)
));
$iTipMessage->scheduleStatus = '5.4;HTTP callback returned error: ' . $httpCode;
return;
}
// Success
$iTipMessage->scheduleStatus = '1.1;Scheduling message forwarded via HTTP callback';
}
}

View File

@@ -0,0 +1,545 @@
<?php
/**
* InternalApiPlugin - Handles all /internal-api/ routes.
*
* Provides a clean namespace for internal operations (resource provisioning,
* ICS import) that is completely separated from the CalDAV protocol namespace.
*
* Endpoints:
* POST /internal-api/resources/ Create a resource principal
* DELETE /internal-api/resources/{resource_id} Delete a resource principal
* POST /internal-api/import/{user}/{calendar} Bulk import ICS events
*
* Access control (defense in depth):
* 1. Django proxy blocklist rejects /internal-api/ paths
* 2. Requires X-Internal-Api-Key header (different from X-Api-Key used by proxy)
* 3. Test coverage verifies the proxy rejects these paths
*/
namespace Calendars\SabreDav;
use Sabre\DAV\Server;
use Sabre\DAV\ServerPlugin;
use Sabre\CalDAV\Backend\PDO as CalDAVBackend;
use Sabre\VObject;
class InternalApiPlugin extends ServerPlugin
{
/** @var Server */
protected $server;
/** @var \PDO */
private $pdo;
/** @var CalDAVBackend */
private $caldavBackend;
/** @var string */
private $apiKey;
public function __construct(\PDO $pdo, CalDAVBackend $caldavBackend, string $apiKey)
{
$this->pdo = $pdo;
$this->caldavBackend = $caldavBackend;
$this->apiKey = $apiKey;
}
public function getPluginName()
{
return 'internal-api';
}
public function initialize(Server $server)
{
$this->server = $server;
// Use method:* (not beforeMethod:*) so SabreDAV calls sendResponse()
// for us after the handler returns false.
$server->on('method:*', [$this, 'handleRequest'], 90);
}
/**
* Intercept all requests under /internal-api/.
*
* @return bool|null false to stop event propagation, null to let
* other handlers proceed.
*/
public function handleRequest($request, $response)
{
$path = $request->getPath();
// Only handle /internal-api/ routes
if (strpos($path, 'internal-api/') !== 0 && $path !== 'internal-api') {
return;
}
// Verify the dedicated internal API key header
$headerValue = $request->getHeader('X-Internal-Api-Key');
if (!$headerValue || !hash_equals($this->apiKey, $headerValue)) {
$response->setStatus(403);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode([
'error' => 'Forbidden: missing or invalid X-Internal-Api-Key header',
]));
return false;
}
$method = $request->getMethod();
// Route: POST /internal-api/resources/
if ($method === 'POST' && preg_match('#^internal-api/resources/?$#', $path)) {
$this->handleCreateResource($request, $response);
return false;
}
// Route: DELETE /internal-api/resources/{resource_id}
if ($method === 'DELETE' && preg_match('#^internal-api/resources/([a-zA-Z0-9-]+)$#', $path, $matches)) {
$this->handleDeleteResource($request, $response, $matches[1]);
return false;
}
// Route: POST /internal-api/users/delete
if ($method === 'POST' && preg_match('#^internal-api/users/delete/?$#', $path)) {
$body = json_decode($request->getBodyAsString(), true);
$email = $body['email'] ?? null;
if (!$email) {
$response->setStatus(400);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode(['error' => 'email is required']));
return false;
}
$this->handleDeleteUser($request, $response, $email);
return false;
}
// Route: POST /internal-api/import/{principalUser}/{calendarUri}
if ($method === 'POST' && preg_match('#^internal-api/import/([^/]+)/([^/]+)$#', $path, $matches)) {
$this->handleImport($request, $response, urldecode($matches[1]), $matches[2]);
return false;
}
$response->setStatus(404);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode([
'error' => 'Not found',
]));
return false;
}
/**
* POST /internal-api/resources/
* Create a resource principal and its default calendar.
*/
private function handleCreateResource($request, $response)
{
$body = json_decode($request->getBodyAsString(), true);
if (!$body) {
$response->setStatus(400);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode(['error' => 'Invalid JSON body']));
return false;
}
$resourceId = $body['resource_id'] ?? null;
$name = $body['name'] ?? null;
$email = $body['email'] ?? null;
$resourceType = $body['resource_type'] ?? 'ROOM';
$orgId = $body['org_id'] ?? null;
if (!$resourceId || !$name || !$email) {
$response->setStatus(400);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode([
'error' => 'Missing required fields: resource_id, name, email',
]));
return false;
}
$principalUri = 'principals/resources/' . $resourceId;
// Wrap principal + calendar creation in a transaction for atomicity
$this->pdo->beginTransaction();
try {
// Insert principal with ON CONFLICT DO NOTHING
$stmt = $this->pdo->prepare(
'INSERT INTO principals (uri, email, displayname, calendar_user_type, org_id)'
. ' VALUES (?, ?, ?, ?, ?)'
. ' ON CONFLICT (uri) DO NOTHING'
);
$stmt->execute([$principalUri, $email, $name, $resourceType, $orgId]);
if ($stmt->rowCount() === 0) {
$this->pdo->rollBack();
$response->setStatus(409);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode([
'error' => "Resource '$resourceId' already exists",
]));
return false;
}
// Create default calendar
$calendarUri = 'default';
$this->caldavBackend->createCalendar(
$principalUri,
$calendarUri,
[
'{DAV:}displayname' => $name,
'{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set'
=> new \Sabre\CalDAV\Xml\Property\SupportedCalendarComponentSet(['VEVENT']),
]
);
$this->pdo->commit();
} catch (\Exception $e) {
$this->pdo->rollBack();
error_log("[InternalApiPlugin] Failed to create resource: " . $e->getMessage());
$response->setStatus(500);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode([
'error' => 'Failed to create resource',
]));
return false;
}
$response->setStatus(201);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode([
'principal_uri' => $principalUri,
'email' => $email,
]));
return false;
}
/**
* DELETE /internal-api/resources/{resource_id}
* Delete a resource principal, its calendars, and all associated data.
*/
private function handleDeleteResource($request, $response, $resourceId)
{
$principalUri = 'principals/resources/' . $resourceId;
$orgId = $request->getHeader('X-CalDAV-Organization');
// Look up the principal
try {
$stmt = $this->pdo->prepare(
'SELECT email, org_id FROM principals WHERE uri = ?'
);
$stmt->execute([$principalUri]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
} catch (\Exception $e) {
error_log("[InternalApiPlugin] Failed to look up principal: " . $e->getMessage());
$response->setStatus(500);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode(['error' => 'Failed to look up resource']));
return false;
}
if (!$row) {
$response->setStatus(404);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode([
'error' => "Resource '$resourceId' not found",
]));
return false;
}
// Verify org scoping — reject if orgs don't match or either is missing
if (!$orgId || !$row['org_id'] || $orgId !== $row['org_id']) {
$response->setStatus(403);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode([
'error' => 'Cannot delete a resource from a different organization',
]));
return false;
}
// Delete calendars and their objects
try {
$calendars = $this->caldavBackend->getCalendarsForUser($principalUri);
foreach ($calendars as $calendar) {
$this->caldavBackend->deleteCalendar($calendar['id']);
}
} catch (\Exception $e) {
error_log("[InternalApiPlugin] Failed to delete calendars: " . $e->getMessage());
}
// Delete scheduling objects, principal rows
$this->deletePrincipalRows($principalUri);
$response->setStatus(200);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode(['deleted' => true]));
return false;
}
/**
* Delete principal row and associated proxy/scheduling rows.
*/
private function deletePrincipalRows($principalUri)
{
try {
// Delete scheduling objects if the table exists
$stmt = $this->pdo->prepare(
"SELECT EXISTS ("
. " SELECT FROM information_schema.tables"
. " WHERE table_name = 'schedulingobjects'"
. ")"
);
$stmt->execute();
if ($stmt->fetchColumn()) {
$del = $this->pdo->prepare(
'DELETE FROM schedulingobjects WHERE principaluri = ?'
);
$del->execute([$principalUri]);
}
// Delete principal and proxy rows
$del = $this->pdo->prepare('DELETE FROM principals WHERE uri = ?');
$del->execute([$principalUri]);
$del = $this->pdo->prepare('DELETE FROM principals WHERE uri LIKE ?');
$del->execute([$principalUri . '/%']);
} catch (\Exception $e) {
error_log("[InternalApiPlugin] Failed to delete principal rows: " . $e->getMessage());
}
}
/**
* POST /internal-api/users/delete
* Delete a user principal and all their calendar data.
* Body: {"email": "user@example.com"}
*/
private function handleDeleteUser($request, $response, $email)
{
$principalUri = 'principals/users/' . $email;
$orgId = $request->getHeader('X-CalDAV-Organization');
// Look up the principal
try {
$stmt = $this->pdo->prepare(
'SELECT id, org_id FROM principals WHERE uri = ?'
);
$stmt->execute([$principalUri]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
} catch (\Exception $e) {
error_log("[InternalApiPlugin] Failed to look up user principal: " . $e->getMessage());
$response->setStatus(500);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode(['error' => 'Failed to look up user']));
return false;
}
if (!$row) {
// Principal doesn't exist — nothing to clean up
$response->setStatus(200);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode(['deleted' => true, 'existed' => false]));
return false;
}
// Verify org scoping — reject if orgs don't match or either is missing
if ($row['org_id']) {
if (!$orgId || $orgId !== $row['org_id']) {
$response->setStatus(403);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode([
'error' => 'Cannot delete a user from a different organization',
]));
return false;
}
}
// Delete calendars and their objects
try {
$calendars = $this->caldavBackend->getCalendarsForUser($principalUri);
foreach ($calendars as $calendar) {
$this->caldavBackend->deleteCalendar($calendar['id']);
}
} catch (\Exception $e) {
error_log("[InternalApiPlugin] Failed to delete user calendars: " . $e->getMessage());
}
// Delete scheduling objects, principal rows
$this->deletePrincipalRows($principalUri);
$response->setStatus(200);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode(['deleted' => true, 'existed' => true]));
return false;
}
/**
* POST /internal-api/import/{principalUser}/{calendarUri}
* Bulk import events from a multi-event ICS file.
*/
private function handleImport($request, $response, $principalUser, $calendarUri)
{
$principalUri = 'principals/users/' . $principalUser;
// Look up calendarId
$calendarId = $this->resolveCalendarId($principalUri, $calendarUri);
if ($calendarId === null) {
$response->setStatus(404);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode(['error' => 'Calendar not found']));
return false;
}
// Read and parse the raw ICS body
$icsBody = $request->getBodyAsString();
if (empty($icsBody)) {
$response->setStatus(400);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode(['error' => 'Empty request body']));
return false;
}
try {
$vcal = VObject\Reader::read($icsBody);
} catch (\Exception $e) {
error_log("[InternalApiPlugin] Failed to parse ICS: " . $e->getMessage());
$response->setStatus(400);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode(['error' => 'Failed to parse ICS file']));
return false;
}
// Validate and auto-repair (fixes missing VALARM ACTION, etc.)
$vcal->validate(VObject\Component::REPAIR);
// Split by UID using the stream-based splitter
$stream = fopen('php://temp', 'r+');
fwrite($stream, $vcal->serialize());
rewind($stream);
$splitter = new VObject\Splitter\ICalendar($stream);
$totalEvents = 0;
$importedCount = 0;
$duplicateCount = 0;
$skippedCount = 0;
$errors = [];
try {
while ($splitVcal = $splitter->getNext()) {
$totalEvents++;
try {
// Extract UID from the first VEVENT
$uid = null;
foreach ($splitVcal->VEVENT as $vevent) {
if (isset($vevent->UID)) {
$uid = (string)$vevent->UID;
break;
}
}
if (!$uid) {
$uid = \Sabre\DAV\UUIDUtil::getUUID();
}
// Sanitize event data (strip attachments, truncate descriptions)
$this->sanitizeAndCheckSize($splitVcal);
$objectUri = $uid . '.ics';
$data = $splitVcal->serialize();
$this->caldavBackend->createCalendarObject(
$calendarId,
$objectUri,
$data
);
$importedCount++;
} catch (\Exception $e) {
$msg = $e->getMessage();
$summary = '';
if (isset($splitVcal->VEVENT) && isset($splitVcal->VEVENT->SUMMARY)) {
$summary = (string)$splitVcal->VEVENT->SUMMARY;
}
if (strpos($msg, '23505') !== false) {
$duplicateCount++;
} elseif (strpos($msg, 'valid instances') !== false) {
$skippedCount++;
} else {
$skippedCount++;
if (count($errors) < 10) {
$errors[] = [
'uid' => $uid ?? 'unknown',
'summary' => $summary,
'error' => $msg,
];
}
error_log(
"[InternalApiPlugin] Failed to import event "
. "uid=" . ($uid ?? 'unknown')
. " summary={$summary}: {$msg}"
);
}
}
}
} finally {
fclose($stream);
}
error_log(
"[InternalApiPlugin] Import complete: "
. "{$importedCount} imported, "
. "{$duplicateCount} duplicates, "
. "{$skippedCount} failed "
. "out of {$totalEvents} total"
);
$response->setStatus(200);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode([
'total_events' => $totalEvents,
'imported_count' => $importedCount,
'duplicate_count' => $duplicateCount,
'skipped_count' => $skippedCount,
'errors' => $errors,
]));
return false;
}
/**
* Sanitize a split VCALENDAR before import and enforce max resource size.
*/
private function sanitizeAndCheckSize(VObject\Component\VCalendar $vcal)
{
$sanitizer = $this->server->getPlugin('calendar-sanitizer');
if ($sanitizer) {
$sanitizer->sanitizeVCalendar($vcal);
$sanitizer->checkResourceSize($vcal);
}
}
/**
* Resolve the internal calendar ID from a principal URI and calendar URI.
*
* @param string $principalUri e.g. "principals/users/user@example.com"
* @param string $calendarUri e.g. "a1b2c3d4-..."
* @return array|null The calendarId pair, or null if not found.
*/
private function resolveCalendarId(string $principalUri, string $calendarUri)
{
$calendars = $this->caldavBackend->getCalendarsForUser($principalUri);
foreach ($calendars as $calendar) {
if ($calendar['uri'] === $calendarUri) {
return $calendar['id'];
}
}
return null;
}
public function getPluginInfo()
{
return [
'name' => $this->getPluginName(),
'description' => 'Internal API for resource provisioning and ICS import',
];
}
}

View File

@@ -0,0 +1,119 @@
<?php
/**
* Custom root node for the /principals/ collection.
*
* SabreDAV's built-in Principal\Collection uses getName() = basename($prefix),
* so Principal\Collection('principals/users') would appear at /users/ in the
* tree, not /principals/users/. This node sits at /principals/ and delegates
* to child Principal\Collection nodes:
* principals/users/{email} → Principal\Collection(prefix='principals/users')
* principals/resources/{id} → Principal\Collection(prefix='principals/resources')
*
*/
namespace Calendars\SabreDav;
use Sabre\CalDAV;
use Sabre\DAV;
use Sabre\DAVACL;
use Sabre\DAVACL\PrincipalBackend\BackendInterface as PrincipalBackendInterface;
class PrincipalsRoot extends DAV\Collection
{
/** @var DAV\INode[] */
private $children;
public function __construct(PrincipalBackendInterface $principalBackend)
{
$this->children = [
new NamedPrincipalCollection('users', $principalBackend, 'principals/users'),
new ResourcePrincipalCollection('resources', $principalBackend, 'principals/resources'),
];
}
public function getName()
{
return 'principals';
}
public function getChild($name)
{
foreach ($this->children as $child) {
if ($child->getName() === $name) {
return $child;
}
}
throw new DAV\Exception\NotFound('Collection ' . $name . ' not found');
}
public function getChildren()
{
return $this->children;
}
}
/**
* A Principal\Collection whose getName() returns a custom value.
*
* Used as a child of PrincipalsRoot so that:
* principals/users/ → NamedPrincipalCollection('users', ..., 'principals/users')
* principals/resources/ → NamedPrincipalCollection('resources', ..., 'principals/resources')
*/
class NamedPrincipalCollection extends CalDAV\Principal\Collection
{
/** @var string */
private $nodeName;
public function __construct(
string $nodeName,
PrincipalBackendInterface $principalBackend,
string $principalPrefix
) {
parent::__construct($principalBackend, $principalPrefix);
$this->nodeName = $nodeName;
}
public function getName()
{
return $this->nodeName;
}
}
/**
* Principal collection for resources that returns ResourcePrincipal nodes.
*
* Resource principals have no DAV owner, so the default ACL (which only
* grants {DAV:}all to {DAV:}owner) blocks all property reads with 403.
* This collection returns ResourcePrincipal nodes that additionally grant
* {DAV:}read to {DAV:}authenticated, allowing any logged-in user to
* discover resource names, types, and emails via PROPFIND.
*/
class ResourcePrincipalCollection extends NamedPrincipalCollection
{
public function getChildForPrincipal(array $principal)
{
return new ResourcePrincipal($this->principalBackend, $principal);
}
}
/**
* A principal node with a permissive read ACL for resource discovery.
*/
class ResourcePrincipal extends CalDAV\Principal\User
{
public function getACL()
{
return [
[
'privilege' => '{DAV:}all',
'principal' => '{DAV:}owner',
'protected' => true,
],
[
'privilege' => '{DAV:}read',
'principal' => '{DAV:}authenticated',
'protected' => true,
],
];
}
}

View File

@@ -0,0 +1,385 @@
<?php
/**
* ResourceAutoSchedulePlugin - Automatic scheduling for resource principals.
*
* Intercepts iTIP messages delivered to resource principals (ROOM/RESOURCE)
* and automatically accepts or declines based on:
* - The resource's auto-schedule mode (automatic, accept-always, decline-always, manual)
* - Calendar conflict detection (for 'automatic' mode)
* - Org scoping (rejects cross-org bookings)
*
* Runs after Schedule\Plugin delivers the iTIP message (priority 120 > 110).
*
* This plugin also sets $message->scheduleStatus before HttpCallbackIMipPlugin
* runs, which prevents email delivery to resource addresses (resource addresses
* are not real mailboxes).
*/
namespace Calendars\SabreDav;
use Sabre\DAV\Server;
use Sabre\DAV\ServerPlugin;
use Sabre\VObject\ITip\Message;
use Sabre\VObject\Reader;
use Sabre\CalDAV\Backend\PDO as CalDAVBackend;
class ResourceAutoSchedulePlugin extends ServerPlugin
{
/** @var Server */
protected $server;
/** @var \PDO */
private $pdo;
/** @var CalDAVBackend */
private $caldavBackend;
/** Custom namespace for resource properties */
private const NS = 'urn:lasuite:calendars';
public function __construct(\PDO $pdo, CalDAVBackend $caldavBackend)
{
$this->pdo = $pdo;
$this->caldavBackend = $caldavBackend;
}
public function getPluginName()
{
return 'resource-auto-schedule';
}
public function initialize(Server $server)
{
$this->server = $server;
// Priority 120: runs after Schedule\Plugin (110)
$server->on('schedule', [$this, 'autoSchedule'], 120);
// Priority 200: runs BEFORE Schedule\Plugin's propFindEarly (150)
// which hardcodes calendar-user-type to 'INDIVIDUAL'. By setting
// the real value first, the Schedule\Plugin's handle() becomes a no-op.
$server->on('propFind', [$this, 'propFindResourceType'], 200);
}
/**
* Set the correct calendar-user-type for resource principals.
*
* Schedule\Plugin::propFindEarly (priority 150) hardcodes INDIVIDUAL via
* handle(), which only fires when the property isn't already resolved.
* By setting the real DB value here at priority 200 via set(), we pre-empt it.
*/
public function propFindResourceType(\Sabre\DAV\PropFind $propFind, \Sabre\DAV\INode $node)
{
if (!($node instanceof ResourcePrincipal)) {
return;
}
$props = $node->getProperties(
['{urn:ietf:params:xml:ns:caldav}calendar-user-type']
);
$cutype = $props['{urn:ietf:params:xml:ns:caldav}calendar-user-type'] ?? null;
if ($cutype) {
$propFind->set('{urn:ietf:params:xml:ns:caldav}calendar-user-type', $cutype);
}
}
/**
* Handle scheduling messages to resource principals.
*
* @param Message $message
*/
public function autoSchedule(Message $message)
{
// Only handle REQUEST method (new invitations and updates)
if ($message->method !== 'REQUEST') {
return;
}
// Only handle messages to resource principals
$recipientPrincipal = $this->resolveRecipientPrincipal($message->recipient);
if (!$recipientPrincipal) {
return;
}
$cutype = $recipientPrincipal['calendar_user_type'] ?? 'INDIVIDUAL';
if (!in_array($cutype, ['ROOM', 'RESOURCE'], true)) {
return;
}
// Enforce org scoping: reject cross-org bookings
$requestOrgId = $this->server->httpRequest
? $this->server->httpRequest->getHeader('X-CalDAV-Organization')
: null;
$resourceOrgId = $recipientPrincipal['org_id'] ?? null;
if ($resourceOrgId) {
if (!$requestOrgId || $requestOrgId !== $resourceOrgId) {
$this->declineInvitation($message, 'Cross-organization booking not allowed');
return;
}
}
// Read auto-schedule mode from propertystorage
$mode = $this->getAutoScheduleMode($recipientPrincipal['uri']);
switch ($mode) {
case 'accept-always':
$this->acceptInvitation($message);
break;
case 'decline-always':
$this->declineInvitation($message, 'Resource is offline');
break;
case 'manual':
// Leave as NEEDS-ACTION for manual approval
// But still set scheduleStatus to prevent email delivery
$message->scheduleStatus = '1.0;Pending manual approval';
break;
case 'automatic':
default:
if ($this->hasConflict($recipientPrincipal, $message)) {
$this->declineInvitation($message, 'Resource is busy');
} else {
$this->acceptInvitation($message);
}
break;
}
}
/**
* Resolve the recipient email to a principal record.
*
* @param string $recipient mailto: URI
* @return array|null Principal row or null
*/
private function resolveRecipientPrincipal($recipient)
{
$email = $this->extractEmail($recipient);
if (!$email) {
return null;
}
try {
$stmt = $this->pdo->prepare(
'SELECT id, uri, email, calendar_user_type, org_id'
. ' FROM principals WHERE email = ?'
);
$stmt->execute([strtolower($email)]);
return $stmt->fetch(\PDO::FETCH_ASSOC) ?: null;
} catch (\Exception $e) {
error_log("[ResourceAutoSchedulePlugin] DB error: " . $e->getMessage());
return null;
}
}
/**
* Extract email from a mailto: URI.
*
* @param string $uri
* @return string|null
*/
private function extractEmail($uri)
{
if (stripos($uri, 'mailto:') === 0) {
return strtolower(substr($uri, 7));
}
return null;
}
/**
* Get auto-schedule mode from propertystorage.
*
* @param string $principalUri
* @return string
*/
private function getAutoScheduleMode($principalUri)
{
try {
$stmt = $this->pdo->prepare(
"SELECT value FROM propertystorage"
. " WHERE path = ? AND name = '{" . self::NS . "}auto-schedule-mode'"
);
$stmt->execute([$principalUri]);
$result = $stmt->fetchColumn();
return $result ?: 'automatic';
} catch (\Exception $e) {
error_log("[ResourceAutoSchedulePlugin] Failed to read auto-schedule mode: " . $e->getMessage());
return 'automatic';
}
}
/**
* Check if the resource has a conflict with the incoming event.
*
* @param array $principal
* @param Message $message
* @return bool
*/
private function hasConflict($principal, Message $message)
{
if (!$message->message) {
return false;
}
$vcalendar = $message->message;
// Get the resource's calendar
$calendarId = $this->getResourceCalendarId($principal['uri']);
if (!$calendarId) {
return false; // No calendar = no conflicts
}
// Extract time ranges from all VEVENT components
foreach ($vcalendar->VEVENT as $vevent) {
// Skip transparent events
$transp = isset($vevent->TRANSP) ? (string)$vevent->TRANSP : 'OPAQUE';
if ($transp === 'TRANSPARENT') {
continue;
}
$dtstart = $vevent->DTSTART ? $vevent->DTSTART->getDateTime() : null;
$dtend = null;
if (isset($vevent->DTEND)) {
$dtend = $vevent->DTEND->getDateTime();
} elseif (isset($vevent->DURATION)) {
$dtend = clone $dtstart;
$dtend->add($vevent->DURATION->getDateInterval());
}
if (!$dtstart || !$dtend) {
continue;
}
// Query for overlapping events in the resource's calendar
$startTs = $dtstart->getTimestamp();
$endTs = $dtend->getTimestamp();
// Get UID of the incoming event to exclude updates to the same event
$uid = isset($vevent->UID) ? (string)$vevent->UID : null;
if ($this->hasOverlappingEvents($calendarId, $startTs, $endTs, $uid)) {
return true;
}
}
return false;
}
/**
* Get the resource's default calendar ID.
*
* @param string $principalUri
* @return array|null [calendarId, instanceId] pair or null
*/
private function getResourceCalendarId($principalUri)
{
$calendars = $this->caldavBackend->getCalendarsForUser($principalUri);
if (!empty($calendars)) {
return $calendars[0]['id'];
}
return null;
}
/**
* Check for overlapping events in a calendar.
*
* @param array $calendarId [calendarId, instanceId]
* @param int $startTs Start timestamp
* @param int $endTs End timestamp
* @param string|null $excludeUid UID to exclude (for updates)
* @return bool
*/
private function hasOverlappingEvents($calendarId, $startTs, $endTs, $excludeUid = null)
{
try {
// Normalize calendarId: SabreDAV may return an array [id, instanceId]
// or a scalar integer depending on the version/backend.
$calId = is_array($calendarId) ? $calendarId[0] : $calendarId;
// Use calendarobjects table directly for conflict check
// firstoccurence and lastoccurence are Unix timestamps stored by SabreDAV
$sql = 'SELECT COUNT(*) FROM calendarobjects'
. ' WHERE calendarid = ?'
. ' AND firstoccurence < ? AND lastoccurence > ?';
$params = [$calId, $endTs, $startTs];
if ($excludeUid) {
$sql .= ' AND uid != ?';
$params[] = $excludeUid;
}
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return (int)$stmt->fetchColumn() > 0;
} catch (\Exception $e) {
error_log("[ResourceAutoSchedulePlugin] Conflict check failed: " . $e->getMessage());
return true; // Fail-closed: reject booking if check fails
}
}
/**
* Accept the invitation.
*
* @param Message $message
*/
private function acceptInvitation(Message $message)
{
$message->scheduleStatus = '1.2;Scheduling message delivered (auto-accepted)';
// Update PARTSTAT in the delivered calendar object
$this->updatePartstat($message, 'ACCEPTED');
}
/**
* Decline the invitation.
*
* @param Message $message
* @param string $reason
*/
private function declineInvitation(Message $message, $reason = '')
{
$message->scheduleStatus = '3.0;Scheduling message declined' . ($reason ? ": $reason" : '');
// Update PARTSTAT in the delivered calendar object
$this->updatePartstat($message, 'DECLINED');
}
/**
* Update the PARTSTAT of the resource attendee in the iTIP message.
*
* @param Message $message
* @param string $partstat ACCEPTED, DECLINED, etc.
*/
private function updatePartstat(Message $message, $partstat)
{
if (!$message->message) {
return;
}
$recipientEmail = $this->extractEmail($message->recipient);
if (!$recipientEmail) {
return;
}
foreach ($message->message->VEVENT as $vevent) {
if (!isset($vevent->ATTENDEE)) {
continue;
}
foreach ($vevent->ATTENDEE as $attendee) {
$email = $this->extractEmail((string)$attendee);
if ($email === $recipientEmail) {
$attendee['PARTSTAT'] = $partstat;
}
}
}
}
public function getPluginInfo()
{
return [
'name' => $this->getPluginName(),
'description' => 'Auto-scheduling for resource principals (rooms, equipment)',
];
}
}

View File

@@ -0,0 +1,63 @@
<?php
/**
* ResourceMkCalendarBlockPlugin - Blocks MKCALENDAR on resource principals.
*
* A resource principal has exactly one calendar (created during provisioning).
* This plugin prevents additional calendars from being created on resource
* principals by rejecting MKCALENDAR requests targeting resource calendar homes.
*/
namespace Calendars\SabreDav;
use Sabre\DAV\Server;
use Sabre\DAV\ServerPlugin;
use Sabre\DAV\Exception\Forbidden;
class ResourceMkCalendarBlockPlugin extends ServerPlugin
{
/** @var Server */
protected $server;
public function getPluginName()
{
return 'resource-mkcalendar-block';
}
public function initialize(Server $server)
{
$this->server = $server;
// Hook before MKCALENDAR is processed
$server->on('beforeMethod:MKCALENDAR', [$this, 'beforeMkCalendar'], 90);
}
/**
* Block MKCALENDAR on resource principal calendar homes.
*
* @param \Sabre\HTTP\RequestInterface $request
* @param \Sabre\HTTP\ResponseInterface $response
* @return bool|null false to stop, null to continue
*/
public function beforeMkCalendar($request, $response)
{
$path = $request->getPath();
// Check if the path is under a resource calendar home
// Resource calendar homes: calendars/resources/{id}/
if (preg_match('#^calendars/resources/#', $path)) {
throw new Forbidden(
'Resource principals can only have one calendar. '
. 'Additional calendar creation is not allowed.'
);
}
return null; // Allow for non-resource paths
}
public function getPluginInfo()
{
return [
'name' => $this->getPluginName(),
'description' => 'Blocks MKCALENDAR on resource principal calendar homes',
];
}
}

19
src/frontend/Caddyfile Normal file
View File

@@ -0,0 +1,19 @@
{
auto_https off
admin off
}
:8080 {
root * /srv
header X-Frame-Options DENY
route {
try_files {path} /index.html
file_server
}
handle_errors {
rewrite * /404.html
file_server
}
}

View File

@@ -1,13 +1,12 @@
FROM node:22-alpine AS frontend-deps
FROM node:24-alpine AS frontend-deps
WORKDIR /home/frontend/
COPY ./package.json ./package.json
COPY ./yarn.lock ./yarn.lock
COPY ./package-lock.json ./package-lock.json
COPY ./apps/calendars/package.json ./apps/calendars/package.json
RUN yarn install --frozen-lockfile
RUN npm ci
COPY .dockerignore ./.dockerignore
# COPY ./.prettierrc.js ./.prettierrc.js
@@ -26,16 +25,16 @@ WORKDIR /home/frontend/apps/calendars
FROM frontend-deps AS calendars-dev
WORKDIR /home/frontend/apps/calendars
WORKDIR /home/frontend
EXPOSE 3000
RUN yarn build-theme
RUN cd apps/calendars && npm run build-theme
# Build open-calendar package if dist doesn't exist, then start dev server
CMD ["/bin/sh", "-c", "cd /home/frontend/apps/calendars && yarn dev"]
CMD ["/bin/sh", "-c", "cd /home/frontend/apps/calendars && npm run dev"]
# Tilt will rebuild calendars target so, we dissociate calendars and calendars-builder
# Tilt will rebuild calendars target so, we dissociate calendars and calendars-builder
# to avoid rebuilding the app at every changes.
FROM calendars AS calendars-builder
@@ -44,28 +43,20 @@ WORKDIR /home/frontend/apps/calendars
ARG API_ORIGIN
ENV NEXT_PUBLIC_API_ORIGIN=${API_ORIGIN}
RUN yarn build
RUN npm run build
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:alpine3.22 AS frontend-production
FROM caddy:2-alpine AS frontend-production
# Upgrade system packages to install security updates
USER root
RUN apk update && \
apk upgrade && \
rm -rf /var/cache/apk/*
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
COPY --from=calendars-builder \
/home/frontend/apps/calendars/out \
/usr/share/nginx/html
/srv
COPY ./apps/calendars/conf/default.conf /etc/nginx/conf.d
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
COPY ./Caddyfile /etc/caddy/Caddyfile
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
CMD ["nginx", "-g", "daemon off;"]
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]

View File

@@ -0,0 +1,13 @@
FROM caddy:2-alpine
RUN apk update && \
apk upgrade && \
rm -rf /var/cache/apk/*
COPY --chown=65534:65534 out /srv
COPY --chown=65534:65534 ./Caddyfile /etc/caddy/Caddyfile
USER nobody
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]

View File

@@ -1,20 +0,0 @@
FROM nginxinc/nginx-unprivileged:alpine3.22
# Upgrade system packages to install security updates
USER root
RUN apk update && \
apk upgrade && \
rm -rf /var/cache/apk/*
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
COPY out /usr/share/nginx/html
COPY ./apps/calendars/conf/default.conf /etc/nginx/conf.d
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -1,2 +1,2 @@
NEXT_PUBLIC_API_ORIGIN=
NEXT_PUBLIC_VISIO_BASE_URL=https://visio.suite.anct.gouv.fr
NEXT_PUBLIC_VISIO_BASE_URL=

View File

@@ -1,2 +1,2 @@
NEXT_PUBLIC_API_ORIGIN=http://localhost:8921
NEXT_PUBLIC_API_ORIGIN=http://localhost:8931
NEXT_PUBLIC_VISIO_BASE_URL=https://visio.suite.anct.gouv.fr

View File

@@ -1,26 +0,0 @@
server {
listen 8080;
listen 3000;
server_name localhost;
root /usr/share/nginx/html;
add_header X-Frame-Options DENY always;
location / {
try_files $uri index.html $uri/ =404;
}
location ~ "^/401/?$" {
try_files $uri /401.html;
}
location ~ "^/403/?$" {
try_files $uri /403.html;
}
error_page 404 /404.html;
location = /404.html {
internal;
}
}

View File

@@ -1,19 +1,18 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
import nextConfig from "eslint-config-next";
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
import nextTypescript from "eslint-config-next/typescript";
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
...nextConfig,
...nextCoreWebVitals,
...nextTypescript,
{
rules: {
"react-hooks/exhaustive-deps": "off",
// TODO: fix these patterns to be React Compiler compatible
"react-hooks/set-state-in-effect": "warn",
"react-hooks/refs": "warn",
"react-hooks/preserve-manual-memoization": "warn",
"@next/next/no-img-element": "off",
},
},

View File

@@ -1,6 +1,4 @@
import type { Config } from "jest";
import { pathsToModuleNameMapper } from "ts-jest";
import tsconfig from "./tsconfig.json";
const config: Config = {
preset: "ts-jest",
@@ -11,10 +9,8 @@ const config: Config = {
// Handle static assets FIRST (before path aliases)
"\\.(css|less|scss|sass|svg|png|jpg|jpeg|gif)$":
"<rootDir>/__mocks__/fileMock.js",
// Then handle path aliases
...pathsToModuleNameMapper(tsconfig.compilerOptions.paths || {}, {
prefix: "<rootDir>/",
}),
// Path aliases (mirrors tsconfig.json paths)
"^@/(.*)$": "<rootDir>/src/$1",
},
transform: {
"^.+\\.(ts|tsx)$": [

View File

@@ -2,16 +2,7 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "export",
debug: process.env.NODE_ENV === "development",
reactStrictMode: false,
webpack: (config, { isServer }) => {
// Resolve workspace packages
config.resolve.alias = {
...config.resolve.alias,
};
return config;
},
reactStrictMode: true,
};
export default nextConfig;

View File

@@ -4,55 +4,54 @@
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build --no-lint",
"build": "next build",
"start": "next start",
"lint": "next lint",
"lint": "eslint .",
"build-theme": "cunningham -g css,scss,ts -o src/styles && mv src/styles/cunningham-tokens.scss src/styles/cunningham-tokens-sass.scss",
"test": "jest",
"test:watch": "jest --watch"
},
"engines": {
"node": ">=22.0.0 <25.0.0",
"yarn": "1.22.22"
"node": ">=24.0.0 <25.0.0",
"npm": ">=10.0.0"
},
"dependencies": {
"@event-calendar/core": "^5.2.3",
"@event-calendar/core": "^5.4.1",
"@gouvfr-lasuite/cunningham-react": "4.2.0",
"@gouvfr-lasuite/ui-kit": "0.19.6",
"@tanstack/react-query": "5.90.10",
"@gouvfr-lasuite/ui-kit": "0.19.10",
"@tanstack/react-query": "5.90.21",
"@tanstack/react-table": "8.21.3",
"@viselect/react": "3.9.0",
"clsx": "2.1.1",
"date-fns": "4.1.0",
"i18next": "25.6.2",
"i18next-browser-languagedetector": "8.2.0",
"i18next": "25.8.14",
"i18next-browser-languagedetector": "8.2.1",
"ical.js": "^2.2.1",
"next": "15.4.9",
"next-i18next": "15.4.2",
"next": "16.1.6",
"next-i18next": "15.4.3",
"pretty-bytes": "7.1.0",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-dropzone": "14.3.8",
"react-hook-form": "7.66.0",
"react-i18next": "16.3.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-dropzone": "15.0.0",
"react-hook-form": "7.71.2",
"react-i18next": "16.5.6",
"react-toastify": "11.0.5",
"sass": "1.94.0",
"ts-ics": "^2.4.0",
"tsdav": "2.1.6"
"sass": "1.97.3",
"ts-ics": "^2.4.2",
"tsdav": "2.1.8"
},
"devDependencies": {
"@eslint/eslintrc": "3.2.0",
"@tanstack/eslint-plugin-query": "5.66.1",
"@tanstack/react-query-devtools": "5.66.9",
"@types/jest": "29.5.14",
"@types/minimatch": "3.0.5",
"@types/node": "24.10.1",
"@types/react": "19.2.5",
"@gouvfr-lasuite/cunningham-tokens": "3.1.0",
"@tanstack/eslint-plugin-query": "5.91.4",
"@tanstack/react-query-devtools": "5.91.3",
"@types/jest": "30.0.0",
"@types/node": "24.12.0",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"eslint": "9.20.1",
"eslint-config-next": "15.1.7",
"jest": "29.7.0",
"ts-jest": "29.2.5",
"typescript": "5.4.5"
"eslint": "9.30.1",
"eslint-config-next": "16.1.6",
"jest": "30.2.0",
"ts-jest": "29.4.6",
"typescript": "5.9.3"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,661 @@
<svg width="1360" height="1360" viewBox="0 0 1360 1360" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_n_340_30467)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M1215.28 1066.31C1247.82 1138.56 1306.59 1028.37 1314.74 1159.78C1326.81 1354.27 1025.2 1321.14 984.444 1294.81C932.993 1261.57 902.424 1336.75 850.805 1273.53C823.881 1240.55 768.574 1295.86 743.571 1219.4C714.816 1131.45 788.694 1043.22 926.467 1036.63C1193.76 1023.85 1175.85 978.763 1215.28 1066.31Z" fill="url(#paint0_radial_340_30467)" fill-opacity="0.8"/>
</g>
<g filter="url(#filter1_n_340_30467)">
<g filter="url(#filter2_d_340_30467)">
<rect x="176.84" y="227.182" width="991" height="591" rx="20" fill="#3E5DE7"/>
<rect x="176.84" y="227.182" width="991" height="591" rx="20" fill="#F6F8F9" fill-opacity="0.8"/>
<rect x="173.34" y="223.682" width="998" height="598" rx="23.5" stroke="#3E5DE7" stroke-width="7"/>
<rect x="173.34" y="223.682" width="998" height="598" rx="23.5" stroke="#F6F8F9" stroke-opacity="0.25" stroke-width="7"/>
</g>
</g>
<g filter="url(#filter3_n_340_30467)">
<g clip-path="url(#clip0_340_30467)">
<rect x="169.152" y="227.381" width="999" height="598" rx="19.6708" fill="white"/>
<rect x="213.854" y="267.381" width="225.121" height="28.4133" rx="4.37128" fill="#8CA3FF"/>
<rect x="666.281" y="267.381" width="67.7548" height="28.4133" rx="4.37128" fill="#8CA3FF"/>
<rect x="449.903" y="267.381" width="28.4133" height="28.4133" rx="4.37128" fill="#DAE2FF"/>
<g clip-path="url(#clip1_340_30467)">
<rect x="213.514" y="388.684" width="911" height="394" rx="7.64974" fill="#DAE2FF"/>
<rect x="213.514" y="388.684" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="343.56" y="388.684" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="473.604" y="388.684" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="603.65" y="388.684" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="733.695" y="388.684" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="863.741" y="388.684" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="993.787" y="388.684" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="213.514" y="424.747" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="343.56" y="424.747" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="473.604" y="424.747" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="603.65" y="424.747" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="733.695" y="424.747" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="863.741" y="424.747" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="993.787" y="424.747" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="213.514" y="460.81" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="343.56" y="460.81" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="473.604" y="460.81" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="603.65" y="460.81" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="733.695" y="460.81" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="863.741" y="460.81" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="993.787" y="460.81" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="213.514" y="496.873" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="343.56" y="496.873" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="473.604" y="496.873" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="603.65" y="496.873" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="733.695" y="496.873" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="863.741" y="496.873" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="993.787" y="496.873" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="213.514" y="532.936" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="343.56" y="532.936" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="473.604" y="532.936" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="603.65" y="532.936" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="733.695" y="532.936" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="863.741" y="532.936" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="993.787" y="532.936" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="213.514" y="568.999" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="343.56" y="568.999" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="473.604" y="568.999" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="603.65" y="568.999" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="733.695" y="568.999" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="863.741" y="568.999" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="993.787" y="568.999" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="213.514" y="605.062" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="343.56" y="605.062" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="473.604" y="605.062" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="603.65" y="605.062" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="733.695" y="605.062" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="863.741" y="605.062" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="993.787" y="605.062" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="213.514" y="641.125" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="343.56" y="641.125" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="473.604" y="641.125" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="603.65" y="641.125" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="733.695" y="641.125" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="863.741" y="641.125" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="993.787" y="641.125" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="213.514" y="677.188" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="343.56" y="677.188" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="473.604" y="677.188" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="603.65" y="677.188" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="733.695" y="677.188" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="863.741" y="677.188" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="993.787" y="677.188" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="213.514" y="713.251" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="343.56" y="713.251" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="473.604" y="713.251" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="603.65" y="713.251" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="733.695" y="713.251" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="863.741" y="713.251" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="993.787" y="713.251" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="213.514" y="749.314" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="343.56" y="749.314" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="473.604" y="749.314" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="603.65" y="749.314" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="733.695" y="749.314" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="863.741" y="749.314" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="993.787" y="749.314" width="130.046" height="36.063" fill="#F5F7FF" stroke="#EDF1FF" stroke-width="2.18564"/>
<rect x="473.058" y="442.232" width="131.138" height="126.767" rx="6.55692" fill="#7E98FF"/>
<rect x="482.894" y="450.974" width="103.818" height="10.9282" rx="4.37128" fill="#DAE2FF"/>
<rect x="471.965" y="749.312" width="131.138" height="36.063" rx="6.55692" fill="#7E98FF"/>
<rect x="481.797" y="759.148" width="103.818" height="10.9282" rx="4.37128" fill="#DAE2FF"/>
<g clip-path="url(#clip2_340_30467)">
<rect x="535.347" y="518.732" width="58.9955" height="41.4003" rx="20.7002" fill="#FCEEAC"/>
<path d="M562.701 551.425C562.663 551.649 558.97 552.046 558.97 552.281C558.97 552.287 555.51 552.466 557.445 552.019C555.414 554.201 554.879 557.204 555.326 560.368C556.11 565.932 563.407 568.259 568.97 567.475L581.282 567.835C586.846 567.05 579.816 561.987 579.031 556.424C579.367 553.796 579.607 552.987 577.745 550.442C576.723 549.045 577.535 546.26 577.173 543.695C576.698 540.327 575.359 539.124 574.882 537.411C573.569 533.772 571.857 531.909 569.591 530.348C568.942 529.901 568.413 529.598 567.414 529.023C565.368 527.844 563.803 527.475 561.201 527.475C553.894 527.475 544.591 534.046 547.97 539.488C547.38 544.517 553.894 551.501 561.201 551.501C561.708 551.501 562.208 551.475 562.701 551.425Z" fill="#89300D"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M561.274 553.937C561.274 553.937 563.697 554.008 564.729 553.444L561.403 555.533L561.274 553.937Z" fill="#CF3E83"/>
<path d="M567.162 548.444C568.426 547.34 570.504 546.799 570.507 544.891C570.508 544.221 570.116 543.061 568.669 543.058C566.836 543.056 566.98 545.13 567.002 545.361L567.004 545.373C567.005 545.379 567.004 545.382 567.004 545.382C566.796 545.463 566.587 545.47 566.379 545.414C564.892 545.431 561.115 544.98 559.01 540.281C558.566 540.748 558.066 541.157 557.523 541.503C555.957 542.497 554.027 542.958 552.052 542.728C552.173 545.102 552.869 551.126 555.873 553.125C557.109 553.946 559.357 554.047 561.273 553.937L561.713 559.378C561.713 559.378 569.229 558.67 569.471 557.975C568.471 555.475 565.471 553.881 565.471 551.975C565.471 550.309 565.731 549.078 567.162 548.444Z" fill="#FFEDE1"/>
<path opacity="0.401762" fill-rule="evenodd" clip-rule="evenodd" d="M561.276 553.937C561.276 553.937 563.699 554.008 564.731 553.444L561.405 555.533L561.276 553.937Z" fill="#161616"/>
</g>
<rect x="536.986" y="520.371" width="55.717" height="38.1219" rx="19.0609" stroke="#B7A73F" stroke-width="3.27846"/>
<rect x="863.741" y="424.2" width="131.138" height="126.767" rx="6.55692" fill="#7E98FF"/>
<rect x="872.483" y="434.032" width="103.818" height="10.9282" rx="4.37128" fill="#DAE2FF"/>
<rect x="872.483" y="451.521" width="35.0576" height="10.9282" rx="4.37128" fill="#DAE2FF"/>
<g clip-path="url(#clip3_340_30467)">
<rect x="924.393" y="492.183" width="58.9955" height="41.4003" rx="20.7002" fill="#E1FFFA"/>
<path d="M938.89 521.038C938.89 518.828 943.938 500.115 954.567 500.441C969.312 500.894 971.172 523.736 970.497 526.012C969.96 527.825 969.292 528.768 957.936 528.447C940.88 527.964 938.89 523.017 938.89 521.038Z" fill="#161616"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M955.724 525.182C955.724 525.182 953.479 525.249 952.522 524.72L955.604 526.678L955.724 525.182Z" fill="#CF3E83"/>
<path d="M950.268 520.034C949.097 518.999 947.172 518.492 947.17 516.703C947.169 516.075 947.532 514.987 948.872 514.985C950.571 514.983 950.437 516.927 950.416 517.144L950.414 517.155L950.415 517.155C950.415 517.16 950.414 517.163 950.414 517.163C950.608 517.239 950.801 517.245 950.994 517.194C952.372 517.209 955.87 516.787 957.821 512.382C958.232 512.819 958.695 513.203 959.198 513.527C960.649 514.459 962.438 514.891 964.268 514.676C964.155 516.901 963.51 522.548 960.727 524.421C959.583 525.191 957.499 525.286 955.724 525.182L955.471 528.284L960.019 530.157L939.939 529.978C939.939 529.978 943.25 527.922 945.635 527.609C947.265 527.395 949.838 527.813 949.838 527.813C951.475 527.394 952.52 525.319 952.52 523.533C952.52 521.971 951.595 520.628 950.268 520.034Z" fill="#FFEBC0"/>
<path d="M944.937 527.731C941.527 528.035 938.787 530.207 938.521 531.139H961.015C960 530.27 957.933 528.684 957.783 529.291C957.596 530.049 955.723 530.096 952.257 530.096C948.792 530.096 945.763 527.658 944.937 527.731Z" fill="#D9D9D9"/>
<path opacity="0.401762" fill-rule="evenodd" clip-rule="evenodd" d="M955.72 525.182C955.72 525.182 953.475 525.249 952.519 524.72L955.601 526.678L955.72 525.182Z" fill="#161616"/>
</g>
<rect x="926.032" y="493.822" width="55.717" height="38.1219" rx="19.0609" stroke="#018F83" stroke-width="3.27846"/>
<rect x="603.104" y="623.094" width="130.046" height="125.711" rx="6.55692" fill="#7E98FF"/>
<rect x="611.771" y="632.844" width="102.953" height="10.8372" rx="4.37128" fill="#DAE2FF"/>
<rect x="611.771" y="650.187" width="71.0127" height="10.8372" rx="4.37128" fill="#DAE2FF"/>
<g clip-path="url(#clip4_340_30467)">
<rect x="667.045" y="698.949" width="58.504" height="41.0555" rx="20.5277" fill="white"/>
<rect x="667.045" y="698.949" width="58.504" height="41.0555" rx="20.5277" fill="#ECB299"/>
<path d="M683.54 714.893C683.21 715.98 682.786 717.852 682.784 719.042C682.784 719.195 682.786 719.346 682.792 719.498C682.792 719.498 683.289 722.062 683.54 725.902C683.791 729.742 690.874 731.601 693.849 732.447C696.823 733.294 699.816 732.259 699.816 732.259L704.724 729.146C704.724 729.146 708.22 726.273 708.058 725.902C706.993 723.464 708.067 719.1 708.067 719.1L708.059 719.1C708.059 719.093 708.059 719.086 708.059 719.078C708.069 712.341 702.42 706.872 695.44 706.862C689.97 706.854 685.307 710.201 683.54 714.893Z" fill="#3A3A3A"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M693.395 731.361C693.395 731.361 695.766 731.43 696.776 730.884L693.521 732.905L693.395 731.361Z" fill="#CF3E83"/>
<path d="M701.084 717.02C696.647 715.682 696.299 713.55 696.299 713.55C696.299 713.55 694.93 715.727 692.108 716.357C691.847 716.487 692.391 715.502 692.108 715.057C691.825 714.612 688.831 716.78 688.202 717.02C687.572 717.26 686.271 717.591 686.271 717.591C686.271 717.591 686.271 726.469 689.861 729.185C691.166 729.997 692.211 731.085 693.394 731.361L693.594 734.732L688.384 735.988L686.673 736.734L709.603 736.937L709.049 736.447C709.049 736.447 701.6 736.685 700.312 734.086C699.699 732.851 699.993 730.576 699.993 730.576C699.993 730.576 701.167 728.557 701.792 727.707C704.576 723.92 702.062 719.099 701.084 717.02Z" fill="#845D48"/>
<path opacity="0.395159" fill-rule="evenodd" clip-rule="evenodd" d="M693.399 731.361C693.399 731.361 695.77 731.43 696.78 730.885L693.525 732.905L693.399 731.361Z" fill="#161616"/>
</g>
<rect x="668.684" y="700.588" width="55.2256" height="37.777" rx="18.8885" stroke="#E4794A" stroke-width="3.27846"/>
</g>
<rect x="215.153" y="390.323" width="907.722" height="390.722" rx="6.01051" stroke="#DAE2FF" stroke-width="3.27846"/>
<ellipse cx="217.095" cy="551.633" rx="5.69678" ry="5.65186" fill="#4166F6"/>
<path d="M1123.81 552.633C1124.36 552.633 1124.81 552.185 1124.81 551.633C1124.81 551.081 1124.36 550.633 1123.81 550.633V551.633V552.633ZM220.163 551.633V552.633H1123.81V551.633V550.633H220.163V551.633Z" fill="#4166F6"/>
<rect x="213.854" y="314.372" width="348.609" height="10.9282" rx="4.37128" fill="#DAE2FF"/>
<rect width="75.4046" height="10.9282" rx="4.37128" transform="matrix(-1 0 0 1 316.579 351.528)" fill="#C9D4FF"/>
<rect width="75.4046" height="10.9282" rx="4.37128" transform="matrix(-1 0 0 1 445.531 351.528)" fill="#C9D4FF"/>
<rect width="75.4046" height="10.9282" rx="4.37128" transform="matrix(-1 0 0 1 576.67 351.528)" fill="#C9D4FF"/>
<rect width="75.4046" height="10.9282" rx="4.37128" transform="matrix(-1 0 0 1 706.716 351.528)" fill="#C9D4FF"/>
<rect width="75.4046" height="10.9282" rx="4.37128" transform="matrix(-1 0 0 1 837.854 351.528)" fill="#C9D4FF"/>
<rect width="75.4046" height="10.9282" rx="4.37128" transform="matrix(-1 0 0 1 966.807 351.528)" fill="#C9D4FF"/>
<rect width="75.4046" height="10.9282" rx="4.37128" transform="matrix(-1 0 0 1 1097.95 351.528)" fill="#C9D4FF"/>
<path d="M162.491 688.116H1174.81V874.988H162.491V688.116Z" fill="url(#paint1_linear_340_30467)"/>
</g>
<rect x="170.733" y="228.962" width="995.838" height="594.838" rx="18.0897" stroke="url(#paint2_linear_340_30467)" stroke-width="3.1622"/>
</g>
<g filter="url(#filter4_n_340_30467)">
<path d="M880.178 693.82L879.457 689.818C879.457 689.818 877.835 687.181 876.095 682.025C874.355 676.869 871.842 674.837 869.945 666.092C867.79 656.164 863.915 663.95 864.146 668.833C864.326 672.645 865.944 676.564 866.021 679.065C864.329 678.437 861.266 678.038 852.204 668.329C850.579 666.588 846.708 659.853 846.708 659.853C845.314 658.017 842.6 654.038 840.094 655.052C839.473 655.303 839.25 656.386 839.25 656.386C839.25 656.386 837.606 653.854 835.138 655.009C834.168 655.464 836.506 661.869 836.506 661.869C836.506 661.869 834.236 659.456 832.442 660.382C831.935 660.645 839.391 678.844 839.391 678.844L838.267 674.505L836.295 670.525C836.295 670.525 834.803 668.716 833.075 669.532C832.388 669.856 832.494 674.551 834.258 679.002C837.003 685.926 840.331 690.15 844.088 694.002C848.629 698.657 849.699 700.573 856.807 705.089C857.657 705.629 858.685 706.209 859.774 706.78C866.883 718.504 887.826 752.717 895.752 762.06C905.365 773.393 937.881 789.889 937.881 789.889L951.963 756.384L918.524 741.725L880.178 693.82Z" fill="#F1B793"/>
<path d="M962.594 630.736C962.688 641.25 962.809 663.449 967.931 674.922C972.023 680.295 979.325 676.672 986.426 677.111L984.076 689.099L965.902 694.684L997.589 713.466L1023.48 687.895C1023.48 687.895 1022.25 691.274 1017.85 686.684C1013.45 682.095 1017 670.639 1017 670.639C1022.13 666.999 1029.66 671.058 1030.53 663.931C1030.82 661.46 1023.17 644.183 1017.85 643.53C1017.33 638.757 1011.3 630.196 1011.3 630.196C1010.38 630.433 1001.99 613.64 1001.14 613.218C994.107 612.075 981.694 643.814 971.068 619.333C968.583 626.846 970.142 624.845 962.594 630.736Z" fill="#EFAC84"/>
<path d="M967.853 1135L968.048 1157.41L991.136 1156.02L989.4 1126.31L967.853 1135ZM1052.44 1132.01L1054.71 1154.88L1078.48 1153.62L1073.99 1124.12L1052.44 1132.01Z" fill="#DAE2FF"/>
<path d="M1031.69 1123.5H1028.31L1030.38 1142.81H1087.35L1085.28 1123.5H1081.51L1039.09 807.434L1012.44 807.434L947.624 807.434L953.007 1123.84H950.102V1142.47H1007.07V1123.84H1003.85L1007.07 1005.18L1031.69 1123.5Z" fill="#2F2F3D"/>
<path d="M1011.29 1176.11C1008.65 1174.72 1006.39 1173.12 1004.83 1171.89C1003.29 1170.69 1002.67 1168.64 1003.31 1166.78C1003.76 1165.46 1004.78 1164.42 1006.09 1163.94L1023.55 1157.6L1044.44 1142.79L1052.74 1142.68C1052.74 1142.68 1054.88 1142.55 1055.91 1145.16C1056.37 1146.45 1056.35 1147.55 1055.86 1148.43C1055.53 1149.06 1055.03 1149.43 1054.6 1149.65C1055.16 1150.17 1056.97 1150.01 1062.07 1150.46C1068.97 1150.08 1070.43 1144.52 1070.45 1144.19L1070.48 1143.94L1070.61 1142.79L1076.99 1142.79C1076.99 1142.79 1078.39 1142.39 1081.69 1161.17C1081.96 1161.8 1083.75 1166.49 1083.08 1171.12C1082.34 1176.18 1060.5 1175.98 1056.13 1175.9C1055.99 1175.93 1031.14 1179.45 1021.82 1179.25C1018.12 1179.17 1014.43 1177.79 1011.28 1176.13L1011.29 1176.11Z" fill="#001875"/>
<path d="M928.692 1167.54C925.888 1165.96 923.474 1164.22 921.8 1162.9C920.148 1161.6 919.443 1159.54 920.065 1157.75C920.5 1156.48 921.545 1155.54 922.908 1155.16L941.074 1150.22L953.184 1142.38L961.49 1142.38C964.134 1142.38 971.759 1142.38 971.759 1142.38C972.274 1143.69 972.247 1143.31 971.759 1144.15C971.429 1144.73 970.91 1145.06 970.466 1145.25C971.069 1145.8 972.931 1148.55 978.295 1149.38C987.497 1149.38 988.259 1142.7 988.281 1142.38C991.816 1142.38 992.66 1142.38 992.66 1142.38C992.513 1142.38 994.591 1140.98 997.609 1157.68C997.904 1158.32 999.916 1163.08 999.329 1167.59C998.691 1172.51 977.368 1171.62 972.78 1171.21C972.63 1171.23 949.625 1172.3 939.839 1171.41C935.952 1171.06 932.032 1169.43 928.685 1167.55L928.692 1167.54Z" fill="#001875"/>
<path d="M1014.92 785.456L1023.22 810.05C1023.22 810.05 1099.93 811.548 1101.47 782.901C1102.45 764.655 1085.53 734.473 1085.53 734.473C1085.53 734.473 1065.48 702.45 1042.3 691.643C1037.55 689.425 1026.02 687.575 1019.03 686.812C1016.89 686.578 1018.17 687.412 1017.43 688.825C1016.66 690.3 1014.32 692.486 1012.71 694.12C1009.34 697.52 1002.7 700.096 999.134 700.623C996.417 701.024 991.166 701.648 988.64 701.309C986.114 700.969 983.368 699.83 982.634 699.153C981.9 698.476 980.808 697.628 980.241 696.323C979.675 695.018 979.704 695.392 979.704 694.188C979.704 692.985 979.766 693.163 979.884 692.59C980.002 692.017 980.002 691.672 980.071 691.376C980.379 690.066 980.241 690.244 980.241 690.244C966.561 694.374 959.631 695.902 954.648 701.91L916.561 728.623L890.501 694.638L856.876 717.201C856.876 717.201 886.648 772.636 908.224 778.765C925.605 783.701 943.212 768.348 949.403 762.19L942.047 799.508C940.869 805.951 946.514 808.389 951.543 808.161L1014.92 785.456Z" fill="#FF5655"/>
<g opacity="0.20459" filter="url(#filter5_n_340_30467)">
<path d="M1019.85 788.532L1020.45 790.325L1032.64 786.27L1032.05 784.478L1019.85 788.532Z" fill="#161616"/>
</g>
<g opacity="0.20459" filter="url(#filter6_n_340_30467)">
<path d="M1018.68 784.485L1019.28 786.277L1031.47 782.223L1030.88 780.43L1018.68 784.485Z" fill="#161616"/>
</g>
<g opacity="0.20459" filter="url(#filter7_n_340_30467)">
<path d="M1020.85 792.708L1021.45 794.501L1033.64 790.446L1033.05 788.654L1020.85 792.708Z" fill="#161616"/>
</g>
<g opacity="0.20459" filter="url(#filter8_n_340_30467)">
<path d="M1022.04 796.798L1022.54 798.619L1034.93 795.2L1034.43 793.379L1022.04 796.798Z" fill="#161616"/>
</g>
<g opacity="0.20459" filter="url(#filter9_n_340_30467)">
<path d="M1023.14 801.329L1023.67 803.142L1036 799.542L1035.47 797.729L1023.14 801.329Z" fill="#161616"/>
</g>
<g opacity="0.20459" filter="url(#filter10_n_340_30467)">
<path d="M1024.32 805.645L1024.85 807.461L1037.2 803.916L1036.68 802.101L1024.32 805.645Z" fill="#161616"/>
</g>
<path opacity="0.201242" fill-rule="evenodd" clip-rule="evenodd" d="M949.519 762.213L954.071 744.234L941.78 768.805L949.519 762.213Z" fill="#161616"/>
<path opacity="0.6" d="M1021.78 831.615L1041.5 847.817L1044.07 844.689L1024.35 828.487L1021.78 831.615Z" fill="#161616"/>
<g opacity="0.20459" filter="url(#filter11_n_340_30467)">
<path d="M885.411 698.135L883.912 699.284L891.729 709.483L893.228 708.334L885.411 698.135Z" fill="#161616"/>
</g>
<g opacity="0.20459" filter="url(#filter12_n_340_30467)">
<path d="M888.854 695.707L887.355 696.856L895.173 707.055L896.672 705.905L888.854 695.707Z" fill="#161616"/>
</g>
<g opacity="0.20459" filter="url(#filter13_n_340_30467)">
<path d="M881.79 700.446L880.29 701.595L888.108 711.793L889.607 710.644L881.79 700.446Z" fill="#161616"/>
</g>
<g opacity="0.20459" filter="url(#filter14_n_340_30467)">
<path d="M878.311 702.901L876.754 703.971L884.034 714.56L885.591 713.49L878.311 702.901Z" fill="#161616"/>
</g>
<g opacity="0.20459" filter="url(#filter15_n_340_30467)">
<path d="M874.391 705.424L872.85 706.516L880.285 716.998L881.825 715.905L874.391 705.424Z" fill="#161616"/>
</g>
<g opacity="0.20459" filter="url(#filter16_n_340_30467)">
<path d="M870.701 707.954L869.155 709.04L876.542 719.555L878.087 718.468L870.701 707.954Z" fill="#161616"/>
</g>
<g opacity="0.20459" filter="url(#filter17_n_340_30467)">
<path d="M866.749 710.604L865.249 711.753L873.067 721.952L874.566 720.802L866.749 710.604Z" fill="#161616"/>
</g>
<g opacity="0.20459" filter="url(#filter18_n_340_30467)">
<path d="M863.057 713.066L861.515 714.157L868.943 724.644L870.484 723.552L863.057 713.066Z" fill="#161616"/>
</g>
<g opacity="0.20459" filter="url(#filter19_n_340_30467)">
<path d="M859.743 715.401L858.131 716.386L864.831 727.352L866.443 726.366L859.743 715.401Z" fill="#161616"/>
</g>
<path fill-rule="evenodd" clip-rule="evenodd" d="M1015.4 595.15C1009.58 594.284 1001.26 595.434 992.416 596.643C971.951 599.44 962.844 610.93 960.702 623.849C960.696 623.845 960.693 623.843 960.693 623.843C960.086 625.664 959.889 630.501 956.808 629.125C956.485 630.117 958.418 632.021 960.503 632.394C960.565 632.418 960.634 632.428 960.702 632.425C960.896 632.45 961.091 632.463 961.285 632.459C962.403 632.433 964.555 632.148 966.007 630.343C969.826 629.682 974.408 629.866 977.395 629.176C982.476 628 982.489 632.116 995.196 634.023C1007.44 635.861 995.196 626.454 1002.53 631.202C1008.27 635.628 1006.26 654.254 1016.53 645.727C1018.29 645.353 1022.3 645.375 1024.18 648.459C1026.53 652.313 1028.01 659.18 1015.55 663.596C1015.55 663.596 1014.68 675.45 1015.55 678.684C1016.42 681.918 1027.07 675.655 1028.99 672.796C1031.39 669.222 1030.61 667.934 1032.9 665.717C1035.19 663.499 1042.07 657.277 1041.73 650.553C1041.4 643.83 1042.3 631.331 1038.72 621.593C1037.29 617.704 1042.23 619.445 1041.27 618.444C1034.85 611.753 1034.2 609.975 1034.19 609.968C1032.43 607.079 1027.96 601.071 1024.18 600.14C1020.4 599.21 1019.8 596.573 1015.4 595.15Z" fill="#221811"/>
<path opacity="0.6" fill-rule="evenodd" clip-rule="evenodd" d="M1006.84 1005.88L983.077 852.672L1005.68 1048.65L1006.84 1005.88Z" fill="#161616"/>
<path opacity="0.6" d="M1031.58 1124.19L1081.38 1124.19L1080.88 1120.36L1030.79 1120.37L1031.58 1124.19Z" fill="#161616"/>
<path opacity="0.6" d="M952.907 1124.58L1003.62 1124.58L1003.82 1120.47L952.902 1120.46L952.907 1124.58Z" fill="#161616"/>
<path opacity="0.201149" d="M967.84 809.198C965.178 775.395 946.756 768.248 960.03 730.338" stroke="#161616" stroke-width="3"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M939.408 840.834L1002.9 833.416C1005.17 833.154 1006.66 830.926 1006.04 828.73L983.704 748.137C983.209 746.39 981.522 745.26 979.718 745.468L916.222 752.885C913.955 753.146 912.462 755.376 913.084 757.571L935.422 838.164C935.917 839.911 937.605 841.041 939.408 840.834Z" fill="#18181C"/>
<path d="M997.97 814.903C997.838 814.938 997.705 814.974 997.57 815.012C992.76 816.325 987.845 817.119 981.007 816.218C976.609 815.643 972.829 814.891 972.871 814.189C972.992 812.416 975.127 812.019 975.127 812.019L979.234 812.217L983.184 811.983C983.184 811.983 965.033 808.55 965.033 808.018C965.09 806.146 968.069 805.386 968.069 805.386C968.069 805.386 961.827 804.393 961.899 803.4C962.069 800.876 964.856 800.684 964.856 800.684C964.856 800.684 964.082 800.017 964.16 799.4C964.466 796.91 968.906 796.498 971.02 796.194C971.02 796.194 978.212 796.095 980.354 795.562C985.988 794.156 989.341 792.899 991.445 791.85L980.355 751.962C979.86 750.216 978.172 749.086 976.369 749.293L917.205 756.267C914.938 756.527 913.445 758.757 914.067 760.952L934.82 838.42C935.315 840.167 937.002 841.297 938.806 841.09L997.97 834.117C1000.24 833.855 1001.73 831.626 1001.11 829.431L997.97 814.903Z" fill="#FCBDA4"/>
<path d="M994.64 788.343C994.845 788.492 995.081 788.994 995.259 789.187C995.123 789.3 994.988 789.428 994.832 789.57C993.52 790.768 991.038 792.896 980.355 795.563C978.223 796.093 971.088 796.194 971.021 796.195C968.908 796.499 964.467 796.91 964.161 799.4C964.084 800.01 964.839 800.669 964.856 800.684C964.83 800.686 962.069 800.888 961.899 803.401C961.829 804.394 968.07 805.387 968.07 805.387C968.07 805.387 965.091 806.146 965.034 808.019C965.044 808.552 983.186 811.984 983.186 811.984L979.234 812.217L975.128 812.019C975.128 812.019 972.993 812.417 972.872 814.19C972.831 814.891 976.611 815.643 981.009 816.218C987.846 817.119 992.762 816.324 997.571 815.012C997.975 814.898 998.365 814.799 998.74 814.7C1003.69 813.402 1005.9 813.16 1012.27 809.692C1015.09 808.153 1019.25 805.103 1020.99 803.557L1016.4 786.575L1010.08 784.413C1010.06 784.406 1007.12 783.26 1002.18 782.348C999.067 781.774 996.028 782.001 992.814 781.654L994.64 788.343Z" fill="#EFAC84"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M972.653 759.106C973.198 760.818 974.361 761.977 975.251 761.694C976.141 761.411 976.421 759.793 975.876 758.08C975.331 756.367 974.168 755.208 973.277 755.491C972.387 755.775 972.108 757.393 972.653 759.106Z" fill="#020101"/>
<path opacity="0.200498" d="M1041.29 735.532L1058.76 764.464L1015.32 781.654" stroke="#161616" stroke-width="3"/>
</g>
<g filter="url(#filter20_n_340_30467)">
<g filter="url(#filter21_f_340_30467)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M540.777 1089.83C567.818 1128.54 627.357 1072.23 629.094 1141.34C631.664 1243.64 352.733 1218.7 316.017 1203.87C269.662 1185.15 237.9 1223.81 192.721 1189.36C169.157 1171.39 172.63 1175.87 152.785 1135.14C129.962 1088.3 145.217 1067.03 273.595 1067.03C522.663 1067.03 508.015 1042.93 540.777 1089.83Z" fill="url(#paint3_radial_340_30467)" fill-opacity="0.8"/>
</g>
<g filter="url(#filter22_n_340_30467)">
<path d="M474.357 592.659C474.203 587.305 474.769 587.733 474.9 584.38C475.01 581.56 475.362 576.535 475.592 574.072C475.696 569.645 474.573 565.034 475.665 564.896C480.575 564.275 480.221 568.029 480.944 570.623L481.209 572.316C482.205 569.597 484.535 564.772 485.936 561.593L485.993 561.462C487.762 557.449 490.707 550.764 494.429 552.803C495.955 553.639 492.992 558.739 491.765 561.428C490.804 563.532 488.49 568.143 487.457 571.159C488.147 569.441 490.684 564.457 491.677 562.196C492.432 560.473 494.548 556.526 494.966 555.747L494.979 555.723C496.401 553.071 498.942 551.795 500.033 552.22C501.807 552.911 501.624 554.45 500.524 557.779C500.141 558.939 494.478 570.847 493.694 573.542L499.043 561.654C500.528 558.452 502.229 558.12 503.909 559.116C505.619 560.131 500.826 572.081 498.985 576.488L502.938 567.167C502.938 567.167 503.982 564.252 505.397 564.456C506.958 564.681 506.481 568.348 506.481 568.348L504.908 574.072C504.908 574.072 502.005 581.35 500.524 587.005C499.043 592.659 492.281 599.141 492.281 599.141C492.281 599.141 481.742 633.664 472.509 654.5C465.023 671.395 431.44 688.962 431.44 688.962C431.44 688.962 430.285 662.638 427.678 661.928C427.678 661.928 445.837 651.066 452.143 644.819C454.966 642.021 475.122 597.109 475.122 597.109L474.357 592.659Z" fill="#EAC7B2"/>
</g>
<path fill-rule="evenodd" clip-rule="evenodd" d="M315.279 844.843L327.372 839.856L315.903 774.617L333.789 737.133L307.936 722.351C307.936 722.351 295.156 758.383 294.8 770.159C294.176 790.768 315.279 844.843 315.279 844.843Z" fill="#EAC7B2"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M300.423 729.843L334.873 741.565L345.11 700.979L318.085 692.753L300.423 729.843Z" fill="#E3B965"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M428.206 655.805L433.101 699.846L413.406 709.753C413.406 709.753 406.328 725.247 401.058 733.284C393.308 745.098 386.363 776.517 386.363 776.517H322.676L322.267 751.865C322.267 751.865 306.523 693.087 321.703 686.442C337.228 679.641 344.815 681.621 367.563 681.655C388.977 681.691 428.206 655.805 428.206 655.805Z" fill="#FFDE9B"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M325.712 1098.78L357.282 1107.26L392.782 871.76C397.546 842.961 396.149 813.478 388.68 785.259L386.363 776.519H340.373L330.628 948.588L325.712 1098.78Z" fill="#00786D"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M276.354 1105.76L309.356 1103.21L330.629 948.588L358.146 776.519H322.676L310.629 794.497C303.104 805.74 298.526 818.691 297.322 832.168L276.354 1105.76Z" fill="#00786D"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M328.618 1100.5L326.418 1113.78L348.813 1121.69L349.869 1107.82L328.618 1100.5Z" fill="#EAC7B2"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M280.314 1097.36L278.021 1110.57L300.082 1118.22L301.25 1104.44L280.314 1097.36Z" fill="#EAC7B2"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M318.844 1107.68L360.758 1115.41L362.961 1103.46L321.047 1095.73L318.844 1107.68Z" fill="#0E4D47"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M270.674 1105.24L312.587 1112.28L314.79 1101.39L272.876 1094.35L270.674 1105.24Z" fill="#0E4D47"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M315.426 642.165C317.378 664.061 344.648 675.45 365.977 668.863C385.82 662.737 394.202 643.987 390.442 624.059C390.164 622.561 393.152 618.107 392.084 614.649C388.611 603.413 387.694 600.228 377.854 595.537C369.019 591.322 360.099 584.91 342.123 591.622C323.574 601.067 306.251 616.055 315.426 642.165Z" fill="#6A2601"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M378.516 675.787C378.516 675.787 396.005 668.237 398.057 661.509C403.833 642.568 376.484 605.422 376.484 605.422L343.485 645.767C343.485 645.767 348.744 685.377 348.872 685.377C348.971 685.377 378.163 685.029 378.163 685.029L378.516 675.787Z" fill="#EAC7B2"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M341.85 650.39C352.264 657.887 366.32 656.874 376.225 648.794C377.015 648.16 377.772 647.48 378.501 646.746C379.688 645.584 380.784 644.292 381.786 642.901C391.279 629.72 388.791 611.706 376.226 602.659C375.017 601.789 373.761 601.032 372.467 600.397C360.294 594.348 344.86 598.224 336.278 610.139C326.875 623.196 329.237 640.992 341.492 650.122C341.609 650.216 341.729 650.303 341.85 650.39Z" fill="#6A2601"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M330.633 719.022C346.827 726.776 353.765 718.938 366.108 722.742C369.876 723.903 375.364 726.776 375.364 726.776C375.364 726.776 386.327 712.152 386.534 702.699C386.743 693.15 381.301 678.809 381.301 678.809L382.222 631.502L313.612 630.248C313.612 630.248 316.767 654.423 316.165 669.948C315.676 682.576 313.455 681.019 313.21 693.607C312.965 706.195 319.991 713.926 330.633 719.022Z" fill="#6A2601"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M342.132 828.137L337.185 842.392L330.57 948.957L342.132 828.137Z" fill="#0D0D11" fill-opacity="0.4"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M382.382 647.224C382.382 647.224 383.251 638.528 377.135 638.537C372.578 638.543 373.443 643.089 373.447 645.594C373.46 654.053 381.689 655.183 381.689 659.405" fill="#EAC7B2"/>
</g>
<g filter="url(#filter23_n_340_30467)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M273.431 1115.97C272.415 1119.49 266.285 1131.91 268.681 1136.02C274.852 1146.61 296.6 1158.39 300.679 1158.39C308.517 1158.39 319.394 1157.44 319.395 1155.52C319.395 1151.69 319.154 1146.56 314.949 1144.59C309.743 1142.16 310.113 1143.85 308.131 1142.24C305.61 1140.19 300.583 1127.97 300.583 1125.43C300.583 1123.23 303.646 1115.53 300.65 1113.26C297.654 1110.98 288.311 1112.76 282.597 1110.89C279.865 1110 278.418 1105.78 276.217 1107.81C273.774 1110.07 274.448 1112.44 273.431 1115.97Z" fill="#8D0F0C"/>
</g>
<g filter="url(#filter24_n_340_30467)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M322.552 1119.8C321.535 1123.33 315.405 1133.74 317.801 1137.85C323.972 1148.45 345.72 1160.23 349.8 1160.23C357.637 1160.23 369.514 1160.28 369.515 1158.35C369.515 1154.53 369.274 1149.39 365.069 1147.43C359.864 1144.99 359.233 1145.68 357.251 1144.07C354.73 1142.02 348.704 1131.81 348.704 1129.26C348.704 1127.07 352.767 1119.36 349.771 1117.09C346.775 1114.82 337.431 1116.59 331.718 1114.72C328.985 1113.83 327.539 1109.61 325.337 1111.64C322.895 1113.9 323.568 1116.28 322.552 1119.8Z" fill="#8D0F0C"/>
</g>
<defs>
<filter id="filter0_n_340_30467" x="737.418" y="1013.63" width="577.676" height="301.327" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feTurbulence type="fractalNoise" baseFrequency="1 1" stitchTiles="stitch" numOctaves="3" result="noise" seed="9422" />
<feColorMatrix in="noise" type="luminanceToAlpha" result="alphaNoise" />
<feComponentTransfer in="alphaNoise" result="coloredNoise1">
<feFuncA type="discrete" tableValues="0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 "/>
</feComponentTransfer>
<feComposite operator="in" in2="shape" in="coloredNoise1" result="noise1Clipped" />
<feFlood flood-color="rgba(131, 131, 131, 0.1)" result="color1Flood" />
<feComposite operator="in" in2="noise1Clipped" in="color1Flood" result="color1" />
<feMerge result="effect1_noise_340_30467">
<feMergeNode in="shape" />
<feMergeNode in="color1" />
</feMerge>
</filter>
<filter id="filter1_n_340_30467" x="169.84" y="220.182" width="1005" height="605" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feTurbulence type="fractalNoise" baseFrequency="1 1" stitchTiles="stitch" numOctaves="3" result="noise" seed="9422" />
<feColorMatrix in="noise" type="luminanceToAlpha" result="alphaNoise" />
<feComponentTransfer in="alphaNoise" result="coloredNoise1">
<feFuncA type="discrete" tableValues="0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 "/>
</feComponentTransfer>
<feComposite operator="in" in2="shape" in="coloredNoise1" result="noise1Clipped" />
<feFlood flood-color="rgba(131, 131, 131, 0.1)" result="color1Flood" />
<feComposite operator="in" in2="noise1Clipped" in="color1Flood" result="color1" />
<feMerge result="effect1_noise_340_30467">
<feMergeNode in="shape" />
<feMergeNode in="color1" />
</feMerge>
</filter>
<filter id="filter2_d_340_30467" x="84.1398" y="138.482" width="1176.4" height="776.4" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="42.85"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.784314 0 0 0 0 0.827451 0 0 0 0 1 0 0 0 1 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_340_30467"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_340_30467" result="shape"/>
</filter>
<filter id="filter3_n_340_30467" x="169.152" y="227.381" width="999" height="598" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feTurbulence type="fractalNoise" baseFrequency="1 1" stitchTiles="stitch" numOctaves="3" result="noise" seed="9422" />
<feColorMatrix in="noise" type="luminanceToAlpha" result="alphaNoise" />
<feComponentTransfer in="alphaNoise" result="coloredNoise1">
<feFuncA type="discrete" tableValues="0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 "/>
</feComponentTransfer>
<feComposite operator="in" in2="shape" in="coloredNoise1" result="noise1Clipped" />
<feFlood flood-color="rgba(131, 131, 131, 0.1)" result="color1Flood" />
<feComposite operator="in" in2="noise1Clipped" in="color1Flood" result="color1" />
<feMerge result="effect1_noise_340_30467">
<feMergeNode in="shape" />
<feMergeNode in="color1" />
</feMerge>
</filter>
<filter id="filter4_n_340_30467" x="832.417" y="594.848" width="288.999" height="584.41" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feTurbulence type="fractalNoise" baseFrequency="1 1" stitchTiles="stitch" numOctaves="3" result="noise" seed="9422" />
<feColorMatrix in="noise" type="luminanceToAlpha" result="alphaNoise" />
<feComponentTransfer in="alphaNoise" result="coloredNoise1">
<feFuncA type="discrete" tableValues="0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 "/>
</feComponentTransfer>
<feComposite operator="in" in2="shape" in="coloredNoise1" result="noise1Clipped" />
<feFlood flood-color="rgba(131, 131, 131, 0.1)" result="color1Flood" />
<feComposite operator="in" in2="noise1Clipped" in="color1Flood" result="color1" />
<feMerge result="effect1_noise_340_30467">
<feMergeNode in="shape" />
<feMergeNode in="color1" />
</feMerge>
</filter>
<filter id="filter5_n_340_30467" x="1019.85" y="784.478" width="12.79" height="5.84668" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feTurbulence type="fractalNoise" baseFrequency="4 4" stitchTiles="stitch" numOctaves="3" result="noise" seed="9427" />
<feColorMatrix in="noise" type="luminanceToAlpha" result="alphaNoise" />
<feComponentTransfer in="alphaNoise" result="coloredNoise1">
<feFuncA type="discrete" tableValues="0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 "/>
</feComponentTransfer>
<feComposite operator="in" in2="shape" in="coloredNoise1" result="noise1Clipped" />
<feFlood flood-color="rgba(255, 255, 255, 0.4)" result="color1Flood" />
<feComposite operator="in" in2="noise1Clipped" in="color1Flood" result="color1" />
<feMerge result="effect1_noise_340_30467">
<feMergeNode in="shape" />
<feMergeNode in="color1" />
</feMerge>
</filter>
<filter id="filter6_n_340_30467" x="1018.68" y="780.431" width="12.79" height="5.84668" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feTurbulence type="fractalNoise" baseFrequency="4 4" stitchTiles="stitch" numOctaves="3" result="noise" seed="9427" />
<feColorMatrix in="noise" type="luminanceToAlpha" result="alphaNoise" />
<feComponentTransfer in="alphaNoise" result="coloredNoise1">
<feFuncA type="discrete" tableValues="0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 "/>
</feComponentTransfer>
<feComposite operator="in" in2="shape" in="coloredNoise1" result="noise1Clipped" />
<feFlood flood-color="rgba(255, 255, 255, 0.4)" result="color1Flood" />
<feComposite operator="in" in2="noise1Clipped" in="color1Flood" result="color1" />
<feMerge result="effect1_noise_340_30467">
<feMergeNode in="shape" />
<feMergeNode in="color1" />
</feMerge>
</filter>
<filter id="filter7_n_340_30467" x="1020.85" y="788.654" width="12.79" height="5.84668" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feTurbulence type="fractalNoise" baseFrequency="4 4" stitchTiles="stitch" numOctaves="3" result="noise" seed="9427" />
<feColorMatrix in="noise" type="luminanceToAlpha" result="alphaNoise" />
<feComponentTransfer in="alphaNoise" result="coloredNoise1">
<feFuncA type="discrete" tableValues="0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 "/>
</feComponentTransfer>
<feComposite operator="in" in2="shape" in="coloredNoise1" result="noise1Clipped" />
<feFlood flood-color="rgba(255, 255, 255, 0.4)" result="color1Flood" />
<feComposite operator="in" in2="noise1Clipped" in="color1Flood" result="color1" />
<feMerge result="effect1_noise_340_30467">
<feMergeNode in="shape" />
<feMergeNode in="color1" />
</feMerge>
</filter>
<filter id="filter8_n_340_30467" x="1022.04" y="793.379" width="12.8896" height="5.23975" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feTurbulence type="fractalNoise" baseFrequency="4 4" stitchTiles="stitch" numOctaves="3" result="noise" seed="9427" />
<feColorMatrix in="noise" type="luminanceToAlpha" result="alphaNoise" />
<feComponentTransfer in="alphaNoise" result="coloredNoise1">
<feFuncA type="discrete" tableValues="0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 "/>
</feComponentTransfer>
<feComposite operator="in" in2="shape" in="coloredNoise1" result="noise1Clipped" />
<feFlood flood-color="rgba(255, 255, 255, 0.4)" result="color1Flood" />
<feComposite operator="in" in2="noise1Clipped" in="color1Flood" result="color1" />
<feMerge result="effect1_noise_340_30467">
<feMergeNode in="shape" />
<feMergeNode in="color1" />
</feMerge>
</filter>
<filter id="filter9_n_340_30467" x="1023.14" y="797.729" width="12.8643" height="5.41309" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feTurbulence type="fractalNoise" baseFrequency="4 4" stitchTiles="stitch" numOctaves="3" result="noise" seed="9427" />
<feColorMatrix in="noise" type="luminanceToAlpha" result="alphaNoise" />
<feComponentTransfer in="alphaNoise" result="coloredNoise1">
<feFuncA type="discrete" tableValues="0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 "/>
</feComponentTransfer>
<feComposite operator="in" in2="shape" in="coloredNoise1" result="noise1Clipped" />
<feFlood flood-color="rgba(255, 255, 255, 0.4)" result="color1Flood" />
<feComposite operator="in" in2="noise1Clipped" in="color1Flood" result="color1" />
<feMerge result="effect1_noise_340_30467">
<feMergeNode in="shape" />
<feMergeNode in="color1" />
</feMerge>
</filter>
<filter id="filter10_n_340_30467" x="1024.33" y="802.1" width="12.8721" height="5.36035" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feTurbulence type="fractalNoise" baseFrequency="4 4" stitchTiles="stitch" numOctaves="3" result="noise" seed="9427" />
<feColorMatrix in="noise" type="luminanceToAlpha" result="alphaNoise" />
<feComponentTransfer in="alphaNoise" result="coloredNoise1">
<feFuncA type="discrete" tableValues="0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 "/>
</feComponentTransfer>
<feComposite operator="in" in2="shape" in="coloredNoise1" result="noise1Clipped" />
<feFlood flood-color="rgba(255, 255, 255, 0.4)" result="color1Flood" />
<feComposite operator="in" in2="noise1Clipped" in="color1Flood" result="color1" />
<feMerge result="effect1_noise_340_30467">
<feMergeNode in="shape" />
<feMergeNode in="color1" />
</feMerge>
</filter>
<filter id="filter11_n_340_30467" x="883.911" y="698.135" width="9.31738" height="11.3477" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feTurbulence type="fractalNoise" baseFrequency="4 4" stitchTiles="stitch" numOctaves="3" result="noise" seed="9427" />
<feColorMatrix in="noise" type="luminanceToAlpha" result="alphaNoise" />
<feComponentTransfer in="alphaNoise" result="coloredNoise1">
<feFuncA type="discrete" tableValues="0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 "/>
</feComponentTransfer>
<feComposite operator="in" in2="shape" in="coloredNoise1" result="noise1Clipped" />
<feFlood flood-color="rgba(255, 255, 255, 0.4)" result="color1Flood" />
<feComposite operator="in" in2="noise1Clipped" in="color1Flood" result="color1" />
<feMerge result="effect1_noise_340_30467">
<feMergeNode in="shape" />
<feMergeNode in="color1" />
</feMerge>
</filter>
<filter id="filter12_n_340_30467" x="887.354" y="695.707" width="9.31738" height="11.3477" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feTurbulence type="fractalNoise" baseFrequency="4 4" stitchTiles="stitch" numOctaves="3" result="noise" seed="9427" />
<feColorMatrix in="noise" type="luminanceToAlpha" result="alphaNoise" />
<feComponentTransfer in="alphaNoise" result="coloredNoise1">
<feFuncA type="discrete" tableValues="0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 "/>
</feComponentTransfer>
<feComposite operator="in" in2="shape" in="coloredNoise1" result="noise1Clipped" />
<feFlood flood-color="rgba(255, 255, 255, 0.4)" result="color1Flood" />
<feComposite operator="in" in2="noise1Clipped" in="color1Flood" result="color1" />
<feMerge result="effect1_noise_340_30467">
<feMergeNode in="shape" />
<feMergeNode in="color1" />
</feMerge>
</filter>
<filter id="filter13_n_340_30467" x="880.29" y="700.446" width="9.31738" height="11.3477" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feTurbulence type="fractalNoise" baseFrequency="4 4" stitchTiles="stitch" numOctaves="3" result="noise" seed="9427" />
<feColorMatrix in="noise" type="luminanceToAlpha" result="alphaNoise" />
<feComponentTransfer in="alphaNoise" result="coloredNoise1">
<feFuncA type="discrete" tableValues="0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 "/>
</feComponentTransfer>
<feComposite operator="in" in2="shape" in="coloredNoise1" result="noise1Clipped" />
<feFlood flood-color="rgba(255, 255, 255, 0.4)" result="color1Flood" />
<feComposite operator="in" in2="noise1Clipped" in="color1Flood" result="color1" />
<feMerge result="effect1_noise_340_30467">
<feMergeNode in="shape" />
<feMergeNode in="color1" />
</feMerge>
</filter>
<filter id="filter14_n_340_30467" x="876.754" y="702.901" width="8.83691" height="11.6592" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feTurbulence type="fractalNoise" baseFrequency="4 4" stitchTiles="stitch" numOctaves="3" result="noise" seed="9427" />
<feColorMatrix in="noise" type="luminanceToAlpha" result="alphaNoise" />
<feComponentTransfer in="alphaNoise" result="coloredNoise1">
<feFuncA type="discrete" tableValues="0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 "/>
</feComponentTransfer>
<feComposite operator="in" in2="shape" in="coloredNoise1" result="noise1Clipped" />
<feFlood flood-color="rgba(255, 255, 255, 0.4)" result="color1Flood" />
<feComposite operator="in" in2="noise1Clipped" in="color1Flood" result="color1" />
<feMerge result="effect1_noise_340_30467">
<feMergeNode in="shape" />
<feMergeNode in="color1" />
</feMerge>
</filter>
<filter id="filter15_n_340_30467" x="872.851" y="705.424" width="8.97461" height="11.5742" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feTurbulence type="fractalNoise" baseFrequency="4 4" stitchTiles="stitch" numOctaves="3" result="noise" seed="9427" />
<feColorMatrix in="noise" type="luminanceToAlpha" result="alphaNoise" />
<feComponentTransfer in="alphaNoise" result="coloredNoise1">
<feFuncA type="discrete" tableValues="0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 "/>
</feComponentTransfer>
<feComposite operator="in" in2="shape" in="coloredNoise1" result="noise1Clipped" />
<feFlood flood-color="rgba(255, 255, 255, 0.4)" result="color1Flood" />
<feComposite operator="in" in2="noise1Clipped" in="color1Flood" result="color1" />
<feMerge result="effect1_noise_340_30467">
<feMergeNode in="shape" />
<feMergeNode in="color1" />
</feMerge>
</filter>
<filter id="filter16_n_340_30467" x="869.154" y="707.954" width="8.93359" height="11.6006" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feTurbulence type="fractalNoise" baseFrequency="4 4" stitchTiles="stitch" numOctaves="3" result="noise" seed="9427" />
<feColorMatrix in="noise" type="luminanceToAlpha" result="alphaNoise" />
<feComponentTransfer in="alphaNoise" result="coloredNoise1">
<feFuncA type="discrete" tableValues="0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 "/>
</feComponentTransfer>
<feComposite operator="in" in2="shape" in="coloredNoise1" result="noise1Clipped" />
<feFlood flood-color="rgba(255, 255, 255, 0.4)" result="color1Flood" />
<feComposite operator="in" in2="noise1Clipped" in="color1Flood" result="color1" />
<feMerge result="effect1_noise_340_30467">
<feMergeNode in="shape" />
<feMergeNode in="color1" />
</feMerge>
</filter>
<filter id="filter17_n_340_30467" x="865.249" y="710.604" width="9.31738" height="11.3477" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feTurbulence type="fractalNoise" baseFrequency="4 4" stitchTiles="stitch" numOctaves="3" result="noise" seed="9427" />
<feColorMatrix in="noise" type="luminanceToAlpha" result="alphaNoise" />
<feComponentTransfer in="alphaNoise" result="coloredNoise1">
<feFuncA type="discrete" tableValues="0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 "/>
</feComponentTransfer>
<feComposite operator="in" in2="shape" in="coloredNoise1" result="noise1Clipped" />
<feFlood flood-color="rgba(255, 255, 255, 0.4)" result="color1Flood" />
<feComposite operator="in" in2="noise1Clipped" in="color1Flood" result="color1" />
<feMerge result="effect1_noise_340_30467">
<feMergeNode in="shape" />
<feMergeNode in="color1" />
</feMerge>
</filter>
<filter id="filter18_n_340_30467" x="861.516" y="713.066" width="8.96875" height="11.5781" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feTurbulence type="fractalNoise" baseFrequency="4 4" stitchTiles="stitch" numOctaves="3" result="noise" seed="9427" />
<feColorMatrix in="noise" type="luminanceToAlpha" result="alphaNoise" />
<feComponentTransfer in="alphaNoise" result="coloredNoise1">
<feFuncA type="discrete" tableValues="0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 "/>
</feComponentTransfer>
<feComposite operator="in" in2="shape" in="coloredNoise1" result="noise1Clipped" />
<feFlood flood-color="rgba(255, 255, 255, 0.4)" result="color1Flood" />
<feComposite operator="in" in2="noise1Clipped" in="color1Flood" result="color1" />
<feMerge result="effect1_noise_340_30467">
<feMergeNode in="shape" />
<feMergeNode in="color1" />
</feMerge>
</filter>
<filter id="filter19_n_340_30467" x="858.132" y="715.402" width="8.31152" height="11.9502" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feTurbulence type="fractalNoise" baseFrequency="4 4" stitchTiles="stitch" numOctaves="3" result="noise" seed="9427" />
<feColorMatrix in="noise" type="luminanceToAlpha" result="alphaNoise" />
<feComponentTransfer in="alphaNoise" result="coloredNoise1">
<feFuncA type="discrete" tableValues="0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 "/>
</feComponentTransfer>
<feComposite operator="in" in2="shape" in="coloredNoise1" result="noise1Clipped" />
<feFlood flood-color="rgba(255, 255, 255, 0.4)" result="color1Flood" />
<feComposite operator="in" in2="noise1Clipped" in="color1Flood" result="color1" />
<feMerge result="effect1_noise_340_30467">
<feMergeNode in="shape" />
<feMergeNode in="color1" />
</feMerge>
</filter>
<filter id="filter20_n_340_30467" x="143.969" y="552.14" width="485.143" height="665.604" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feTurbulence type="fractalNoise" baseFrequency="1 1" stitchTiles="stitch" numOctaves="3" result="noise" seed="9422" />
<feColorMatrix in="noise" type="luminanceToAlpha" result="alphaNoise" />
<feComponentTransfer in="alphaNoise" result="coloredNoise1">
<feFuncA type="discrete" tableValues="0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 "/>
</feComponentTransfer>
<feComposite operator="in" in2="shape" in="coloredNoise1" result="noise1Clipped" />
<feFlood flood-color="rgba(131, 131, 131, 0.1)" result="color1Flood" />
<feComposite operator="in" in2="noise1Clipped" in="color1Flood" result="color1" />
<feMerge result="effect1_noise_340_30467">
<feMergeNode in="shape" />
<feMergeNode in="color1" />
</feMerge>
</filter>
<filter id="filter21_f_340_30467" x="128.969" y="1045.84" width="515.143" height="186.905" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="7.5" result="effect1_foregroundBlur_340_30467"/>
</filter>
<filter id="filter22_n_340_30467" x="427.678" y="552.14" width="78.8721" height="136.822" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feTurbulence type="fractalNoise" baseFrequency="4 4" stitchTiles="stitch" numOctaves="3" result="noise" seed="9427" />
<feColorMatrix in="noise" type="luminanceToAlpha" result="alphaNoise" />
<feComponentTransfer in="alphaNoise" result="coloredNoise1">
<feFuncA type="discrete" tableValues="0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 "/>
</feComponentTransfer>
<feComposite operator="in" in2="shape" in="coloredNoise1" result="noise1Clipped" />
<feFlood flood-color="rgba(255, 255, 255, 0.4)" result="color1Flood" />
<feComposite operator="in" in2="noise1Clipped" in="color1Flood" result="color1" />
<feMerge result="effect1_noise_340_30467">
<feMergeNode in="shape" />
<feMergeNode in="color1" />
</feMerge>
</filter>
<filter id="filter23_n_340_30467" x="268.129" y="1107.26" width="51.2656" height="51.1304" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feTurbulence type="fractalNoise" baseFrequency="1 1" stitchTiles="stitch" numOctaves="3" result="noise" seed="9422" />
<feColorMatrix in="noise" type="luminanceToAlpha" result="alphaNoise" />
<feComponentTransfer in="alphaNoise" result="coloredNoise1">
<feFuncA type="discrete" tableValues="0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 "/>
</feComponentTransfer>
<feComposite operator="in" in2="shape" in="coloredNoise1" result="noise1Clipped" />
<feFlood flood-color="rgba(131, 131, 131, 0.1)" result="color1Flood" />
<feComposite operator="in" in2="noise1Clipped" in="color1Flood" result="color1" />
<feMerge result="effect1_noise_340_30467">
<feMergeNode in="shape" />
<feMergeNode in="color1" />
</feMerge>
</filter>
<filter id="filter24_n_340_30467" x="317.25" y="1111.1" width="52.2646" height="49.1304" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feTurbulence type="fractalNoise" baseFrequency="1 1" stitchTiles="stitch" numOctaves="3" result="noise" seed="9422" />
<feColorMatrix in="noise" type="luminanceToAlpha" result="alphaNoise" />
<feComponentTransfer in="alphaNoise" result="coloredNoise1">
<feFuncA type="discrete" tableValues="0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 "/>
</feComponentTransfer>
<feComposite operator="in" in2="shape" in="coloredNoise1" result="noise1Clipped" />
<feFlood flood-color="rgba(131, 131, 131, 0.1)" result="color1Flood" />
<feComposite operator="in" in2="noise1Clipped" in="color1Flood" result="color1" />
<feMerge result="effect1_noise_340_30467">
<feMergeNode in="shape" />
<feMergeNode in="color1" />
</feMerge>
</filter>
<radialGradient id="paint0_radial_340_30467" cx="0" cy="0" r="1" gradientTransform="matrix(272.189 2.64963 2.67201 96.1337 1035.2 1173.92)" gradientUnits="userSpaceOnUse">
<stop stop-color="#DFE2EA"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</radialGradient>
<linearGradient id="paint1_linear_340_30467" x1="668.652" y1="688.116" x2="668.652" y2="874.988" gradientUnits="userSpaceOnUse">
<stop stop-color="#F8F8FE" stop-opacity="0"/>
<stop offset="1" stop-color="#F8F8FE"/>
</linearGradient>
<linearGradient id="paint2_linear_340_30467" x1="668.359" y1="526.171" x2="668.639" y2="825.381" gradientUnits="userSpaceOnUse">
<stop stop-color="#F8F8FE" stop-opacity="0"/>
<stop offset="1" stop-color="#F8F8FE"/>
</linearGradient>
<radialGradient id="paint3_radial_340_30467" cx="0" cy="0" r="1" gradientTransform="matrix(285.964 -19.5716 8.034 77.6234 386.54 1160.71)" gradientUnits="userSpaceOnUse">
<stop stop-color="#DFE2EA"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</radialGradient>
<clipPath id="clip0_340_30467">
<rect x="169.152" y="227.381" width="999" height="598" rx="19.6708" fill="white"/>
</clipPath>
<clipPath id="clip1_340_30467">
<rect x="213.514" y="388.684" width="911" height="394" rx="7.64974" fill="white"/>
</clipPath>
<clipPath id="clip2_340_30467">
<rect x="535.347" y="518.732" width="58.9955" height="41.4003" rx="20.7002" fill="white"/>
</clipPath>
<clipPath id="clip3_340_30467">
<rect x="924.393" y="492.183" width="58.9955" height="41.4003" rx="20.7002" fill="white"/>
</clipPath>
<clipPath id="clip4_340_30467">
<rect x="667.045" y="698.949" width="58.504" height="41.0555" rx="20.5277" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -30,47 +30,14 @@ export const fetchAPI = async (
});
}
const csrfToken = getCSRFToken();
const isFormData = init?.body instanceof FormData;
const response = await fetch(apiUrl, {
...init,
credentials: "include",
headers: {
...init?.headers,
"Content-Type": "application/json",
...(csrfToken && { "X-CSRFToken": csrfToken }),
},
});
if (response.ok) {
return response;
}
const data = await response.text();
if (isJson(data)) {
throw new APIError(response.status, JSON.parse(data));
}
throw new APIError(response.status);
};
export const fetchAPIFormData = async (
input: string,
init?: RequestInit & { params?: Record<string, string | number> },
) => {
const apiUrl = new URL(`${baseApiUrl("1.0")}${input}`);
if (init?.params) {
Object.entries(init.params).forEach(([key, value]) => {
apiUrl.searchParams.set(key, String(value));
});
}
const csrfToken = getCSRFToken();
const response = await fetch(apiUrl, {
...init,
credentials: "include",
headers: {
...init?.headers,
...(!isFormData && { "Content-Type": "application/json" }),
...(csrfToken && { "X-CSRFToken": csrfToken }),
},
});

Some files were not shown because too many files have changed in this diff Show More