from __future__ import annotations import http.client import ipaddress import socket import ssl import urllib.parse from dataclasses import dataclass from datetime import UTC, date, datetime, time, timedelta from itertools import islice from typing import Any from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from dateutil.rrule import rrulestr from fastapi import HTTPException from icalendar import Calendar MAX_ICS_BYTES = 2_000_000 MAX_REDIRECTS = 3 DEFAULT_RECURRENCE_LIMIT = 10_000 TIMEOUT_SECONDS = 10 _ALLOWED_CONTENT_TYPES = {"text/calendar", "text/plain", "application/octet-stream"} @dataclass(frozen=True) class FetchResult: content: bytes | None etag: str | None last_modified: str | None not_modified: bool def _is_global(value: str) -> bool: return ipaddress.ip_address(value.split("%", 1)[0]).is_global def validate_calendar_url(url: str) -> tuple[str, str, int]: try: parsed = urllib.parse.urlsplit(url) port = parsed.port or (443 if parsed.scheme == "https" else 80) except ValueError as exc: raise HTTPException(422, "invalid calendar URL") from exc if ( parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username is not None or parsed.password is not None or parsed.fragment ): raise HTTPException(422, "calendar URL must be HTTP(S) without userinfo or fragment") try: infos = socket.getaddrinfo(parsed.hostname, port, type=socket.SOCK_STREAM) except socket.gaierror as exc: raise HTTPException(422, "calendar host cannot be resolved") from exc addresses = {item[4][0] for item in infos} if not addresses or not all(_is_global(address) for address in addresses): raise HTTPException(422, "calendar host must resolve only to public addresses") return url, min(addresses), port def _request(url: str, headers: dict[str, str]) -> tuple[int, list[tuple[str, str]], str, bytes]: _, ip, port = validate_calendar_url(url) parsed = urllib.parse.urlsplit(url) target = f"[{ip}]" if ":" in ip else ip host = parsed.hostname or "" if parsed.port: host = f"{host}:{parsed.port}" request_headers = {"Host": host, "User-Agent": "dodo-calendar-fetch/1.0", **headers} path = urllib.parse.urlunsplit(("", "", parsed.path or "/", parsed.query, "")) connection: http.client.HTTPConnection if parsed.scheme == "https": connection = http.client.HTTPSConnection(target, port=port, timeout=TIMEOUT_SECONDS) else: connection = http.client.HTTPConnection(target, port=port, timeout=TIMEOUT_SECONDS) try: if parsed.scheme == "https": raw = socket.create_connection((ip, port), timeout=TIMEOUT_SECONDS) connection.sock = ssl.create_default_context().wrap_socket( raw, server_hostname=parsed.hostname ) connection.request("GET", path, headers=request_headers) response = connection.getresponse() length = response.getheader("Content-Length") if length and int(length) > MAX_ICS_BYTES: raise HTTPException(413, "calendar exceeds 2MB") body = response.read(MAX_ICS_BYTES + 1) return response.status, response.getheaders(), response.getheader("Content-Type") or "", body except HTTPException: raise except (OSError, http.client.HTTPException, ValueError) as exc: raise HTTPException(502, "calendar upstream unavailable") from exc finally: connection.close() def fetch_calendar(url: str, *, etag: str | None = None, last_modified: str | None = None) -> FetchResult: headers = {} if etag: headers["If-None-Match"] = etag if last_modified: headers["If-Modified-Since"] = last_modified current = url for redirect_count in range(MAX_REDIRECTS + 1): status, response_headers, content_type, body = _request(current, headers) header_map = {key.lower(): value for key, value in response_headers} if status in {301, 302, 303, 307, 308}: if redirect_count == MAX_REDIRECTS or not header_map.get("location"): raise HTTPException(502, "calendar redirect limit exceeded") current = urllib.parse.urljoin(current, header_map["location"]) validate_calendar_url(current) continue if status == 304: return FetchResult(None, etag, last_modified, True) if status >= 400: raise HTTPException(502, f"calendar upstream returned HTTP {status}") if len(body) > MAX_ICS_BYTES: raise HTTPException(413, "calendar exceeds 2MB") if content_type.split(";", 1)[0].lower() not in _ALLOWED_CONTENT_TYPES: raise HTTPException(422, "URL did not return an iCalendar document") return FetchResult(body, header_map.get("etag"), header_map.get("last-modified"), False) raise HTTPException(502, "calendar redirect limit exceeded") def _localize(value: date | datetime, timezone: ZoneInfo) -> tuple[datetime, bool]: if isinstance(value, datetime): return (value if value.tzinfo else value.replace(tzinfo=timezone)), False return datetime.combine(value, time.min, tzinfo=timezone), True def _utc(value: date | datetime, timezone: ZoneInfo) -> tuple[datetime, bool]: localized, all_day = _localize(value, timezone) return localized.astimezone(UTC), all_day def _duration(event: Any, starts_at: datetime, all_day: bool, timezone: ZoneInfo) -> timedelta: if event.get("dtend"): ends_at, _ = _utc(event.decoded("dtend"), timezone) return max(ends_at - starts_at, timedelta()) if event.get("duration"): return event.decoded("duration") return timedelta(days=1) if all_day else timedelta(hours=1) def _exdates(event: Any, timezone: ZoneInfo) -> set[datetime]: values = event.get("exdate") if not values: return set() excluded = set() for item in values if isinstance(values, list) else [values]: for value in getattr(item, "dts", []): excluded.add(_utc(value.dt, timezone)[0]) return excluded def _overlaps(start: datetime, end: datetime, window_start: datetime, window_end: datetime) -> bool: return start < window_end and end > window_start def _event_dict(event: Any, source_id: str, source: str, color: str, start: datetime, end: datetime, all_day: bool) -> dict: uid = str(event.get("uid") or "") title = str(event.get("summary") or "Untitled event").strip() or "Untitled event" return { "id": f"{uid or title}:{start.isoformat()}", "title": title, "starts_at": start, "ends_at": end, "all_day": all_day, "source_id": source_id, "source_name": source, "color": color, } def parse_ics_events( content: bytes | str, source_name: str, color: str, window_start: datetime, window_end: datetime, timezone_name: str, *, source_id: str = "", recurrence_limit: int = DEFAULT_RECURRENCE_LIMIT, ) -> list[dict]: try: timezone = ZoneInfo(timezone_name) except ZoneInfoNotFoundError as exc: raise ValueError("invalid timezone") from exc try: calendar = Calendar.from_ical(content) except Exception as exc: raise ValueError("invalid iCalendar document") from exc components = list(calendar.walk("VEVENT")) master_durations = {} for event in components: if event.get("dtstart") and not event.get("recurrence-id"): master_start, master_all_day = _utc(event.decoded("dtstart"), timezone) master_durations[str(event.get("uid") or "")] = _duration( event, master_start, master_all_day, timezone ) overrides = {} for event in components: recurrence_id = event.get("recurrence-id") if recurrence_id: overrides[(str(event.get("uid") or ""), _utc(event.decoded("recurrence-id"), timezone)[0])] = event events = [] for event in components: if not event.get("dtstart") or event.get("recurrence-id") or str(event.get("status") or "").upper() == "CANCELLED": continue local_start, all_day = _localize(event.decoded("dtstart"), timezone) start = local_start.astimezone(UTC) duration = _duration(event, start, all_day, timezone) uid = str(event.get("uid") or "") if event.get("rrule"): try: rule = rrulestr(event.get("rrule").to_ical().decode(), dtstart=local_start) bounded = rule.xafter(window_start - duration, count=recurrence_limit + 1, inc=True) occurrences = [item for item in islice(bounded, recurrence_limit + 1) if item < window_end] except Exception as exc: raise ValueError("invalid recurrence rule") from exc if len(occurrences) > recurrence_limit: raise ValueError("recurrence limit exceeded") excluded = _exdates(event, timezone) for occurrence in occurrences: occurrence = (occurrence if occurrence.tzinfo else occurrence.replace(tzinfo=timezone)).astimezone(UTC) if occurrence in excluded or (uid, occurrence) in overrides: continue end = occurrence + duration if _overlaps(occurrence, end, window_start, window_end): events.append(_event_dict(event, source_id, source_name, color, occurrence, end, all_day)) else: end = start + duration if _overlaps(start, end, window_start, window_end): events.append(_event_dict(event, source_id, source_name, color, start, end, all_day)) for event in overrides.values(): if not event.get("dtstart") or str(event.get("status") or "").upper() == "CANCELLED": continue start, all_day = _utc(event.decoded("dtstart"), timezone) uid = str(event.get("uid") or "") duration = ( _duration(event, start, all_day, timezone) if event.get("dtend") or event.get("duration") else master_durations.get(uid, timedelta(days=1) if all_day else timedelta(hours=1)) ) end = start + duration if _overlaps(start, end, window_start, window_end): events.append(_event_dict(event, source_id, source_name, color, start, end, all_day)) return sorted(events, key=lambda item: (item["starts_at"], item["title"], item["id"]))