[verified] feat: add external calendar subscriptions

This commit is contained in:
2026-09-20 12:29:21 +08:00
parent d7014c4f4f
commit 583b6a8a9d
11 changed files with 849 additions and 2 deletions
+233
View File
@@ -0,0 +1,233 @@
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: 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_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,
*,
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"))
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_name, color, occurrence, end, all_day))
else:
end = start + duration
if _overlaps(start, end, window_start, window_end):
events.append(_event_dict(event, 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)
end = start + _duration(event, start, all_day, timezone)
if _overlaps(start, end, window_start, window_end):
events.append(_event_dict(event, source_name, color, start, end, all_day))
return sorted(events, key=lambda item: (item["starts_at"], item["title"], item["id"]))