From 81954a4eadda5cfac6a5fd3f075236333e3d4509 Mon Sep 17 00:00:00 2001 From: Sylvain Zimmer Date: Thu, 19 Feb 2026 18:15:47 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(invitations)=20add=20invitation=20RSV?= =?UTF-8?q?P=20links=20in=20HTML=20emails=20(#10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also include many fixes and scalingo deployment --- CLAUDE.md | 21 +- bin/export_pg_vars.sh | 3 + bin/scalingo_postfrontend | 69 +- bin/scalingo_run_web | 4 +- compose.yaml | 6 +- .../development/etc/nginx/conf.d/default.conf | 17 + docker/sabredav/server.php | 2 +- env.d/development/caldav.defaults | 2 +- src/backend/calendars/settings.py | 11 +- src/backend/core/api/viewsets.py | 59 +- src/backend/core/api/viewsets_caldav.py | 44 +- src/backend/core/api/viewsets_ical.py | 104 ++- src/backend/core/api/viewsets_rsvp.py | 164 +++++ src/backend/core/services/caldav_service.py | 291 ++++++-- .../services/calendar_invitation_service.py | 212 ++++-- src/backend/core/services/import_service.py | 40 +- .../core/services/translation_service.py | 152 +++++ .../templates/emails/calendar_invitation.html | 33 +- .../templates/emails/calendar_invitation.txt | 23 +- .../emails/calendar_invitation_cancel.html | 26 +- .../emails/calendar_invitation_cancel.txt | 17 +- .../emails/calendar_invitation_reply.html | 24 +- .../emails/calendar_invitation_reply.txt | 15 +- .../emails/calendar_invitation_update.html | 35 +- .../emails/calendar_invitation_update.txt | 23 +- src/backend/core/templates/rsvp/response.html | 76 +++ src/backend/core/tests/test_caldav_proxy.py | 47 ++ src/backend/core/tests/test_caldav_service.py | 21 + src/backend/core/tests/test_import_events.py | 28 +- src/backend/core/tests/test_rsvp.py | 622 ++++++++++++++++++ .../core/tests/test_translation_service.py | 93 +++ src/backend/core/urls.py | 4 + .../calendar/contexts/CalendarContext.tsx | 35 +- .../calendar/services/dav/CalDavService.ts | 129 +--- .../src/features/calendar/utils/DavClient.ts | 4 +- .../src/features/i18n/translations.json | 318 +++++++++ src/nginx/servers.conf.erb | 31 +- 37 files changed, 2294 insertions(+), 511 deletions(-) create mode 100644 src/backend/core/api/viewsets_rsvp.py create mode 100644 src/backend/core/services/translation_service.py create mode 100644 src/backend/core/templates/rsvp/response.html create mode 100644 src/backend/core/tests/test_rsvp.py create mode 100644 src/backend/core/tests/test_translation_service.py diff --git a/CLAUDE.md b/CLAUDE.md index b6af64e..796e948 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,13 +80,20 @@ Yarn workspaces monorepo: ### CalDAV Server (`docker/sabredav/`) PHP SabreDAV server providing CalDAV protocol support, running against the shared PostgreSQL database. -### Service Ports (Development) -- Frontend: http://localhost:8920 -- Backend API: http://localhost:8921 -- CalDAV: http://localhost:8922 -- Keycloak: http://localhost:8925 -- PostgreSQL: 8926 -- Mailcatcher: http://localhost:1081 +**IMPORTANT: Never query the SabreDAV database tables directly from Django.** Always interact with CalDAV through the SabreDAV HTTP API (PROPFIND, REPORT, PUT, etc.). + +### Development Services + +| Service | URL / Port | Description | +|---------|------------|-------------| +| **Frontend** | [http://localhost:8920](http://localhost:8920) | Next.js Calendar frontend | +| **Backend API** | [http://localhost:8921](http://localhost:8921) | Django REST API | +| **CalDAV** | [http://localhost:8922](http://localhost:8922) | SabreDAV CalDAV server | +| **Nginx** | [http://localhost:8923](http://localhost:8923) | Reverse proxy (frontend + API) | +| **Redis** | 8924 | Cache and Celery broker | +| **Keycloak** | [http://localhost:8925](http://localhost:8925) | OIDC identity provider | +| **PostgreSQL** | 8926 | Database server | +| **Mailcatcher** | [http://localhost:8927](http://localhost:8927) | Email testing interface | ## Key Technologies diff --git a/bin/export_pg_vars.sh b/bin/export_pg_vars.sh index 7988acc..3944862 100755 --- a/bin/export_pg_vars.sh +++ b/bin/export_pg_vars.sh @@ -10,11 +10,14 @@ if [ -n "$DATABASE_URL" ] && [ -z "$PGHOST" ]; then eval "$(python3 -c " import os, urllib.parse u = urllib.parse.urlparse(os.environ['DATABASE_URL']) +qs = dict(urllib.parse.parse_qsl(u.query)) print(f'export PGHOST=\"{u.hostname}\"') print(f'export PGPORT=\"{u.port or 5432}\"') print(f'export PGDATABASE=\"{u.path.lstrip(\"/\")}\"') print(f'export PGUSER=\"{u.username}\"') print(f'export PGPASSWORD=\"{urllib.parse.unquote(u.password)}\"') +if 'sslmode' in qs: + print(f'export PGSSLMODE=\"{qs[\"sslmode\"]}\"') ")" echo "-----> Parsed DATABASE_URL into PG* vars (host=$PGHOST port=$PGPORT db=$PGDATABASE)" fi diff --git a/bin/scalingo_postfrontend b/bin/scalingo_postfrontend index 8df2741..848310a 100644 --- a/bin/scalingo_postfrontend +++ b/bin/scalingo_postfrontend @@ -9,6 +9,8 @@ echo "-----> Running post-frontend script" mkdir -p build/ mv src/frontend/apps/calendars/out build/frontend-out +cp src/frontend/apps/calendars/src/features/i18n/translations.json translations.json + mv src/backend/* ./ mv src/nginx/* ./ @@ -21,31 +23,72 @@ PHP_PREFIX=".php" DEB_DIR="/tmp/php-debs" mkdir -p "$DEB_DIR" "$PHP_PREFIX" -BASE_URL="http://security.ubuntu.com/ubuntu/pool/main/p/php8.3" -VERSION="8.3.6-0ubuntu0.24.04.6" +# Hardcoded Launchpad URLs for PHP 8.3.6-0maysync1 (Ubuntu Noble amd64) +# Source: https://launchpad.net/ubuntu/noble/amd64/php8.3-fpm/8.3.6-0maysync1 +declare -A PHP_DEBS=( + [php8.3-cli]="http://launchpadlibrarian.net/724872605/php8.3-cli_8.3.6-0maysync1_amd64.deb" + [php8.3-fpm]="http://launchpadlibrarian.net/724872610/php8.3-fpm_8.3.6-0maysync1_amd64.deb" + [php8.3-common]="http://launchpadlibrarian.net/724872606/php8.3-common_8.3.6-0maysync1_amd64.deb" + [php8.3-opcache]="http://launchpadlibrarian.net/724872623/php8.3-opcache_8.3.6-0maysync1_amd64.deb" + [php8.3-readline]="http://launchpadlibrarian.net/724872627/php8.3-readline_8.3.6-0maysync1_amd64.deb" + [php8.3-pgsql]="http://launchpadlibrarian.net/724872624/php8.3-pgsql_8.3.6-0maysync1_amd64.deb" + [php8.3-xml]="http://launchpadlibrarian.net/724872633/php8.3-xml_8.3.6-0maysync1_amd64.deb" + [php8.3-mbstring]="http://launchpadlibrarian.net/724872617/php8.3-mbstring_8.3.6-0maysync1_amd64.deb" + [php8.3-curl]="http://launchpadlibrarian.net/724872607/php8.3-curl_8.3.6-0maysync1_amd64.deb" + [php-common]="http://launchpadlibrarian.net/710804987/php-common_93ubuntu2_all.deb" +) -for pkg in cli fpm common opcache readline pgsql xml mbstring curl; do - echo " Downloading php8.3-${pkg}" - curl -fsSL -o "$DEB_DIR/php8.3-${pkg}.deb" \ - "${BASE_URL}/php8.3-${pkg}_${VERSION}_amd64.deb" +for pkg in "${!PHP_DEBS[@]}"; do + echo " Downloading ${pkg}" + curl -fsSL -o "$DEB_DIR/${pkg}.deb" "${PHP_DEBS[$pkg]}" done -curl -fsSL -o "$DEB_DIR/php-common.deb" \ - "http://mirrors.kernel.org/ubuntu/pool/main/p/php-defaults/php-common_93ubuntu2_all.deb" for deb in "$DEB_DIR"/*.deb; do dpkg-deb -x "$deb" "$PHP_PREFIX" done -# Create php wrapper +# Detect PHP extension directory (e.g. .php/usr/lib/php/20230831) +EXT_DIR_NAME="$(ls -1 "$PHP_PREFIX/usr/lib/php/" | grep '^20' | head -1)" +echo " Extension API dir: ${EXT_DIR_NAME}" +echo " Available .so files: $(ls "$PHP_PREFIX/usr/lib/php/$EXT_DIR_NAME/" 2>/dev/null | tr '\n' ' ')" + +# Build a single php.ini that sets extension_dir (relative to /app at runtime) +# then loads every shared extension present. +# Conf.d symlinks from debs are broken (absolute paths to /etc/php/...), +# so we bypass them entirely with a self-contained ini. +PHP_INI="$PHP_PREFIX/php.ini" +{ + echo "; Auto-generated PHP config" + echo "extension_dir = /app/.php/usr/lib/php/${EXT_DIR_NAME}" + echo "" + for so in "$PHP_PREFIX/usr/lib/php/$EXT_DIR_NAME"/*.so; do + [ -f "$so" ] || continue + name="$(basename "$so")" + if [ "$name" = "opcache.so" ]; then + echo "zend_extension = ${name}" + else + echo "extension = ${name}" + fi + done +} > "$PHP_INI" +echo " Generated php.ini:" +cat "$PHP_INI" | sed 's/^/ /' + +# Create a build-time copy with the current path (not /app) +BUILD_INI="/tmp/php-build.ini" +sed "s|/app/.php|$PWD/.php|" "$PHP_INI" > "$BUILD_INI" + +# Create php wrapper (uses /app php.ini at runtime) cat > bin/php << 'WRAPPER' #!/bin/bash DIR="$(cd "$(dirname "$0")/.." && pwd)" -PHP_INI_SCAN_DIR="$DIR/.php/etc/php/8.3/cli/conf.d" \ - exec "$DIR/.php/usr/bin/php8.3" "$@" +exec "$DIR/.php/usr/bin/php8.3" -c "$DIR/.php/php.ini" -n "$@" WRAPPER chmod +x bin/php -echo "-----> PHP version: $(bin/php -v | head -1)" +# For build-time, verify with the build-time ini +echo "-----> PHP version: $("$PHP_PREFIX/usr/bin/php8.3" -n -c "$BUILD_INI" -v | head -1)" +echo "-----> PHP modules: $("$PHP_PREFIX/usr/bin/php8.3" -n -c "$BUILD_INI" -m | tr '\n' ' ')" # Download Composer and install SabreDAV dependencies echo "-----> Installing SabreDAV dependencies" @@ -53,6 +96,6 @@ curl -fsSL -o bin/composer.phar \ https://getcomposer.org/download/latest-stable/composer.phar cp -r docker/sabredav sabredav cd sabredav -../bin/php ../bin/composer.phar install \ +"../$PHP_PREFIX/usr/bin/php8.3" -n -c "$BUILD_INI" ../bin/composer.phar install \ --no-dev --optimize-autoloader --no-interaction cd .. diff --git a/bin/scalingo_run_web b/bin/scalingo_run_web index c33ff5f..60af21c 100644 --- a/bin/scalingo_run_web +++ b/bin/scalingo_run_web @@ -4,8 +4,8 @@ source bin/export_pg_vars.sh # Start PHP-FPM for SabreDAV (CalDAV server) -PHP_INI_SCAN_DIR=/app/.php/etc/php/8.3/cli/conf.d \ - .php/usr/sbin/php-fpm8.3 \ +.php/usr/sbin/php-fpm8.3 \ + -n -c /app/.php/php.ini \ --fpm-config /app/sabredav/php-fpm.conf \ --nodaemonize & diff --git a/compose.yaml b/compose.yaml index e02c558..e092066 100644 --- a/compose.yaml +++ b/compose.yaml @@ -18,12 +18,12 @@ services: redis: image: redis:5 ports: - - "6379:6379" + - "8924:6379" mailcatcher: image: sj26/mailcatcher:latest ports: - - "1081:1080" + - "8927:1080" backend-dev: build: @@ -44,6 +44,7 @@ services: volumes: - ./src/backend:/app - ./data/static:/data/static + - ./src/frontend/apps/calendars/src/features/i18n/translations.json:/data/translations.json:ro - /app/.venv networks: - lasuite @@ -76,6 +77,7 @@ services: volumes: - ./src/backend:/app - ./data/static:/data/static + - ./src/frontend/apps/calendars/src/features/i18n/translations.json:/data/translations.json:ro - /app/.venv nginx: diff --git a/docker/files/development/etc/nginx/conf.d/default.conf b/docker/files/development/etc/nginx/conf.d/default.conf index 698cec4..8b932d7 100644 --- a/docker/files/development/etc/nginx/conf.d/default.conf +++ b/docker/files/development/etc/nginx/conf.d/default.conf @@ -12,6 +12,23 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } + # RSVP and iCal routes - proxy to Django backend + location /rsvp/ { + proxy_pass http://backend-dev:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /ical/ { + proxy_pass http://backend-dev:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + # Frontend - proxy to Next.js dev server location / { proxy_pass http://frontend-dev:3000; diff --git a/docker/sabredav/server.php b/docker/sabredav/server.php index f725344..cf4bfa6 100644 --- a/docker/sabredav/server.php +++ b/docker/sabredav/server.php @@ -21,7 +21,7 @@ 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('CALENDARS_BASE_URI') ?: '/'; +$baseUri = getenv('CALDAV_BASE_URI') ?: '/'; // Database connection from environment variables $dbHost = getenv('PGHOST') ?: 'postgresql'; diff --git a/env.d/development/caldav.defaults b/env.d/development/caldav.defaults index 2bda01d..8ceb8c0 100644 --- a/env.d/development/caldav.defaults +++ b/env.d/development/caldav.defaults @@ -3,7 +3,7 @@ PGPORT=5432 PGDATABASE=calendars PGUSER=pgroot PGPASSWORD=pass -CALENDARS_BASE_URI=/api/v1.0/caldav/ +CALDAV_BASE_URI=/api/v1.0/caldav/ CALDAV_INBOUND_API_KEY=changeme-inbound-in-production CALDAV_OUTBOUND_API_KEY=changeme-outbound-in-production # Default callback URL for sending scheduling notifications (emails) diff --git a/src/backend/calendars/settings.py b/src/backend/calendars/settings.py index b212a94..09e237b 100755 --- a/src/backend/calendars/settings.py +++ b/src/backend/calendars/settings.py @@ -119,6 +119,14 @@ class Base(Configuration): ) APP_NAME = values.Value("Calendrier", 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 + ) + TRANSLATIONS_JSON_PATH = values.Value( + "/data/translations.json", + environ_name="TRANSLATIONS_JSON_PATH", + environ_prefix=None, + ) # Security ALLOWED_HOSTS = values.ListValue([]) @@ -857,7 +865,7 @@ class Development(Base): DEFAULT_FROM_EMAIL = "calendars@calendars.world" CALENDAR_INVITATION_FROM_EMAIL = "calendars@calendars.world" APP_NAME = "Calendrier (Dev)" - APP_URL = "http://localhost:8920" + APP_URL = "http://localhost:8921" DEBUG_TOOLBAR_CONFIG = { "SHOW_TOOLBAR_CALLBACK": lambda request: True, @@ -945,6 +953,7 @@ class Production(Base): SECURE_REDIRECT_EXEMPT = [ "^__lbheartbeat__", "^__heartbeat__", + r"^api/v1\.0/caldav-scheduling-callback/", ] # Modern browsers require to have the `secure` attribute on cookies with `Samesite=none` diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index fde1afb..1c6b6e5 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -3,8 +3,6 @@ import json import logging -import re -from urllib.parse import unquote from django.conf import settings from django.core.cache import cache @@ -19,7 +17,11 @@ from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.throttling import UserRateThrottle from core import models -from core.services.caldav_service import CalendarService +from core.services.caldav_service import ( + CalendarService, + normalize_caldav_path, + verify_caldav_access, +) from core.services.import_service import MAX_FILE_SIZE, ICSImportService from . import permissions, serializers @@ -261,47 +263,6 @@ class ConfigView(drf.views.APIView): return theme_customization -# Regex for CalDAV path validation (shared with SubscriptionTokenViewSet) -# Pattern: /calendars/// -CALDAV_PATH_PATTERN = re.compile( - r"^/calendars/[^/]+/[a-zA-Z0-9-]+/$", -) - - -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 - """ - 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]) - return path_email.lower() == user.email.lower() - return False - - -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/. - """ - if not caldav_path.startswith("/"): - caldav_path = "/" + caldav_path - # Strip CalDAV API prefix — keep from /calendars/ onwards - calendars_idx = caldav_path.find("/calendars/") - if calendars_idx > 0: - caldav_path = caldav_path[calendars_idx:] - if not caldav_path.endswith("/"): - caldav_path = caldav_path + "/" - return caldav_path - - class CalendarViewSet(viewsets.GenericViewSet): """ViewSet for calendar operations. @@ -354,10 +315,10 @@ class CalendarViewSet(viewsets.GenericViewSet): status=status.HTTP_400_BAD_REQUEST, ) - caldav_path = _normalize_caldav_path(caldav_path) + caldav_path = normalize_caldav_path(caldav_path) # Verify user access - if not _verify_caldav_access(request.user, caldav_path): + 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, @@ -429,7 +390,7 @@ class SubscriptionTokenViewSet(viewsets.GenericViewSet): 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): + 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, @@ -468,10 +429,10 @@ class SubscriptionTokenViewSet(viewsets.GenericViewSet): status=status.HTTP_400_BAD_REQUEST, ) - caldav_path = _normalize_caldav_path(caldav_path) + caldav_path = normalize_caldav_path(caldav_path) # Verify user has access to this calendar - if not _verify_caldav_access(request.user, caldav_path): + 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, diff --git a/src/backend/core/api/viewsets_caldav.py b/src/backend/core/api/viewsets_caldav.py index 3f31e1b..0549f0d 100644 --- a/src/backend/core/api/viewsets_caldav.py +++ b/src/backend/core/api/viewsets_caldav.py @@ -12,6 +12,7 @@ from django.views.decorators.csrf import csrf_exempt import requests +from core.services.caldav_service import CalDAVHTTPClient, validate_caldav_proxy_path from core.services.calendar_invitation_service import calendar_invitation_service logger = logging.getLogger(__name__) @@ -45,44 +46,37 @@ class CalDAVProxyView(View): return HttpResponse(status=401) # Build the CalDAV server URL - caldav_url = settings.CALDAV_URL path = kwargs.get("path", "") + # Validate path to prevent traversal attacks + 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 - # Build target URL - CalDAV server uses base URI /api/v1.0/caldav/ - # The proxy receives requests at /api/v1.0/caldav/... and forwards them - # to the CalDAV server at the same path (sabre/dav expects requests at its base URI) - base_uri_path = "/api/v1.0/caldav" + http = CalDAVHTTPClient() + + # Build target URL clean_path = path.lstrip("/") if path else "" - - # Construct target URL - always include the base URI path if clean_path: - target_url = f"{caldav_url}{base_uri_path}/{clean_path}" + target_url = http.build_url(clean_path) else: - # Root request - use base URI path - target_url = f"{caldav_url}{base_uri_path}/" + target_url = http.build_url("") - # Prepare headers for CalDAV server - # CalDAV server uses custom auth backend that requires X-Forwarded-User header and API key - headers = { - "Content-Type": request.content_type or "application/xml", - "X-Forwarded-User": user_principal, - "X-Forwarded-For": request.META.get("REMOTE_ADDR", ""), - "X-Forwarded-Host": request.get_host(), - "X-Forwarded-Proto": request.scheme, - } - - # API key is required for authentication - outbound_api_key = settings.CALDAV_OUTBOUND_API_KEY - if not outbound_api_key: + # Prepare headers — start with shared auth headers, add proxy-specific ones + try: + headers = CalDAVHTTPClient.build_base_headers(user_principal) + except ValueError: logger.error("CALDAV_OUTBOUND_API_KEY is not configured") return HttpResponse( status=500, content="CalDAV authentication not configured" ) - headers["X-Api-Key"] = outbound_api_key + headers["Content-Type"] = request.content_type or "application/xml" + headers["X-Forwarded-For"] = request.META.get("REMOTE_ADDR", "") + headers["X-Forwarded-Host"] = request.get_host() + headers["X-Forwarded-Proto"] = request.scheme # Add callback URL for CalDAV scheduling (iTip/iMip) # The CalDAV server will call this URL when it needs to send invitations @@ -130,7 +124,7 @@ class CalDAVProxyView(View): headers=headers, data=body, auth=auth, - timeout=30, + timeout=CalDAVHTTPClient.DEFAULT_TIMEOUT, allow_redirects=False, ) diff --git a/src/backend/core/api/viewsets_ical.py b/src/backend/core/api/viewsets_ical.py index e9534a3..10b28cf 100644 --- a/src/backend/core/api/viewsets_ical.py +++ b/src/backend/core/api/viewsets_ical.py @@ -2,7 +2,6 @@ import logging -from django.conf import settings from django.http import Http404, HttpResponse from django.utils import timezone from django.utils.decorators import method_decorator @@ -12,6 +11,7 @@ from django.views.decorators.csrf import csrf_exempt import requests from core.models import CalendarSubscriptionToken +from core.services.caldav_service import CalDAVHTTPClient logger = logging.getLogger(__name__) @@ -50,69 +50,18 @@ class ICalExportView(View): ) # Proxy to SabreDAV - caldav_url = settings.CALDAV_URL - outbound_api_key = settings.CALDAV_OUTBOUND_API_KEY - - if not outbound_api_key: + http = CalDAVHTTPClient() + try: + caldav_path = subscription.caldav_path.lstrip("/") + response = http.request( + "GET", + subscription.owner.email, + caldav_path, + query="export", + ) + except ValueError: logger.error("CALDAV_OUTBOUND_API_KEY is not configured") return HttpResponse(status=500, content="iCal export not configured") - - # Build the CalDAV export URL - # caldav_path is like "/calendars/user@example.com/calendar-uuid/" - # We need to call /api/v1.0/caldav/calendars/user@example.com/calendar-uuid?export - base_uri_path = "/api/v1.0/caldav" - caldav_path = subscription.caldav_path.lstrip("/") - target_url = f"{caldav_url}{base_uri_path}/{caldav_path}?export" - - # Prepare headers for CalDAV server - headers = { - "X-Forwarded-User": subscription.owner.email, - "X-Api-Key": outbound_api_key, - } - - try: - logger.debug( - "Proxying iCal export for caldav_path %s to %s", - subscription.caldav_path, - target_url, - ) - response = requests.get( - target_url, - headers=headers, - timeout=30, # Balanced timeout: allows large calendars while preventing DoS - ) - - if response.status_code != 200: - logger.error( - "CalDAV server returned %d for iCal export: %s", - response.status_code, - response.content[:500], - ) - return HttpResponse( - status=502, - content="Error generating calendar data", - content_type="text/plain", - ) - - # Return ICS response - django_response = HttpResponse( - content=response.content, - 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('"', '\\"') - django_response["Content-Disposition"] = ( - f'attachment; filename="{safe_name}.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 - except requests.exceptions.RequestException as e: logger.error("CalDAV server error during iCal export: %s", str(e)) return HttpResponse( @@ -120,3 +69,34 @@ class ICalExportView(View): content="Calendar server unavailable", content_type="text/plain", ) + + if response.status_code != 200: + logger.error( + "CalDAV server returned %d for iCal export: %s", + response.status_code, + response.content[:500], + ) + return HttpResponse( + status=502, + content="Error generating calendar data", + content_type="text/plain", + ) + + # Return ICS response + django_response = HttpResponse( + content=response.content, + 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('"', '\\"') + django_response["Content-Disposition"] = ( + f'attachment; filename="{safe_name}.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 diff --git a/src/backend/core/api/viewsets_rsvp.py b/src/backend/core/api/viewsets_rsvp.py new file mode 100644 index 0000000..42a81b2 --- /dev/null +++ b/src/backend/core/api/viewsets_rsvp.py @@ -0,0 +1,164 @@ +"""RSVP view for handling invitation responses from email links.""" + +import logging +import re + +from django.core.signing import BadSignature, Signer +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 core.services.caldav_service import CalDAVHTTPClient +from core.services.translation_service import TranslationService + +logger = logging.getLogger(__name__) + +PARTSTAT_ICONS = { + "accepted": "✅", # green check + "tentative": "❓", # question mark + "declined": "❌", # red cross +} + +PARTSTAT_COLORS = { + "accepted": "#16a34a", + "tentative": "#d97706", + "declined": "#dc2626", +} + +PARTSTAT_VALUES = { + "accepted": "ACCEPTED", + "tentative": "TENTATIVE", + "declined": "DECLINED", +} + + +def _render_error(request, message, lang="fr"): + """Render the RSVP error page.""" + t = TranslationService.t + return render( + request, + "rsvp/response.html", + { + "page_title": t("rsvp.error.title", lang), + "error": message, + "error_title": t("rsvp.error.invalidLink", lang), + "header_color": "#dc2626", + "lang": lang, + }, + status=400, + ) + + +def _is_event_past(icalendar_data): + """Check if the event has already ended. + + For recurring events without DTEND, falls back to DTSTART. + If the event has an RRULE, it is never considered past (the + recurrence may extend indefinitely). + """ + from core.services.calendar_invitation_service import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel + ICalendarParser, + ) + + vevent = ICalendarParser.extract_vevent_block(icalendar_data) + if not vevent: + return False + + # Recurring events may have future occurrences — don't reject them + rrule, _ = ICalendarParser.extract_property_with_params(vevent, "RRULE") + if rrule: + return False + + # Use DTEND if available, otherwise DTSTART + for prop in ("DTEND", "DTSTART"): + raw, params = ICalendarParser.extract_property_with_params(vevent, prop) + dt = ICalendarParser.parse_datetime(raw, params.get("TZID")) + if dt: + # Make timezone-aware if naive (assume UTC) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt < timezone.now() + + return False + + +@method_decorator(csrf_exempt, name="dispatch") +class RSVPView(View): + """Handle RSVP responses from invitation email links.""" + + def get(self, request): # noqa: PLR0911 + """Process an RSVP response.""" + token = request.GET.get("token", "") + action = request.GET.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 + ) + + if not uid or not recipient_email or not organizer_email: + return _render_error(request, t("rsvp.error.invalidPayload", lang), lang) + + http = CalDAVHTTPClient() + + # 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 "" + label = t(f"rsvp.{action}", lang) + + return render( + request, + "rsvp/response.html", + { + "page_title": label, + "heading": label, + "message": t("rsvp.responseSent", lang), + "status_icon": PARTSTAT_ICONS[action], + "header_color": PARTSTAT_COLORS[action], + "event_summary": summary, + "lang": lang, + }, + ) diff --git a/src/backend/core/services/caldav_service.py b/src/backend/core/services/caldav_service.py index abf75ca..8a4c3bb 100644 --- a/src/backend/core/services/caldav_service.py +++ b/src/backend/core/services/caldav_service.py @@ -1,29 +1,186 @@ """Services for CalDAV integration.""" import logging +import re from datetime import date, datetime, timedelta from typing import Optional +from urllib.parse import unquote from uuid import uuid4 from django.conf import settings from django.utils import timezone +import icalendar +import requests + +import caldav as caldav_lib from caldav import DAVClient +from caldav.elements.cdav import CalendarDescription +from caldav.elements.dav import DisplayName +from caldav.elements.ical import CalendarColor from caldav.lib.error import NotFoundError logger = logging.getLogger(__name__) +class CalDAVHTTPClient: + """Low-level HTTP client for CalDAV server communication. + + Centralizes header building, URL construction, API key validation, + and HTTP requests. All higher-level CalDAV consumers delegate to this. + """ + + BASE_URI_PATH = "/api/v1.0/caldav" + DEFAULT_TIMEOUT = 30 + + def __init__(self): + self.base_url = settings.CALDAV_URL.rstrip("/") + + @staticmethod + def get_api_key() -> str: + """Return the outbound API key, raising ValueError if not configured.""" + key = settings.CALDAV_OUTBOUND_API_KEY + if not key: + raise ValueError("CALDAV_OUTBOUND_API_KEY is not configured") + return key + + @classmethod + def build_base_headers(cls, email: str) -> dict: + """Build authentication headers for CalDAV requests.""" + return { + "X-Api-Key": cls.get_api_key(), + "X-Forwarded-User": email, + } + + def build_url(self, path: str, query: str = "") -> str: + """Build a full CalDAV URL from a resource path. + + Handles paths with or without the /api/v1.0/caldav prefix. + """ + # If the path already includes the base URI prefix, use it directly + if path.startswith(self.BASE_URI_PATH): + url = f"{self.base_url}{path}" + else: + clean_path = path.lstrip("/") + url = f"{self.base_url}{self.BASE_URI_PATH}/{clean_path}" + if query: + url = f"{url}?{query}" + return url + + def request( # noqa: PLR0913 + self, + method: str, + email: str, + path: str, + *, + query: str = "", + data=None, + extra_headers: dict | None = None, + timeout: int | None = None, + content_type: str | None = None, + ) -> requests.Response: + """Make an authenticated HTTP request to the CalDAV server.""" + headers = self.build_base_headers(email) + if content_type: + headers["Content-Type"] = content_type + if extra_headers: + headers.update(extra_headers) + + url = self.build_url(path, query) + return requests.request( + method=method, + url=url, + headers=headers, + data=data, + 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) + caldav_url = f"{self.base_url}{self.BASE_URI_PATH}/" + return DAVClient( + url=caldav_url, + username=None, + password=None, + timeout=self.DEFAULT_TIMEOUT, + headers=headers, + ) + + def find_event_by_uid(self, email: str, uid: str) -> tuple[str | None, str | None]: + """Find an event by UID across all of the user's calendars. + + Returns (ical_data, href) or (None, None). + """ + client = self.get_dav_client(email) + try: + principal = client.principal() + for cal in principal.calendars(): + try: + event = cal.object_by_uid(uid) + return event.data, str(event.url.path) + except caldav_lib.error.NotFoundError: + continue + logger.warning("Event UID %s not found in user %s calendars", uid, email) + return None, None + except Exception: + logger.exception("CalDAV error looking up event %s", uid) + return 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.""" + try: + response = self.request( + "PUT", + email, + href, + data=ical_data.encode("utf-8"), + content_type="text/calendar; charset=utf-8", + ) + if response.status_code in (200, 201, 204): + return True + logger.error( + "CalDAV PUT failed: %s %s", + response.status_code, + response.text[:500], + ) + return False + except requests.exceptions.RequestException: + logger.exception("CalDAV PUT error for %s", href) + return False + + @staticmethod + def update_attendee_partstat( + ical_data: str, email: str, new_partstat: str + ) -> str | None: + """Update the PARTSTAT of an attendee in iCalendar data. + + Returns the modified iCalendar string, or None if attendee not found. + """ + cal = icalendar.Calendar.from_ical(ical_data) + updated = False + + 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: + attendee.params["PARTSTAT"] = icalendar.vText(new_partstat) + updated = True + + if not updated: + return None + + return cal.to_ical().decode("utf-8") + + class CalDAVClient: """ Client for communicating with CalDAV server using the caldav library. """ def __init__(self): - self.base_url = settings.CALDAV_URL - # Set the base URI path as expected by the CalDAV server - self.base_uri_path = "/api/v1.0/caldav/" - self.timeout = 30 + self._http = CalDAVHTTPClient() + self.base_url = self._http.base_url def _get_client(self, user) -> DAVClient: """ @@ -32,33 +189,7 @@ class CalDAVClient: The CalDAV server requires API key authentication via Authorization header and X-Forwarded-User header for user identification. """ - # CalDAV server base URL - include the base URI path that sabre/dav expects - # Remove trailing slash from base_url and base_uri_path to avoid double slashes - base_url_clean = self.base_url.rstrip("/") - base_uri_clean = self.base_uri_path.rstrip("/") - caldav_url = f"{base_url_clean}{base_uri_clean}/" - - # Prepare headers - # API key is required for authentication - headers = { - "X-Forwarded-User": user.email, - } - - outbound_api_key = settings.CALDAV_OUTBOUND_API_KEY - if not outbound_api_key: - raise ValueError("CALDAV_OUTBOUND_API_KEY is not configured") - - headers["X-Api-Key"] = outbound_api_key - - # No username/password needed - authentication is via API key and X-Forwarded-User header - # Pass None to prevent the caldav library from trying Basic auth - return DAVClient( - url=caldav_url, - username=None, - password=None, - timeout=self.timeout, - headers=headers, - ) + return self._http.get_dav_client(user.email) def get_calendar_info(self, user, calendar_path: str) -> dict | None: """ @@ -72,18 +203,12 @@ class CalDAVClient: calendar = client.calendar(url=calendar_url) # Fetch properties props = calendar.get_properties( - [ - "{DAV:}displayname", - "{http://apple.com/ns/ical/}calendar-color", - "{urn:ietf:params:xml:ns:caldav}calendar-description", - ] + [DisplayName(), CalendarColor(), CalendarDescription()] ) - name = props.get("{DAV:}displayname", "Calendar") - color = props.get("{http://apple.com/ns/ical/}calendar-color", "#3174ad") - description = props.get( - "{urn:ietf:params:xml:ns:caldav}calendar-description", "" - ) + name = props.get(DisplayName.tag, "Calendar") + color = props.get(CalendarColor.tag, "#3174ad") + description = props.get(CalendarDescription.tag, "") # Clean up color (CalDAV may return with alpha channel like #RRGGBBAA) if color and len(color) == 9 and color.startswith("#"): @@ -102,7 +227,9 @@ 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) -> str: + def create_calendar( + self, user, calendar_name: str, calendar_id: str, color: str = "" + ) -> str: """ Create a new calendar in CalDAV server for the given user. Returns the CalDAV server path for the calendar. @@ -114,6 +241,10 @@ class CalDAVClient: # Create calendar using caldav library calendar = principal.make_calendar(name=calendar_name) + # 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 calendar_url = str(calendar.url) @@ -390,12 +521,12 @@ class CalendarService: calendar_name = "Mon calendrier" return self.caldav.create_calendar(user, calendar_name, calendar_id) - def create_calendar( # pylint: disable=unused-argument + def create_calendar( self, user, name: str, color: str = "#3174ad" ) -> 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) + return self.caldav.create_calendar(user, name, calendar_id, color=color) def get_events(self, user, caldav_path: str, start=None, end=None) -> list: """Get events from a calendar. Returns parsed event data.""" @@ -414,3 +545,75 @@ class CalendarService: def delete_event(self, user, caldav_path: str, event_uid: str) -> None: """Delete an event.""" self.caldav.delete_event(user, caldav_path, event_uid) + + +# --------------------------------------------------------------------------- +# CalDAV path utilities +# --------------------------------------------------------------------------- + +# Pattern: /calendars/// +CALDAV_PATH_PATTERN = re.compile( + r"^/calendars/[^/]+/[a-zA-Z0-9-]+/$", +) + + +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/. + """ + if not caldav_path.startswith("/"): + caldav_path = "/" + caldav_path + # Strip CalDAV API prefix — keep from /calendars/ onwards + calendars_idx = caldav_path.find("/calendars/") + if calendars_idx > 0: + caldav_path = caldav_path[calendars_idx:] + if not caldav_path.endswith("/"): + caldav_path = caldav_path + "/" + return caldav_path + + +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 + """ + 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]) + return path_email.lower() == user.email.lower() + return False + + +def validate_caldav_proxy_path(path): + """Validate that a CalDAV proxy path is safe. + + Prevents path traversal attacks by rejecting paths with: + - Directory traversal sequences (../) + - Null bytes + - Paths that don't start with expected prefixes + """ + if not path: + return True # Empty path is fine (root request) + + # Block directory traversal + if ".." in path: + return False + + # Block null bytes + if "\x00" in path: + 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 diff --git a/src/backend/core/services/calendar_invitation_service.py b/src/backend/core/services/calendar_invitation_service.py index c8df919..b370086 100644 --- a/src/backend/core/services/calendar_invitation_service.py +++ b/src/backend/core/services/calendar_invitation_service.py @@ -16,28 +16,14 @@ from datetime import timezone as dt_timezone from email import encoders from email.mime.base import MIMEBase from typing import Optional +from urllib.parse import urlencode from django.conf import settings from django.core.mail import EmailMultiAlternatives +from django.core.signing import Signer from django.template.loader import render_to_string -# French month and day names for date formatting -FRENCH_DAYS = ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"] -FRENCH_MONTHS = [ - "", - "janvier", - "février", - "mars", - "avril", - "mai", - "juin", - "juillet", - "août", - "septembre", - "octobre", - "novembre", - "décembre", -] +from core.services.translation_service import TranslationService logger = logging.getLogger(__name__) @@ -201,7 +187,7 @@ class ICalendarParser: # Extract basic properties from VEVENT block uid = cls.extract_property(vevent_block, "UID") - summary = cls.extract_property(vevent_block, "SUMMARY") or "(Sans titre)" + summary = cls.extract_property(vevent_block, "SUMMARY") or "" description = cls.extract_property(vevent_block, "DESCRIPTION") location = cls.extract_property(vevent_block, "LOCATION") url = cls.extract_property(vevent_block, "URL") @@ -338,22 +324,27 @@ class CalendarInvitationService: # pylint: disable=too-many-instance-attributes return False try: + # Resolve language for the recipient + lang = TranslationService.resolve_language(email=recipient) + t = TranslationService.t + summary = event.summary or t("email.noTitle", lang) + # Determine email type and get appropriate subject/content if method == self.METHOD_CANCEL: - subject = self._get_cancel_subject(event) + subject = t("email.subject.cancel", lang, summary=summary) template_prefix = "calendar_invitation_cancel" elif method == self.METHOD_REPLY: - subject = self._get_reply_subject(event) + subject = t("email.subject.reply", lang, summary=summary) template_prefix = "calendar_invitation_reply" elif event.sequence > 0: - subject = self._get_update_subject(event) + subject = t("email.subject.update", lang, summary=summary) template_prefix = "calendar_invitation_update" else: - subject = self._get_invitation_subject(event) + subject = t("email.subject.invitation", lang, summary=summary) template_prefix = "calendar_invitation" # Build context for templates - context = self._build_template_context(event, method) + context = self._build_template_context(event, method, lang) # Render email bodies text_body = render_to_string(f"emails/{template_prefix}.txt", context) @@ -380,52 +371,54 @@ class CalendarInvitationService: # pylint: disable=too-many-instance-attributes ) return False - def _get_invitation_subject(self, event: EventDetails) -> str: - """Generate subject line for new invitation.""" - return f"Invitation : {event.summary}" - - def _get_update_subject(self, event: EventDetails) -> str: - """Generate subject line for event update.""" - return f"Invitation modifiée : {event.summary}" - - def _get_cancel_subject(self, event: EventDetails) -> str: - """Generate subject line for cancellation.""" - return f"Annulé : {event.summary}" - - def _get_reply_subject(self, event: EventDetails) -> str: - """Generate subject line for attendee reply.""" - return f"Réponse : {event.summary}" - - def _format_date_french(self, dt: datetime) -> str: - """Format a datetime in French (e.g., 'jeudi 23 janvier 2026').""" - day_name = FRENCH_DAYS[dt.weekday()] - month_name = FRENCH_MONTHS[dt.month] - return f"{day_name} {dt.day} {month_name} {dt.year}" - - def _build_template_context(self, event: EventDetails, method: str) -> dict: + def _build_template_context( # pylint: disable=too-many-locals + self, event: EventDetails, method: str, lang: str = "fr" + ) -> dict: """Build context dictionary for email templates.""" - # Format dates for display in French + t = TranslationService.t + summary = event.summary or t("email.noTitle", lang) + + # Format dates for display if event.is_all_day: - start_str = self._format_date_french(event.dtstart) + start_str = TranslationService.format_date(event.dtstart, lang) end_str = ( - self._format_date_french(event.dtend) if event.dtend else start_str + TranslationService.format_date(event.dtend, lang) + if event.dtend + else start_str ) - time_str = "Toute la journée" + time_str = t("email.allDay", lang) else: time_format = "%H:%M" - start_str = self._format_date_french(event.dtstart) + start_str = TranslationService.format_date(event.dtstart, lang) start_time = event.dtstart.strftime(time_format) end_time = event.dtend.strftime(time_format) if event.dtend else "" end_str = ( - self._format_date_french(event.dtend) if event.dtend else start_str + TranslationService.format_date(event.dtend, lang) + if event.dtend + else start_str ) time_str = f"{start_time} - {end_time}" if end_time else start_time - return { + organizer_display = event.organizer_name or event.organizer_email + attendee_display = event.attendee_name or event.attendee_email + + # Determine email type key for content lookups + if method == self.METHOD_CANCEL: + type_key = "cancel" + elif method == self.METHOD_REPLY: + type_key = "reply" + elif event.sequence > 0: + type_key = "update" + else: + type_key = "invitation" + + context = { "event": event, + "summary": summary, "method": method, - "organizer_display": event.organizer_name or event.organizer_email, - "attendee_display": event.attendee_name or event.attendee_email, + "lang": lang, + "organizer_display": organizer_display, + "attendee_display": attendee_display, "start_date": start_str, "end_date": end_str, "time_str": time_str, @@ -433,28 +426,100 @@ class CalendarInvitationService: # pylint: disable=too-many-instance-attributes "is_cancel": method == self.METHOD_CANCEL, "app_name": getattr(settings, "APP_NAME", "Calendrier"), "app_url": getattr(settings, "APP_URL", ""), + # Translated content blocks + "content": { + "title": t(f"email.{type_key}.title", lang), + "heading": t(f"email.{type_key}.heading", lang), + "body": t( + f"email.{type_key}.body", + lang, + organizer=organizer_display, + attendee=attendee_display, + ), + "badge": t(f"email.{type_key}.badge", lang), + }, + "labels": { + "when": t("email.labels.when", lang), + "until": t("email.labels.until", lang), + "location": t("email.labels.location", lang), + "videoConference": t("email.labels.videoConference", lang), + "organizer": t("email.labels.organizer", lang), + "attendee": t("email.labels.attendee", lang), + "description": t("email.labels.description", lang), + "wasScheduledFor": t("email.labels.wasScheduledFor", lang), + }, + "actions": { + "accept": t("email.actions.accept", lang), + "maybe": t("email.actions.maybe", lang), + "decline": t("email.actions.decline", lang), + }, + "instructions": t(f"email.instructions.{type_key}", lang), + "footer": t( + f"email.footer.{'invitation' if type_key == 'invitation' else 'notification'}", + lang, + appName=getattr(settings, "APP_NAME", "Calendrier"), + ), } + # Add RSVP links for REQUEST method (invitations and updates) + if method == self.METHOD_REQUEST: + signer = Signer(salt="rsvp") + # Strip mailto: prefix (case-insensitive) for shorter tokens + organizer = re.sub( + r"^mailto:", "", event.organizer_email, flags=re.IGNORECASE + ) + token = signer.sign_object( + { + "uid": event.uid, + "email": event.attendee_email, + "organizer": organizer, + } + ) + app_url = getattr(settings, "APP_URL", "") + base = f"{app_url}/rsvp/" + for action in ("accept", "tentative", "decline"): + partstat = { + "accept": "accepted", + "tentative": "tentative", + "decline": "declined", + } + context[f"rsvp_{action}_url"] = ( + f"{base}?{urlencode({'token': token, 'action': partstat[action]})}" + ) + + return context + def _prepare_ics_attachment(self, icalendar_data: str, method: str) -> str: """ - Prepare ICS content with correct METHOD for attachment. + Prepare ICS content for attachment. - The METHOD property must be in the VCALENDAR component, not VEVENT. + When CALENDAR_ITIP_ENABLED is True, sets the METHOD property so that + calendar clients show Accept/Decline buttons (standard iTIP flow). + When False (default), strips METHOD so the ICS is treated as a plain + calendar object — our own RSVP web links handle responses instead. """ - # Check if METHOD is already present - if "METHOD:" not in icalendar_data.upper(): - # Insert METHOD after VERSION - icalendar_data = re.sub( - r"(VERSION:2\.0\r?\n)", - rf"\1METHOD:{method}\r\n", - icalendar_data, - flags=re.IGNORECASE, - ) + itip_enabled = getattr(settings, "CALENDAR_ITIP_ENABLED", False) + + if itip_enabled: + if "METHOD:" not in icalendar_data.upper(): + icalendar_data = re.sub( + r"(VERSION:2\.0\r?\n)", + rf"\1METHOD:{method}\r\n", + icalendar_data, + flags=re.IGNORECASE, + ) + else: + icalendar_data = re.sub( + r"METHOD:[^\r\n]+", + f"METHOD:{method}", + icalendar_data, + flags=re.IGNORECASE, + ) else: - # Update existing METHOD + # Strip any existing METHOD so clients treat it as a plain event icalendar_data = re.sub( - r"METHOD:[^\r\n]+", - f"METHOD:{method}", + r"METHOD:[^\r\n]+\r?\n", + "", icalendar_data, flags=re.IGNORECASE, ) @@ -503,13 +568,14 @@ class CalendarInvitationService: # pylint: disable=too-many-instance-attributes email.attach_alternative(html_body, "text/html") # Add ICS attachment with proper MIME type - # The Content-Type must include method parameter for calendar clients ics_attachment = MIMEBase("text", "calendar") ics_attachment.set_payload(ics_content.encode("utf-8")) encoders.encode_base64(ics_attachment) - ics_attachment.add_header( - "Content-Type", f"text/calendar; charset=utf-8; method={ics_method}" - ) + itip_enabled = getattr(settings, "CALENDAR_ITIP_ENABLED", False) + content_type = "text/calendar; charset=utf-8" + if itip_enabled: + content_type += f"; method={ics_method}" + ics_attachment.add_header("Content-Type", content_type) ics_attachment.add_header( "Content-Disposition", 'attachment; filename="invite.ics"' ) diff --git a/src/backend/core/services/import_service.py b/src/backend/core/services/import_service.py index 7c6600a..aed11a0 100644 --- a/src/backend/core/services/import_service.py +++ b/src/backend/core/services/import_service.py @@ -3,10 +3,10 @@ import logging from dataclasses import dataclass, field -from django.conf import settings - import requests +from core.services.caldav_service import CalDAVHTTPClient + logger = logging.getLogger(__name__) MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB @@ -36,7 +36,7 @@ class ICSImportService: """ def __init__(self): - self.base_url = settings.CALDAV_URL.rstrip("/") + self._http = CalDAVHTTPClient() def import_events(self, user, caldav_path: str, ics_data: bytes) -> ImportResult: """Import events from ICS data into a calendar. @@ -53,30 +53,26 @@ class ICSImportService: """ result = ImportResult() - # Ensure caldav_path includes the base URI prefix that SabreDAV expects - base_uri = "/api/v1.0/caldav/" - if not caldav_path.startswith(base_uri): - caldav_path = base_uri.rstrip("/") + caldav_path - url = f"{self.base_url}{caldav_path}?import" - - outbound_api_key = settings.CALDAV_OUTBOUND_API_KEY - if not outbound_api_key: + try: + api_key = CalDAVHTTPClient.get_api_key() + except ValueError: result.errors.append("CALDAV_OUTBOUND_API_KEY is not configured") return result - headers = { - "Content-Type": "text/calendar", - "X-Api-Key": outbound_api_key, - "X-Forwarded-User": user.email, - "X-Calendars-Import": outbound_api_key, - } + # 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 try: - # 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 - response = requests.post( - url, data=ics_data, headers=headers, timeout=timeout + response = self._http.request( + "POST", + user.email, + caldav_path, + query="import", + data=ics_data, + content_type="text/calendar", + extra_headers={"X-Calendars-Import": api_key}, + timeout=timeout, ) except requests.RequestException as exc: logger.error("Failed to reach SabreDAV import endpoint: %s", exc) diff --git a/src/backend/core/services/translation_service.py b/src/backend/core/services/translation_service.py new file mode 100644 index 0000000..b6fe54a --- /dev/null +++ b/src/backend/core/services/translation_service.py @@ -0,0 +1,152 @@ +"""Translation service that loads translations from the shared translations.json.""" + +import json +import logging +import os +from datetime import datetime +from typing import Optional + +from django.conf import settings + +logger = logging.getLogger(__name__) + +WEEKDAY_KEYS = [ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", +] + +MONTH_KEYS = [ + "", + "january", + "february", + "march", + "april", + "may", + "june", + "july", + "august", + "september", + "october", + "november", + "december", +] + + +class TranslationService: + """Lightweight translation service backed by translations.json.""" + + _translations = None + + @classmethod + def _load(cls): + """Load translations from JSON file (cached at class level).""" + 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 open(path, encoding="utf-8") as f: + cls._translations = json.load(f) + + @classmethod + def _get_nested(cls, data: dict, dotted_key: str): + """Traverse a nested dict using dot-separated key.""" + parts = dotted_key.split(".") + current = data + for part in parts: + if not isinstance(current, dict) or part not in current: + return None + current = current[part] + return current if isinstance(current, str) else None + + @classmethod + def t(cls, key: str, lang: str = "en", **kwargs) -> str: + """Look up a translation key with interpolation. + + Fallback chain: lang -> "en" -> key itself. + Interpolation: ``{{var}}`` patterns are replaced from kwargs. + """ + cls._load() + + for try_lang in (lang, "en"): + lang_data = cls._translations.get(try_lang, {}) + translation_data = lang_data.get("translation", lang_data) + value = cls._get_nested(translation_data, key) + if value is not None: + for k, v in kwargs.items(): + value = value.replace("{{" + k + "}}", str(v)) + return value + + return key + + @classmethod + def resolve_language(cls, request=None, email: Optional[str] = None) -> str: + """Determine the best language for a request or email recipient. + + - From request: uses Django's get_language() (set by LocaleMiddleware). + - From email: looks up User.language field. + - Fallback: "fr". + """ + if request is not None: + from django.utils.translation import ( # noqa: PLC0415 + get_language, + ) + + lang = get_language() + if lang: + return cls.normalize_lang(lang) + + if email: + try: + from core.models import User # noqa: PLC0415 + + user = User.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) + + return "fr" + + @staticmethod + def normalize_lang(lang_code: str) -> str: + """Normalize a language code to a simple 2-letter code. + + ``"fr-fr"`` -> ``"fr"``, ``"en-us"`` -> ``"en"``, ``"nl-nl"`` -> ``"nl"``. + """ + if not lang_code: + return "fr" + short = lang_code.split("-")[0].lower() + return short if short in ("en", "fr", "nl") else "fr" + + @classmethod + def format_date(cls, dt: datetime, lang: str) -> str: + """Format a date using translated weekday/month names. + + Returns e.g. "jeudi 23 janvier 2026" (fr) + or "Thursday, January 23, 2026" (en). + """ + weekday = cls.t(f"calendar.weekdaysFull.{WEEKDAY_KEYS[dt.weekday()]}", lang) + month = cls.t(f"calendar.recurrence.months.{MONTH_KEYS[dt.month]}", lang) + + if lang == "fr": + month = month.lower() + return f"{weekday} {dt.day} {month} {dt.year}" + if lang == "nl": + month = month.lower() + return f"{weekday} {dt.day} {month} {dt.year}" + + # English format + return f"{weekday}, {month} {dt.day}, {dt.year}" + + @classmethod + def reset(cls): + """Reset cached translations (useful for tests).""" + cls._translations = None diff --git a/src/backend/core/templates/emails/calendar_invitation.html b/src/backend/core/templates/emails/calendar_invitation.html index 45e193e..09270bc 100644 --- a/src/backend/core/templates/emails/calendar_invitation.html +++ b/src/backend/core/templates/emails/calendar_invitation.html @@ -1,9 +1,9 @@ - + - Invitation à un événement + {{ content.title }} + + +
+ {% if error %} +
+

{{ error_title }}

+

{{ error }}

+ {% else %} +
{{ status_icon|safe }}
+

{{ heading }}

+ {% if event_summary %} +
{{ event_summary }}
+ {% endif %} + {% if event_date %} +
{{ event_date }}
+ {% endif %} +

{{ message }}

+ {% endif %} +
+ + diff --git a/src/backend/core/tests/test_caldav_proxy.py b/src/backend/core/tests/test_caldav_proxy.py index 5e92b3f..0da1d59 100644 --- a/src/backend/core/tests/test_caldav_proxy.py +++ b/src/backend/core/tests/test_caldav_proxy.py @@ -9,11 +9,13 @@ import responses from rest_framework.status import ( HTTP_200_OK, HTTP_207_MULTI_STATUS, + HTTP_400_BAD_REQUEST, HTTP_401_UNAUTHORIZED, ) from rest_framework.test import APIClient from core import factories +from core.services.caldav_service import validate_caldav_proxy_path @pytest.mark.django_db @@ -305,3 +307,48 @@ class TestCalDAVProxy: assert response.status_code == HTTP_200_OK assert "Access-Control-Allow-Methods" in response assert "PROPFIND" in response["Access-Control-Allow-Methods"] + + def test_proxy_rejects_path_traversal(self): + """Test that proxy rejects paths with directory traversal.""" + user = factories.UserFactory(email="test@example.com") + client = APIClient() + client.force_login(user) + + response = client.generic( + "PROPFIND", "/api/v1.0/caldav/calendars/../../etc/passwd" + ) + assert response.status_code == HTTP_400_BAD_REQUEST + + def test_proxy_rejects_non_caldav_path(self): + """Test that proxy rejects paths outside allowed prefixes.""" + user = factories.UserFactory(email="test@example.com") + client = APIClient() + client.force_login(user) + + response = client.generic("PROPFIND", "/api/v1.0/caldav/etc/passwd") + assert response.status_code == HTTP_400_BAD_REQUEST + + +class TestValidateCaldavProxyPath: + """Tests for validate_caldav_proxy_path utility.""" + + def test_empty_path_is_valid(self): + assert validate_caldav_proxy_path("") is True + + def test_calendars_path_is_valid(self): + assert validate_caldav_proxy_path("calendars/user@ex.com/uuid/") is True + + def test_principals_path_is_valid(self): + assert validate_caldav_proxy_path("principals/user@ex.com/") is True + + def test_traversal_is_rejected(self): + assert validate_caldav_proxy_path("calendars/../../etc/passwd") is False + + def test_null_byte_is_rejected(self): + assert validate_caldav_proxy_path("calendars/user\x00/") is False + + def test_unknown_prefix_is_rejected(self): + assert validate_caldav_proxy_path("etc/passwd") is False + + def test_leading_slash_calendars_is_valid(self): + assert validate_caldav_proxy_path("/calendars/user@ex.com/uuid/") is True diff --git a/src/backend/core/tests/test_caldav_service.py b/src/backend/core/tests/test_caldav_service.py index fe140fb..2e9d186 100644 --- a/src/backend/core/tests/test_caldav_service.py +++ b/src/backend/core/tests/test_caldav_service.py @@ -66,3 +66,24 @@ class TestCalDAVClient: assert caldav_path is not None 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") + service = CalendarService() + color = "#e74c3c" + + # Create a calendar with a specific color + 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) + assert info is not None + assert info["color"] == color + assert info["name"] == "Red Calendar" diff --git a/src/backend/core/tests/test_import_events.py b/src/backend/core/tests/test_import_events.py index 07b2156..acd0f02 100644 --- a/src/backend/core/tests/test_import_events.py +++ b/src/backend/core/tests/test_import_events.py @@ -276,7 +276,7 @@ def _make_sabredav_response( # noqa: PLR0913 # pylint: disable=too-many-argume class TestICSImportService: """Unit tests for ICSImportService with mocked HTTP call to SabreDAV.""" - @patch("core.services.import_service.requests.post") + @patch("core.services.caldav_service.requests.request") def test_import_single_event(self, mock_post): """Importing a single event should succeed.""" mock_post.return_value = _make_sabredav_response( @@ -299,7 +299,7 @@ class TestICSImportService: call_kwargs = mock_post.call_args assert call_kwargs.kwargs["data"] == ICS_SINGLE_EVENT - @patch("core.services.import_service.requests.post") + @patch("core.services.caldav_service.requests.request") def test_import_multiple_events(self, mock_post): """Importing multiple events should forward all to SabreDAV.""" mock_post.return_value = _make_sabredav_response( @@ -319,7 +319,7 @@ class TestICSImportService: # Single HTTP call, not one per event mock_post.assert_called_once() - @patch("core.services.import_service.requests.post") + @patch("core.services.caldav_service.requests.request") def test_import_empty_ics(self, mock_post): """Importing an ICS with no events should return zero counts.""" mock_post.return_value = _make_sabredav_response( @@ -337,7 +337,7 @@ class TestICSImportService: assert result.skipped_count == 0 assert not result.errors - @patch("core.services.import_service.requests.post") + @patch("core.services.caldav_service.requests.request") def test_import_invalid_ics(self, mock_post): """Importing invalid ICS data should return an error from SabreDAV.""" mock_post.return_value = _make_sabredav_response( @@ -354,7 +354,7 @@ class TestICSImportService: assert result.imported_count == 0 assert len(result.errors) >= 1 - @patch("core.services.import_service.requests.post") + @patch("core.services.caldav_service.requests.request") def test_import_with_timezone(self, mock_post): """Events with timezones should be forwarded to SabreDAV.""" mock_post.return_value = _make_sabredav_response( @@ -375,7 +375,7 @@ class TestICSImportService: assert b"VTIMEZONE" in call_kwargs.kwargs["data"] assert b"Europe/Paris" in call_kwargs.kwargs["data"] - @patch("core.services.import_service.requests.post") + @patch("core.services.caldav_service.requests.request") def test_import_partial_failure(self, mock_post): """When some events fail, SabreDAV reports partial success.""" mock_post.return_value = _make_sabredav_response( @@ -404,7 +404,7 @@ class TestICSImportService: # Only event name is exposed, not raw error details assert result.errors[0] == "Afternoon review" - @patch("core.services.import_service.requests.post") + @patch("core.services.caldav_service.requests.request") def test_import_all_day_event(self, mock_post): """All-day events should be forwarded to SabreDAV.""" mock_post.return_value = _make_sabredav_response( @@ -420,7 +420,7 @@ class TestICSImportService: assert result.total_events == 1 assert result.imported_count == 1 - @patch("core.services.import_service.requests.post") + @patch("core.services.caldav_service.requests.request") def test_import_valarm_without_action(self, mock_post): """VALARM without ACTION is handled by SabreDAV plugin repair.""" mock_post.return_value = _make_sabredav_response( @@ -436,7 +436,7 @@ class TestICSImportService: assert result.total_events == 1 assert result.imported_count == 1 - @patch("core.services.import_service.requests.post") + @patch("core.services.caldav_service.requests.request") def test_import_recurring_with_exception(self, mock_post): """Recurring event + modified occurrence handled by SabreDAV splitter.""" mock_post.return_value = _make_sabredav_response( @@ -453,7 +453,7 @@ class TestICSImportService: assert result.total_events == 1 assert result.imported_count == 1 - @patch("core.services.import_service.requests.post") + @patch("core.services.caldav_service.requests.request") def test_import_event_missing_dtstart(self, mock_post): """Events without DTSTART handling is delegated to SabreDAV.""" mock_post.return_value = _make_sabredav_response( @@ -480,7 +480,7 @@ class TestICSImportService: assert result.skipped_count == 1 assert result.errors[0] == "Missing start" - @patch("core.services.import_service.requests.post") + @patch("core.services.caldav_service.requests.request") def test_import_passes_calendar_path(self, mock_post): """The import URL should include the caldav_path.""" mock_post.return_value = _make_sabredav_response( @@ -498,7 +498,7 @@ class TestICSImportService: assert caldav_path in url assert "?import" in url - @patch("core.services.import_service.requests.post") + @patch("core.services.caldav_service.requests.request") def test_import_sends_auth_headers(self, mock_post): """The import request must include all required auth headers.""" mock_post.return_value = _make_sabredav_response( @@ -518,7 +518,7 @@ class TestICSImportService: assert headers["X-Calendars-Import"] == settings.CALDAV_OUTBOUND_API_KEY assert headers["Content-Type"] == "text/calendar" - @patch("core.services.import_service.requests.post") + @patch("core.services.caldav_service.requests.request") def test_import_duplicates_not_treated_as_errors(self, mock_post): """Duplicate events should be counted separately, not as errors.""" mock_post.return_value = _make_sabredav_response( @@ -541,7 +541,7 @@ class TestICSImportService: assert result.skipped_count == 0 assert not result.errors - @patch("core.services.import_service.requests.post") + @patch("core.services.caldav_service.requests.request") def test_import_network_failure(self, mock_post): """Network failures should return a graceful error.""" mock_post.side_effect = req.ConnectionError("Connection refused") diff --git a/src/backend/core/tests/test_rsvp.py b/src/backend/core/tests/test_rsvp.py new file mode 100644 index 0000000..0f5af23 --- /dev/null +++ b/src/backend/core/tests/test_rsvp.py @@ -0,0 +1,622 @@ +"""Tests for RSVP view and token generation.""" + +import re +from datetime import timedelta +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.template.loader import render_to_string +from django.test import RequestFactory, TestCase, override_settings +from django.utils import timezone + +import icalendar +import pytest + +from core.api.viewsets_rsvp import RSVPView +from core.services.caldav_service import CalDAVHTTPClient +from core.services.calendar_invitation_service import ( + CalendarInvitationService, + ICalendarParser, +) + + +def _make_ics(uid="test-uid-123", summary="Team Meeting", sequence=0, days_from_now=30): + """Build a sample ICS string with a date relative to now.""" + 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" + "ORGANIZER;CN=Alice:mailto:alice@example.com\r\n" + "ATTENDEE;CN=Bob;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:bob@example.com\r\n" + f"SEQUENCE:{sequence}\r\n" + "END:VEVENT\r\n" + "END:VCALENDAR" + ) + + +SAMPLE_ICS = _make_ics() +SAMPLE_ICS_UPDATE = _make_ics(uid="test-uid-456", summary="Updated Meeting", sequence=1) +SAMPLE_ICS_PAST = _make_ics( + uid="test-uid-past", summary="Past Meeting", days_from_now=-30 +) + +SAMPLE_CALDAV_RESPONSE = """\ + + + + /api/v1.0/caldav/calendars/alice%40example.com/cal-uuid/test-uid-123.ics + + + /api/v1.0/caldav/calendars/alice%40example.com/cal-uuid/test-uid-123.ics + {ics_data} + + HTTP/1.1 200 OK + + +""" + + +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") + return signer.sign_object( + { + "uid": uid, + "email": email, + "organizer": organizer, + } + ) + + +class TestRSVPTokenGeneration: + """Tests for RSVP token generation in invitation service.""" + + def test_token_roundtrip(self): + """A generated token can be unsigned to recover the payload.""" + token = _make_token() + signer = Signer(salt="rsvp") + payload = signer.unsign_object(token) + assert payload["uid"] == "test-uid-123" + assert payload["email"] == "bob@example.com" + assert payload["organizer"] == "alice@example.com" + + def test_tampered_token_fails(self): + """A tampered token raises BadSignature.""" + token = _make_token() + "tampered" + signer = Signer(salt="rsvp") + with pytest.raises(BadSignature): + signer.unsign_object(token) + + +@pytest.mark.django_db +class TestRSVPUrlsInContext: + """Tests that RSVP URLs are added to template context for REQUEST method.""" + + def test_request_method_has_rsvp_urls(self): + """REQUEST method should include RSVP URLs in context.""" + event = ICalendarParser.parse(SAMPLE_ICS, "bob@example.com") + service = CalendarInvitationService() + context = service._build_template_context(event, "REQUEST") + + assert "rsvp_accept_url" in context + assert "rsvp_tentative_url" in context + assert "rsvp_decline_url" in context + + # Check URLs contain proper action params + assert "action=accepted" in context["rsvp_accept_url"] + assert "action=tentative" in context["rsvp_tentative_url"] + assert "action=declined" in context["rsvp_decline_url"] + + # Check all URLs contain a token + for key in ("rsvp_accept_url", "rsvp_tentative_url", "rsvp_decline_url"): + parsed = urlparse(context[key]) + params = parse_qs(parsed.query) + assert "token" in params + + def test_cancel_method_has_no_rsvp_urls(self): + """CANCEL method should NOT include RSVP URLs.""" + event = ICalendarParser.parse(SAMPLE_ICS, "bob@example.com") + service = CalendarInvitationService() + context = service._build_template_context(event, "CANCEL") + + assert "rsvp_accept_url" not in context + + def test_reply_method_has_no_rsvp_urls(self): + """REPLY method should NOT include RSVP URLs.""" + event = ICalendarParser.parse(SAMPLE_ICS, "bob@example.com") + service = CalendarInvitationService() + context = service._build_template_context(event, "REPLY") + + assert "rsvp_accept_url" not in context + + +@pytest.mark.django_db +class TestRSVPEmailTemplateRendering: + """Tests that RSVP buttons appear in email templates.""" + + def _build_context(self, ics_data, method="REQUEST"): + event = ICalendarParser.parse(ics_data, "bob@example.com") + service = CalendarInvitationService() + return service._build_template_context(event, method) + + def test_invitation_html_has_rsvp_buttons(self): + context = self._build_context(SAMPLE_ICS) + html = render_to_string("emails/calendar_invitation.html", context) + assert "Accepter" in html + assert "Peut-être" in html + assert "Refuser" in html + + def test_invitation_txt_has_rsvp_links(self): + context = self._build_context(SAMPLE_ICS) + txt = render_to_string("emails/calendar_invitation.txt", context) + assert "Accepter" in txt + assert "Peut-être" in txt + assert "Refuser" in txt + + def test_update_html_has_rsvp_buttons(self): + context = self._build_context(SAMPLE_ICS_UPDATE) + html = render_to_string("emails/calendar_invitation_update.html", context) + assert "Accepter" in html + assert "Peut-être" in html + assert "Refuser" in html + + def test_update_txt_has_rsvp_links(self): + context = self._build_context(SAMPLE_ICS_UPDATE) + txt = render_to_string("emails/calendar_invitation_update.txt", context) + assert "Accepter" in txt + assert "Peut-être" in txt + assert "Refuser" in txt + + def test_cancel_html_has_no_rsvp_buttons(self): + context = self._build_context(SAMPLE_ICS, method="CANCEL") + html = render_to_string("emails/calendar_invitation_cancel.html", context) + assert "rsvp" not in html.lower() or "Accepter" not in html + + def test_invitation_html_no_rsvp_for_cancel(self): + """Cancel templates don't have RSVP buttons.""" + context = self._build_context(SAMPLE_ICS, method="CANCEL") + html = render_to_string("emails/calendar_invitation_cancel.html", context) + assert "Accepter" not in html + + +class TestUpdateAttendeePartstat: + """Tests for the _update_attendee_partstat function.""" + + def test_update_existing_partstat(self): + result = CalDAVHTTPClient.update_attendee_partstat( + SAMPLE_ICS, "bob@example.com", "ACCEPTED" + ) + assert result is not None + assert "PARTSTAT=ACCEPTED" in result + assert "PARTSTAT=NEEDS-ACTION" not in result + + def test_update_to_declined(self): + result = CalDAVHTTPClient.update_attendee_partstat( + SAMPLE_ICS, "bob@example.com", "DECLINED" + ) + assert result is not None + assert "PARTSTAT=DECLINED" in result + + def test_update_to_tentative(self): + result = CalDAVHTTPClient.update_attendee_partstat( + SAMPLE_ICS, "bob@example.com", "TENTATIVE" + ) + assert result is not None + assert "PARTSTAT=TENTATIVE" in result + + def test_unknown_attendee_returns_none(self): + result = CalDAVHTTPClient.update_attendee_partstat( + SAMPLE_ICS, "unknown@example.com", "ACCEPTED" + ) + assert result is None + + def test_preserves_other_attendee_properties(self): + result = CalDAVHTTPClient.update_attendee_partstat( + SAMPLE_ICS, "bob@example.com", "ACCEPTED" + ) + assert result is not None + assert "CN=Bob" in result + assert "mailto:bob@example.com" in result + + +@override_settings( + CALDAV_URL="http://caldav:80", + CALDAV_OUTBOUND_API_KEY="test-api-key", + APP_URL="http://localhost:8921", +) +class TestRSVPView(TestCase): + """Tests for the RSVPView.""" + + def setUp(self): + self.factory = RequestFactory() + self.view = RSVPView.as_view() + + def test_invalid_action_returns_400(self): + token = _make_token() + request = self.factory.get("/rsvp/", {"token": token, "action": "invalid"}) + 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"} + ) + response = self.view(request) + assert response.status_code == 400 + + def test_missing_token_returns_400(self): + request = self.factory.get("/rsvp/", {"action": "accepted"}) + response = self.view(request) + assert response.status_code == 400 + + @patch.object(CalDAVHTTPClient, "put_event") + @patch.object(CalDAVHTTPClient, "find_event_by_uid") + def test_accept_flow(self, mock_find, mock_put): + """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", + ) + mock_put.return_value = True + + token = _make_token() + request = self.factory.get("/rsvp/", {"token": token, "action": "accepted"}) + response = self.view(request) + + 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_put.assert_called_once() + # Check the updated data contains ACCEPTED + put_args = mock_put.call_args + assert "PARTSTAT=ACCEPTED" in put_args[0][2] + + @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_put.return_value = True + + token = _make_token() + request = self.factory.get("/rsvp/", {"token": token, "action": "declined"}) + response = self.view(request) + + assert response.status_code == 200 + assert "declined the invitation" in response.content.decode() + put_args = mock_put.call_args + assert "PARTSTAT=DECLINED" in put_args[0][2] + + @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_put.return_value = True + + token = _make_token() + request = self.factory.get("/rsvp/", {"token": token, "action": "tentative"}) + response = self.view(request) + + assert response.status_code == 200 + content = response.content.decode() + assert "maybe" in content.lower() + put_args = mock_put.call_args + assert "PARTSTAT=TENTATIVE" in put_args[0][2] + + @patch.object(CalDAVHTTPClient, "find_event_by_uid") + def test_event_not_found_returns_400(self, mock_find): + mock_find.return_value = (None, None) + + token = _make_token() + request = self.factory.get("/rsvp/", {"token": token, "action": "accepted"}) + response = self.view(request) + + assert response.status_code == 400 + assert "not found" in response.content.decode().lower() + + @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_put.return_value = False + + token = _make_token() + request = self.factory.get("/rsvp/", {"token": token, "action": "accepted"}) + response = self.view(request) + + assert response.status_code == 400 + assert "error occurred" in response.content.decode().lower() + + @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") + + # 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) + + assert response.status_code == 400 + assert "not listed" in response.content.decode().lower() + + @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") + + token = _make_token(uid="test-uid-past") + request = self.factory.get("/rsvp/", {"token": token, "action": "accepted"}) + response = self.view(request) + + assert response.status_code == 400 + assert "already passed" in response.content.decode().lower() + + +def _make_ics_with_method(method="REQUEST"): + """Build a sample ICS string that includes a METHOD property.""" + return ( + "BEGIN:VCALENDAR\r\n" + "VERSION:2.0\r\n" + f"METHOD:{method}\r\n" + "PRODID:-//Test//EN\r\n" + "BEGIN:VEVENT\r\n" + "UID:itip-test\r\n" + "DTSTART:20260301T100000Z\r\n" + "DTEND:20260301T110000Z\r\n" + "SUMMARY:iTIP test\r\n" + "ORGANIZER:mailto:alice@example.com\r\n" + "ATTENDEE:mailto:bob@example.com\r\n" + "END:VEVENT\r\n" + "END:VCALENDAR" + ) + + +@pytest.mark.django_db +class TestItipSetting: + """Tests for CALENDAR_ITIP_ENABLED setting on ICS attachments.""" + + def _prepare(self, ics_data, method="REQUEST"): + service = CalendarInvitationService() + return service._prepare_ics_attachment(ics_data, method) + + @override_settings(CALENDAR_ITIP_ENABLED=False) + def test_disabled_strips_existing_method(self): + result = self._prepare(_make_ics_with_method("REQUEST")) + cal = icalendar.Calendar.from_ical(result) + assert "METHOD" not in cal + + @override_settings(CALENDAR_ITIP_ENABLED=False) + def test_disabled_does_not_add_method(self): + result = self._prepare(SAMPLE_ICS) + cal = icalendar.Calendar.from_ical(result) + assert "METHOD" not in cal + + @override_settings(CALENDAR_ITIP_ENABLED=True) + def test_enabled_adds_method(self): + result = self._prepare(SAMPLE_ICS, method="REQUEST") + cal = icalendar.Calendar.from_ical(result) + assert str(cal["METHOD"]) == "REQUEST" + + @override_settings(CALENDAR_ITIP_ENABLED=True) + def test_enabled_updates_existing_method(self): + result = self._prepare(_make_ics_with_method("CANCEL"), method="REQUEST") + cal = icalendar.Calendar.from_ical(result) + assert str(cal["METHOD"]) == "REQUEST" + + +@override_settings( + CALDAV_URL="http://caldav:80", + CALDAV_OUTBOUND_API_KEY="test-api-key", + CALDAV_INBOUND_API_KEY="test-inbound-key", + APP_URL="http://localhost:8921", + 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. + """ + + def setUp(self): + self.factory = RequestFactory() + self.rsvp_view = RSVPView.as_view() + + 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 + """ + # Step 1: Send invitation via the CalendarInvitationService + service = CalendarInvitationService() + success = service.send_invitation( + sender_email="alice@example.com", + recipient_email="bob@example.com", + method="REQUEST", + icalendar_data=SAMPLE_ICS, + ) + assert success is True + assert len(mail.outbox) == 1 + + sent_email = mail.outbox[0] + assert "bob@example.com" in sent_email.to + + # Step 2: Extract RSVP accept link from email HTML + html_body = None + for alternative in sent_email.alternatives: + if alternative[1] == "text/html": + html_body = alternative[0] + break + assert html_body is not None, "Email should have an HTML body" + + # Find the accept link (green button with "Accepter") + accept_match = re.search(r' { + const { t } = useTranslation(); const calendarRef = useRef(null); const caldavService = useMemo(() => new CalDavService(), []); const adapter = useMemo(() => new EventCalendarAdapter(), []); const [davCalendars, setDavCalendars] = useState([]); + const davCalendarsRef = useRef([]); + davCalendarsRef.current = davCalendars; const [visibleCalendarUrls, setVisibleCalendarUrls] = useState>( new Set(), ); @@ -139,17 +147,27 @@ export const CalendarContextProvider = ({ ); } else { console.error("Error fetching calendars:", result.error); + addToast( + + {t("calendar.error.fetchCalendars")} + , + ); setDavCalendars([]); setVisibleCalendarUrls(new Set()); } } catch (error) { console.error("Error loading calendars:", error); + addToast( + + {t("calendar.error.fetchCalendars")} + , + ); setDavCalendars([]); setVisibleCalendarUrls(new Set()); } finally { setIsLoading(false); } - }, [caldavService]); + }, [caldavService, t]); const toggleCalendarVisibility = useCallback((calendarUrl: string) => { setVisibleCalendarUrls((prev) => { @@ -160,12 +178,13 @@ export const CalendarContextProvider = ({ newVisible.add(calendarUrl); } // Persist: store the hidden set (all known URLs minus visible) - const allUrls = davCalendars.map((cal) => cal.url); + // Use ref to avoid stale closure over davCalendars + const allUrls = davCalendarsRef.current.map((cal) => cal.url); const newHidden = new Set(allUrls.filter((url) => !newVisible.has(url))); saveHiddenUrls(newHidden); return newVisible; }); - }, [davCalendars]); + }, []); const createCalendar = useCallback( async ( @@ -319,11 +338,21 @@ export const CalendarContextProvider = ({ setIsLoading(false); } else if (isMounted) { console.error("Failed to connect to CalDAV:", result.error); + addToast( + + {t("calendar.error.connection")} + , + ); setIsLoading(false); } } catch (error) { if (isMounted) { console.error("Error connecting to CalDAV:", error); + addToast( + + {t("calendar.error.connection")} + , + ); setIsLoading(false); } } diff --git a/src/frontend/apps/calendars/src/features/calendar/services/dav/CalDavService.ts b/src/frontend/apps/calendars/src/features/calendar/services/dav/CalDavService.ts index 04bb3f2..bf1870f 100644 --- a/src/frontend/apps/calendars/src/features/calendar/services/dav/CalDavService.ts +++ b/src/frontend/apps/calendars/src/features/calendar/services/dav/CalDavService.ts @@ -13,6 +13,7 @@ import { convertIcsTimezone, generateIcsCalendar, type IcsCalendar, + type IcsDateObject, type IcsEvent, } from 'ts-ics' import { @@ -335,7 +336,10 @@ export class CalDavService { } /** - * Add EXDATE to a recurring event to exclude specific occurrences + * Add EXDATE to a recurring event to exclude specific occurrences. + * + * Uses ts-ics to parse and regenerate the ICS, ensuring correct + * EXDATE formatting (DATE vs DATE-TIME, timezone handling). */ async addExdateToEvent( eventUrl: string, @@ -357,111 +361,34 @@ export class CalDavService { throw new Error(`Failed to fetch event: ${fetchResponse.status}`) } - let icsText = await fetchResponse.text() + const icsText = await fetchResponse.text() - // Extract DTSTART format from the VEVENT block (not VTIMEZONE) - // Match DTSTART that comes after BEGIN:VEVENT - const veventMatch = icsText.match(/BEGIN:VEVENT[\s\S]*?DTSTART(;[^\r\n]*)?:([^\r\n]+)/) - let exdateLine = '' - - if (veventMatch) { - const dtstartParams = veventMatch[1] || '' - const dtstartValue = veventMatch[2] - - // Check if it's a DATE-only value (YYYYMMDD format, 8 chars) - const isDateOnly = dtstartValue.trim().length === 8 - - // Format the EXDATE to match DTSTART format - const pad = (n: number) => n.toString().padStart(2, '0') - - if (isDateOnly) { - // DATE format: YYYYMMDD - const year = exdateToAdd.getFullYear() - const month = pad(exdateToAdd.getMonth() + 1) - const day = pad(exdateToAdd.getDate()) - const formattedDate = `${year}${month}${day}` - exdateLine = `EXDATE${dtstartParams}:${formattedDate}` - } else { - // DATE-TIME format - const pad = (n: number) => n.toString().padStart(2, '0') - - // Extract time from DTSTART value (format: YYYYMMDDTHHMMSS or YYYYMMDDTHHMMSSZ) - const timeMatch = dtstartValue.match(/T(\d{2})(\d{2})(\d{2})/) - const originalHours = timeMatch ? timeMatch[1] : '00' - const originalMinutes = timeMatch ? timeMatch[2] : '00' - const originalSeconds = timeMatch ? timeMatch[3] : '00' - - // If DTSTART has TZID, use local time in that timezone - // Otherwise use UTC with Z suffix - if (dtstartParams.includes('TZID')) { - // Use the DATE from exdateToAdd but TIME from original DTSTART - const year = exdateToAdd.getFullYear() - const month = pad(exdateToAdd.getMonth() + 1) - const day = pad(exdateToAdd.getDate()) - const formattedDate = `${year}${month}${day}T${originalHours}${originalMinutes}${originalSeconds}` - exdateLine = `EXDATE${dtstartParams}:${formattedDate}` - } else { - // Use UTC time with Z suffix - const year = exdateToAdd.getUTCFullYear() - const month = pad(exdateToAdd.getUTCMonth() + 1) - const day = pad(exdateToAdd.getUTCDate()) - const formattedDate = `${year}${month}${day}T${originalHours}${originalMinutes}${originalSeconds}Z` - exdateLine = `EXDATE${dtstartParams}:${formattedDate}` - } - } - } else { - // Fallback if DTSTART not found - use UTC DATE-TIME format - const pad = (n: number) => n.toString().padStart(2, '0') - const year = exdateToAdd.getUTCFullYear() - const month = pad(exdateToAdd.getUTCMonth() + 1) - const day = pad(exdateToAdd.getUTCDate()) - const hours = pad(exdateToAdd.getUTCHours()) - const minutes = pad(exdateToAdd.getUTCMinutes()) - const seconds = pad(exdateToAdd.getUTCSeconds()) - const formattedDate = `${year}${month}${day}T${hours}${minutes}${seconds}Z` - exdateLine = `EXDATE:${formattedDate}` + // Parse ICS into structured object + const calendar = convertIcsCalendar(undefined, icsText) + const event = calendar.events?.[0] + if (!event) { + throw new Error('No event found in ICS data') } - // Find the RRULE line in the VEVENT block and add EXDATE after it - const lines = icsText.split('\n') - const newLines: string[] = [] - let exdateAdded = false - let inVEvent = false - - // Extract just the date value from our exdateLine for appending - const exdateValueMatch = exdateLine.match(/:([^\r\n]+)$/) - const exdateValue = exdateValueMatch ? exdateValueMatch[1] : '' - - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim() - - // Track if we're inside a VEVENT block - if (line === 'BEGIN:VEVENT') { - inVEvent = true - newLines.push(lines[i]) - } else if (line === 'END:VEVENT') { - inVEvent = false - newLines.push(lines[i]) - } - // If EXDATE already exists in VEVENT, append to it - else if (inVEvent && !exdateAdded && (line.startsWith('EXDATE:') || line.startsWith('EXDATE;'))) { - newLines.push(`${lines[i]},${exdateValue}`) - exdateAdded = true - } - // Only add EXDATE after RRULE if we're inside VEVENT and no EXDATE exists yet - else if (inVEvent && !exdateAdded && line.startsWith('RRULE:')) { - newLines.push(lines[i]) - newLines.push(exdateLine) - exdateAdded = true - } - else { - newLines.push(lines[i]) - } + // Build EXDATE entry matching DTSTART format (DATE vs DATE-TIME, timezone) + const newExdate: IcsDateObject = { + date: exdateToAdd, + type: event.start.type, + local: event.start.local ? { + date: exdateToAdd, + timezone: event.start.local.timezone, + tzoffset: event.start.local.tzoffset, + } : undefined, } - icsText = newLines.join('\n') + // Append to existing exception dates + event.exceptionDates = [...(event.exceptionDates ?? []), newExdate] - // Update the event with modified ICS + // Regenerate ICS with the updated event + this.validateTimezones(calendar) + const updatedIcsText = generateIcsCalendar(calendar) + + // PUT the updated event back const updateResponse = await fetch(eventUrl, { method: 'PUT', headers: { @@ -469,7 +396,7 @@ export class CalDavService { ...(etag ? { 'If-Match': etag } : {}), ...this._account?.headers, }, - body: icsText, + body: updatedIcsText, ...this._account?.fetchOptions, }) diff --git a/src/frontend/apps/calendars/src/features/calendar/utils/DavClient.ts b/src/frontend/apps/calendars/src/features/calendar/utils/DavClient.ts index d9b3b8d..6b56d74 100644 --- a/src/frontend/apps/calendars/src/features/calendar/utils/DavClient.ts +++ b/src/frontend/apps/calendars/src/features/calendar/utils/DavClient.ts @@ -5,7 +5,9 @@ * Used by CalendarContext to initialize CalDavService. */ -export const caldavServerUrl = `${process.env.NEXT_PUBLIC_API_ORIGIN}/api/v1.0/caldav/`; +import { getOrigin } from "@/features/api/utils"; + +export const caldavServerUrl = `${getOrigin()}/api/v1.0/caldav/`; export const headers = { "Content-Type": "application/xml", diff --git a/src/frontend/apps/calendars/src/features/i18n/translations.json b/src/frontend/apps/calendars/src/features/i18n/translations.json index 1d1a9a1..4737336 100644 --- a/src/frontend/apps/calendars/src/features/i18n/translations.json +++ b/src/frontend/apps/calendars/src/features/i18n/translations.json @@ -91,6 +91,10 @@ "seconds_ago": "few seconds ago" }, "calendar": { + "error": { + "connection": "Failed to connect to the calendar server. Please try again later.", + "fetchCalendars": "Failed to load calendars. Please try again later." + }, "views": { "day": "Day", "week": "Week", @@ -319,6 +323,108 @@ "organizer": "Organizer", "viewProfile": "View profile", "cannotRemoveOrganizer": "Cannot remove organizer" + }, + "weekdaysFull": { + "monday": "Monday", + "tuesday": "Tuesday", + "wednesday": "Wednesday", + "thursday": "Thursday", + "friday": "Friday", + "saturday": "Saturday", + "sunday": "Sunday" + } + }, + "email": { + "subject": { + "invitation": "Invitation: {{summary}}", + "update": "Updated invitation: {{summary}}", + "cancel": "Cancelled: {{summary}}", + "reply": "Reply: {{summary}}" + }, + "noTitle": "(No title)", + "allDay": "All day", + "invitation": { + "title": "Event invitation", + "heading": "Event invitation", + "body": "{{organizer}} invites you to an event" + }, + "update": { + "title": "Event updated", + "heading": "Event updated", + "badge": "UPDATED", + "body": "{{organizer}} has updated an event you are invited to" + }, + "cancel": { + "title": "Event cancelled", + "heading": "Event cancelled", + "badge": "CANCELLED", + "body": "{{organizer}} has cancelled the following event" + }, + "reply": { + "title": "Event reply", + "heading": "Reply received", + "body": "{{attendee}} has replied to your event" + }, + "labels": { + "when": "When", + "until": "until", + "location": "Location", + "videoConference": "Video conference", + "organizer": "Organizer", + "attendee": "Attendee", + "description": "Description", + "wasScheduledFor": "Was scheduled for" + }, + "actions": { + "accept": "Accept", + "maybe": "Maybe", + "decline": "Decline" + }, + "instructions": { + "invitation": "You can also open the attached .ics file with your calendar application.", + "update": "This event has been updated. You can also open the attached .ics file to update your calendar.", + "cancel": "This event has been cancelled. Open the attached .ics file to remove it from your calendar.", + "reply": "The attendee's reply has been recorded. Open the attached .ics file to update your calendar." + }, + "footer": { + "invitation": "This invitation was sent via {{appName}}", + "notification": "This notification was sent via {{appName}}" + }, + "replyTo": { + "body": "{{attendee}} has replied to your event: {{summary}}", + "details": "Event details", + "instruction": "The attendee's reply has been recorded in the attached .ics file." + }, + "cancelDetails": { + "body": "{{organizer}} has cancelled the event: {{summary}}", + "details": "Cancelled event details", + "instruction": "This event has been cancelled. You can remove it from your calendar by opening the attached .ics file." + }, + "updateDetails": { + "body": "{{organizer}} has updated the event", + "details": "Updated event details" + }, + "invitationDetails": { + "body": "{{organizer}} invites you to an event", + "details": "Event details", + "respond": "Respond to this invitation:" + } + }, + "rsvp": { + "accepted": "You have accepted the invitation", + "tentative": "You have replied \"maybe\" to the invitation", + "declined": "You have declined the invitation", + "responseSent": "Your response has been sent to the organizer.", + "error": { + "title": "Error", + "invalidLink": "Invalid link", + "invalidAction": "Invalid action.", + "invalidToken": "This link is invalid or has expired.", + "invalidPayload": "This link is invalid.", + "eventNotFound": "The event was not found. It may have been deleted.", + "eventPast": "This event has already passed.", + "notAttendee": "You are not listed as an attendee of this event.", + "updateFailed": "An error occurred while updating. Please try again." } } } @@ -718,6 +824,10 @@ "seconds_ago": "il y a quelques secondes" }, "calendar": { + "error": { + "connection": "Impossible de se connecter au serveur de calendrier. Veuillez réessayer plus tard.", + "fetchCalendars": "Impossible de charger les calendriers. Veuillez réessayer plus tard." + }, "views": { "day": "Jour", "week": "Semaine", @@ -946,6 +1056,108 @@ "organizer": "Organisateur", "viewProfile": "Voir le profil", "cannotRemoveOrganizer": "Impossible de retirer l'organisateur" + }, + "weekdaysFull": { + "monday": "lundi", + "tuesday": "mardi", + "wednesday": "mercredi", + "thursday": "jeudi", + "friday": "vendredi", + "saturday": "samedi", + "sunday": "dimanche" + } + }, + "email": { + "subject": { + "invitation": "Invitation : {{summary}}", + "update": "Invitation modifiée : {{summary}}", + "cancel": "Annulé : {{summary}}", + "reply": "Réponse : {{summary}}" + }, + "noTitle": "(Sans titre)", + "allDay": "Toute la journée", + "invitation": { + "title": "Invitation à un événement", + "heading": "Invitation à un événement", + "body": "{{organizer}} vous invite à un événement" + }, + "update": { + "title": "Événement modifié", + "heading": "Événement modifié", + "badge": "MODIFIÉ", + "body": "{{organizer}} a modifié un événement auquel vous êtes invité(e)" + }, + "cancel": { + "title": "Événement annulé", + "heading": "Événement annulé", + "badge": "ANNULÉ", + "body": "{{organizer}} a annulé l'événement suivant" + }, + "reply": { + "title": "Réponse à l'événement", + "heading": "Réponse reçue", + "body": "{{attendee}} a répondu à votre événement" + }, + "labels": { + "when": "Quand", + "until": "jusqu'au", + "location": "Lieu", + "videoConference": "Visio", + "organizer": "Organisateur", + "attendee": "Participant", + "description": "Description", + "wasScheduledFor": "Était prévu le" + }, + "actions": { + "accept": "Accepter", + "maybe": "Peut-être", + "decline": "Refuser" + }, + "instructions": { + "invitation": "Vous pouvez aussi ouvrir le fichier .ics en pièce jointe avec votre application de calendrier.", + "update": "Cet événement a été modifié. Vous pouvez aussi ouvrir le fichier .ics en pièce jointe pour mettre à jour votre calendrier.", + "cancel": "Cet événement a été annulé. Ouvrez le fichier .ics en pièce jointe pour le supprimer de votre calendrier.", + "reply": "La réponse du participant a été enregistrée. Ouvrez le fichier .ics en pièce jointe pour mettre à jour votre calendrier." + }, + "footer": { + "invitation": "Cette invitation a été envoyée via {{appName}}", + "notification": "Cette notification a été envoyée via {{appName}}" + }, + "replyTo": { + "body": "{{attendee}} a répondu à votre événement : {{summary}}", + "details": "Détails de l'événement", + "instruction": "La réponse du participant a été enregistrée dans le fichier .ics en pièce jointe." + }, + "cancelDetails": { + "body": "{{organizer}} a annulé l'événement : {{summary}}", + "details": "Détails de l'événement annulé", + "instruction": "Cet événement a été annulé. Vous pouvez le supprimer de votre calendrier en ouvrant le fichier .ics en pièce jointe." + }, + "updateDetails": { + "body": "{{organizer}} a modifié l'événement", + "details": "Détails de l'événement mis à jour" + }, + "invitationDetails": { + "body": "{{organizer}} vous invite à un événement", + "details": "Détails de l'événement", + "respond": "Répondre à cette invitation :" + } + }, + "rsvp": { + "accepted": "Vous avez accepté l'invitation", + "tentative": "Vous avez répondu « peut-être » à l'invitation", + "declined": "Vous avez décliné l'invitation", + "responseSent": "Votre réponse a été envoyée à l'organisateur.", + "error": { + "title": "Erreur", + "invalidLink": "Lien invalide", + "invalidAction": "Action invalide.", + "invalidToken": "Ce lien est invalide ou a expiré.", + "invalidPayload": "Ce lien est invalide.", + "eventNotFound": "L'événement n'a pas été trouvé. Il a peut-être été supprimé.", + "eventPast": "Cet événement est déjà passé.", + "notAttendee": "Vous ne figurez pas parmi les participants de cet événement.", + "updateFailed": "Une erreur est survenue lors de la mise à jour. Veuillez réessayer." } } } @@ -1092,6 +1304,10 @@ "seconds_ago": "een paar seconden geleden" }, "calendar": { + "error": { + "connection": "Kan geen verbinding maken met de kalenderserver. Probeer het later opnieuw.", + "fetchCalendars": "Kan kalenders niet laden. Probeer het later opnieuw." + }, "views": { "day": "Dag", "week": "Week", @@ -1320,6 +1536,108 @@ "organizer": "Organisator", "viewProfile": "Profiel bekijken", "cannotRemoveOrganizer": "Kan organisator niet verwijderen" + }, + "weekdaysFull": { + "monday": "maandag", + "tuesday": "dinsdag", + "wednesday": "woensdag", + "thursday": "donderdag", + "friday": "vrijdag", + "saturday": "zaterdag", + "sunday": "zondag" + } + }, + "email": { + "subject": { + "invitation": "Uitnodiging: {{summary}}", + "update": "Bijgewerkte uitnodiging: {{summary}}", + "cancel": "Geannuleerd: {{summary}}", + "reply": "Antwoord: {{summary}}" + }, + "noTitle": "(Geen titel)", + "allDay": "Hele dag", + "invitation": { + "title": "Uitnodiging voor evenement", + "heading": "Uitnodiging voor evenement", + "body": "{{organizer}} nodigt u uit voor een evenement" + }, + "update": { + "title": "Evenement bijgewerkt", + "heading": "Evenement bijgewerkt", + "badge": "BIJGEWERKT", + "body": "{{organizer}} heeft een evenement bijgewerkt waarvoor u bent uitgenodigd" + }, + "cancel": { + "title": "Evenement geannuleerd", + "heading": "Evenement geannuleerd", + "badge": "GEANNULEERD", + "body": "{{organizer}} heeft het volgende evenement geannuleerd" + }, + "reply": { + "title": "Antwoord op evenement", + "heading": "Antwoord ontvangen", + "body": "{{attendee}} heeft gereageerd op uw evenement" + }, + "labels": { + "when": "Wanneer", + "until": "tot", + "location": "Locatie", + "videoConference": "Videoconferentie", + "organizer": "Organisator", + "attendee": "Deelnemer", + "description": "Beschrijving", + "wasScheduledFor": "Was gepland op" + }, + "actions": { + "accept": "Accepteren", + "maybe": "Misschien", + "decline": "Weigeren" + }, + "instructions": { + "invitation": "U kunt ook het bijgevoegde .ics-bestand openen met uw agendatoepassing.", + "update": "Dit evenement is bijgewerkt. U kunt ook het bijgevoegde .ics-bestand openen om uw agenda bij te werken.", + "cancel": "Dit evenement is geannuleerd. Open het bijgevoegde .ics-bestand om het uit uw agenda te verwijderen.", + "reply": "Het antwoord van de deelnemer is geregistreerd. Open het bijgevoegde .ics-bestand om uw agenda bij te werken." + }, + "footer": { + "invitation": "Deze uitnodiging is verzonden via {{appName}}", + "notification": "Deze melding is verzonden via {{appName}}" + }, + "replyTo": { + "body": "{{attendee}} heeft gereageerd op uw evenement: {{summary}}", + "details": "Evenementdetails", + "instruction": "Het antwoord van de deelnemer is geregistreerd in het bijgevoegde .ics-bestand." + }, + "cancelDetails": { + "body": "{{organizer}} heeft het evenement geannuleerd: {{summary}}", + "details": "Details van het geannuleerde evenement", + "instruction": "Dit evenement is geannuleerd. U kunt het uit uw agenda verwijderen door het bijgevoegde .ics-bestand te openen." + }, + "updateDetails": { + "body": "{{organizer}} heeft het evenement bijgewerkt", + "details": "Bijgewerkte evenementdetails" + }, + "invitationDetails": { + "body": "{{organizer}} nodigt u uit voor een evenement", + "details": "Evenementdetails", + "respond": "Reageer op deze uitnodiging:" + } + }, + "rsvp": { + "accepted": "U heeft de uitnodiging geaccepteerd", + "tentative": "U heeft \"misschien\" geantwoord op de uitnodiging", + "declined": "U heeft de uitnodiging afgewezen", + "responseSent": "Uw antwoord is naar de organisator gestuurd.", + "error": { + "title": "Fout", + "invalidLink": "Ongeldige link", + "invalidAction": "Ongeldige actie.", + "invalidToken": "Deze link is ongeldig of verlopen.", + "invalidPayload": "Deze link is ongeldig.", + "eventNotFound": "Het evenement is niet gevonden. Het is mogelijk verwijderd.", + "eventPast": "Dit evenement is al voorbij.", + "notAttendee": "U staat niet vermeld als deelnemer van dit evenement.", + "updateFailed": "Er is een fout opgetreden bij het bijwerken. Probeer het opnieuw." } } } diff --git a/src/nginx/servers.conf.erb b/src/nginx/servers.conf.erb index d11c2a8..8beba22 100644 --- a/src/nginx/servers.conf.erb +++ b/src/nginx/servers.conf.erb @@ -24,6 +24,35 @@ server { proxy_pass http://backend_server; } + # CalDAV well-known discovery + location = /.well-known/caldav { + proxy_set_header X-Forwarded-Proto https; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + proxy_redirect off; + proxy_pass http://backend_server; + } + + # Django views (RSVP, iCal export) + location ^~ /rsvp/ { + proxy_set_header X-Forwarded-Proto https; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + proxy_redirect off; + proxy_pass http://backend_server; + } + + location ^~ /ical/ { + proxy_set_header X-Forwarded-Proto https; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + proxy_redirect off; + proxy_pass http://backend_server; + } + # Django admin location ^~ /admin/ { proxy_set_header X-Forwarded-Proto https; @@ -48,7 +77,7 @@ server { # Frontend export location / { - try_files $uri index.html $uri/ =404; + try_files $uri $uri.html $uri/ =404; } }