Revert "[verified] feat: add external calendar subscriptions"
This reverts commit 583b6a8a9d.
This commit is contained in:
@@ -1,233 +0,0 @@
|
||||
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"]))
|
||||
Reference in New Issue
Block a user