| """CalDAV → local SQLite sync.
|
|
|
| The Settings UI lets users save CalDAV credentials, but the original
|
| sync path was removed when calendar storage was migrated to SQLite.
|
| This module re-wires that gap as a one-way pull (remote → local),
|
| called on calendar open and from a periodic scheduler loop.
|
|
|
| Design notes:
|
| - We use the `caldav` lib so PROPFIND discovery + REPORT XML work
|
| across Radicale / Nextcloud / Apple / Fastmail without us
|
| reinventing the protocol. It's pure Python.
|
| - The lib is synchronous; we run it in a threadpool via
|
| `asyncio.to_thread` so the FastAPI event loop stays free.
|
| - Each remote calendar maps to one local `CalendarCal` row with
|
| `source="caldav"` and `id` = a stable hash of the remote URL so
|
| re-syncs idempotently target the same row.
|
| - Events upsert by VEVENT UID (kept as the local `uid`). Local
|
| CalDAV-sourced events not seen in the latest pull are deleted so
|
| remote deletions propagate.
|
| - Datetimes are converted to UTC and the row is flagged `is_utc=True`
|
| so the serializer adds the Z suffix and the frontend renders in the
|
| user's local TZ correctly.
|
| """
|
|
|
| import asyncio
|
| import hashlib
|
| import ipaddress
|
| import logging
|
| import os
|
| import socket
|
| import uuid
|
| from datetime import date, datetime, timedelta, timezone
|
| from urllib.parse import urlparse, urlunparse
|
|
|
| logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
| _LOOKBACK_DAYS = 90
|
| _LOOKAHEAD_DAYS = 365
|
| _BLOCKED_HOSTS = {
|
| "localhost",
|
| "localhost.",
|
| "ip6-localhost",
|
| "metadata.google.internal",
|
| }
|
|
|
|
|
| def _private_caldav_allowed() -> bool:
|
| return os.environ.get("ODYSSEUS_ALLOW_PRIVATE_CALDAV", "0").lower() in {"1", "true", "yes"}
|
|
|
|
|
| def _validate_caldav_address(addr: ipaddress._BaseAddress) -> None:
|
| if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
|
| addr = addr.ipv4_mapped
|
| if (
|
| addr.is_loopback
|
| or addr.is_link_local
|
| or addr.is_multicast
|
| or addr.is_unspecified
|
| or addr.is_reserved
|
| ):
|
| raise ValueError("CalDAV URL host is not allowed")
|
| if addr.is_private and not _private_caldav_allowed():
|
| raise ValueError("Private CalDAV IPs require ODYSSEUS_ALLOW_PRIVATE_CALDAV=1")
|
|
|
|
|
| def _validate_caldav_ip(host: str) -> None:
|
| try:
|
| ip = ipaddress.ip_address(host.strip("[]"))
|
| except ValueError:
|
| return
|
| _validate_caldav_address(ip)
|
|
|
|
|
| def _resolve_caldav_host_ips(host: str) -> list[ipaddress._BaseAddress]:
|
| addrs: list[ipaddress._BaseAddress] = []
|
| for family, _, _, _, sockaddr in socket.getaddrinfo(host, None):
|
| if family not in (socket.AF_INET, socket.AF_INET6):
|
| continue
|
| try:
|
| addrs.append(ipaddress.ip_address(sockaddr[0].split("%", 1)[0]))
|
| except ValueError:
|
| continue
|
| return addrs
|
|
|
|
|
| def _validate_caldav_hostname(host: str) -> None:
|
| try:
|
| ipaddress.ip_address(host.strip("[]"))
|
| return
|
| except ValueError:
|
| pass
|
| try:
|
| addrs = _resolve_caldav_host_ips(host)
|
| except OSError:
|
| raise ValueError("CalDAV URL host does not resolve")
|
| if not addrs:
|
| raise ValueError("CalDAV URL host does not resolve")
|
| for addr in addrs:
|
| _validate_caldav_address(addr)
|
|
|
|
|
| def validate_caldav_url(raw_url: str) -> str:
|
| """Validate and normalize a user-provided CalDAV URL before server-side use."""
|
| url = (raw_url if isinstance(raw_url, str) else "").strip()
|
| if not url:
|
| raise ValueError("CalDAV URL is required")
|
| parsed = urlparse(url)
|
| if parsed.scheme not in {"http", "https"}:
|
| raise ValueError("CalDAV URL must start with http:// or https://")
|
| if not parsed.hostname:
|
| raise ValueError("CalDAV URL must include a host")
|
| if parsed.username or parsed.password:
|
| raise ValueError("Put CalDAV credentials in the username/password fields, not the URL")
|
| if parsed.fragment:
|
| raise ValueError("CalDAV URL fragments are not allowed")
|
| try:
|
| parsed.port
|
| except ValueError:
|
| raise ValueError("CalDAV URL has an invalid port")
|
| host = (parsed.hostname or "").lower()
|
| if host in _BLOCKED_HOSTS or host.endswith(".localhost"):
|
| raise ValueError("CalDAV URL host is not allowed")
|
| _validate_caldav_ip(host)
|
| _validate_caldav_hostname(host)
|
| return urlunparse(parsed._replace(fragment="")).rstrip("/")
|
|
|
|
|
| def _stable_cal_id(remote_url: str, owner: str = "", account_id: str = "") -> str:
|
| """Deterministic local id for a remote CalDAV calendar, scoped to owner
|
| and account so two users — or one user with two accounts — pointing at
|
| the same server URL get distinct local rows (avoids PK collision, #2765).
|
| The owner and account_id default to "" for the legacy/URL-only path so
|
| existing callers without those arguments keep working."""
|
| key = f"{owner}\n{account_id}\n{remote_url}"
|
| h = hashlib.sha256(key.encode("utf-8")).hexdigest()[:24]
|
| return f"caldav-{h}"
|
|
|
|
|
| def _to_utc_naive(dt):
|
| """CalDAV datetimes can be tz-aware (with a TZID) or naive. The DB
|
| column is naive but we set is_utc=True so the serializer adds Z.
|
| All-day events stay as date and get widened to datetime here."""
|
| if isinstance(dt, datetime):
|
| if dt.tzinfo is not None:
|
| return dt.astimezone(timezone.utc).replace(tzinfo=None), False
|
| return dt, False
|
|
|
| return datetime(dt.year, dt.month, dt.day), True
|
|
|
|
|
| def _find_existing_event(db, pending, uid_val, calendar_id):
|
| """Find the event to update for THIS calendar.
|
|
|
| CalendarEvent.uid is the global primary key, so an unscoped lookup by uid
|
| returns whatever row holds that VEVENT uid — including another owner's.
|
| The old code then reassigned that row's calendar_id, moving (stealing)
|
| another user's event into the syncing calendar whenever the two share a
|
| uid (shared/subscribed/public calendars, or two accounts on one server).
|
| Scope the lookup to the calendar being synced; a genuine cross-user uid
|
| collision then fails the PK insert inside the per-calendar try/except
|
| instead of hijacking the row. (import_ics was already fixed this way.)
|
| """
|
| from core.database import CalendarEvent
|
| return pending.get(uid_val) or db.query(CalendarEvent).filter(
|
| CalendarEvent.uid == uid_val,
|
| CalendarEvent.calendar_id == calendar_id,
|
| ).first()
|
|
|
|
|
| def _google_caldav_events_url(url: str) -> str | None:
|
| """Map a Google CalDAV *principal* URL to its event-collection URL.
|
|
|
| Google serves the principal at ``…/user`` but events live under ``…/events``
|
| — the ``/user`` resource holds no VEVENTs. The `caldav` library's
|
| principal→home-set discovery does not reliably enumerate calendars from
|
| Google's ``/user`` endpoint, so the sync falls into the "treat the URL as a
|
| single calendar" fallback below. Pointed at ``/user`` that fallback issues
|
| every calendar-query REPORT against the principal, which returns a clean but
|
| empty 200 for all date ranges — the calendar shows no events even though
|
| auth succeeded (issue #2507).
|
|
|
| Both Google CalDAV endpoint forms are handled, since some accounts only
|
| authenticate against one of them:
|
| - newer: ``https://apidata.googleusercontent.com/caldav/v2/<id>/user``
|
| - legacy: ``https://www.google.com/calendar/dav/<id>/user``
|
|
|
| Returns the events URL for a recognised Google principal URL, else None so
|
| the caller keeps the original URL unchanged.
|
| """
|
| parts = urlparse(url)
|
| host = (parts.hostname or "").lower()
|
| path = parts.path.rstrip("/")
|
| if not path.endswith("/user"):
|
| return None
|
| is_google = (
|
| host.endswith("googleusercontent.com")
|
| or (host in ("www.google.com", "google.com") and "/calendar/dav/" in path)
|
| )
|
| if not is_google:
|
| return None
|
| new_path = path[: -len("/user")] + "/events"
|
| return urlunparse(parts._replace(path=new_path))
|
|
|
|
|
| def _open_url_as_calendar(client, url: str):
|
| """Open ``url`` as a single calendar collection.
|
|
|
| Used when principal discovery yields no calendars. Google's principal URL
|
| is not an event collection, so map it to the events URL first
|
| (see ``_google_caldav_events_url``); other servers' URLs are used as-is.
|
| """
|
| target = _google_caldav_events_url(url) or url
|
| return client.calendar(url=target)
|
|
|
|
|
| def _build_dav_client(url: str, username: str, password: str):
|
| """Construct a CalDAV client with automatic redirects disabled.
|
|
|
| ``validate_caldav_url`` resolves and vets the *initial* host, but caldav's
|
| underlying HTTP session follows 3xx redirects by default. So a URL that
|
| passes validation can still be redirected — at request time — to
|
| loopback / link-local / private space, re-opening the SSRF the host check
|
| closes. Pin the session to zero redirects: any 3xx then raises instead of
|
| silently following an attacker-chosen ``Location``. This mirrors the
|
| test-connection path in ``routes/calendar_routes.py``, which already sets
|
| ``follow_redirects=False``.
|
|
|
| DAVClient exposes no per-request redirect flag, so we set it on the session
|
| after construction (the session is created in ``__init__``).
|
| """
|
| import caldav
|
|
|
| client = caldav.DAVClient(url=url, username=username, password=password)
|
|
|
|
|
|
|
|
|
| client.session.max_redirects = 0
|
| return client
|
|
|
|
|
| def _should_prune_window(seen_uids: set, parse_failed: bool) -> bool:
|
| """Whether the post-sync prune of vanished CalDAV events is safe to run.
|
|
|
| The prune deletes local ``origin=="caldav"`` rows in the window whose UID the
|
| server did not just return. Any parse failure (total or partial) makes
|
| ``seen_uids`` an incomplete view of the server, so pruning against it can
|
| delete events that still exist upstream but could not be read: a total
|
| failure wipes the whole window, a partial failure deletes just the
|
| unreadable ones. Only prune on a clean read. An empty ``seen_uids`` after a
|
| clean read is a genuinely empty window, which is safe to prune.
|
| """
|
| return not parse_failed
|
|
|
|
|
| def _sync_blocking(owner: str, url: str, username: str, password: str, account_id: str = "") -> dict:
|
| """The actual sync — synchronous, intended to run in a threadpool.
|
| Returns counts: {calendars, events, deleted, errors}."""
|
|
|
|
|
| from caldav.lib.error import AuthorizationError, NotFoundError
|
| from core.database import CalendarCal, CalendarEvent, SessionLocal
|
|
|
| result = {"calendars": 0, "events": 0, "deleted": 0, "errors": []}
|
|
|
| client = _build_dav_client(url, username, password)
|
|
|
|
|
|
|
|
|
| calendars = []
|
| try:
|
| principal = client.principal()
|
| calendars = principal.calendars()
|
| except (AuthorizationError, NotFoundError) as e:
|
| result["errors"].append(f"Discovery failed: {e}")
|
| return result
|
| except Exception as e:
|
| logger.info(f"CalDAV principal discovery failed, trying URL as calendar: {e}")
|
| try:
|
| calendars = [_open_url_as_calendar(client, url)]
|
| except Exception as e2:
|
| result["errors"].append(f"Could not open URL as calendar: {e2}")
|
| return result
|
|
|
| if not calendars:
|
| try:
|
| calendars = [_open_url_as_calendar(client, url)]
|
| except Exception as e:
|
| result["errors"].append(f"No calendars and URL fallback failed: {e}")
|
| return result
|
|
|
| start = datetime.utcnow() - timedelta(days=_LOOKBACK_DAYS)
|
| end = datetime.utcnow() + timedelta(days=_LOOKAHEAD_DAYS)
|
|
|
| db = SessionLocal()
|
| try:
|
| for remote_cal in calendars:
|
| try:
|
| remote_url = str(remote_cal.url)
|
| cal_id = _stable_cal_id(remote_url, owner=owner, account_id=account_id)
|
| display_name = (remote_cal.name or "").strip() or "CalDAV"
|
|
|
| local_cal = db.query(CalendarCal).filter(
|
| CalendarCal.id == cal_id,
|
| CalendarCal.owner == owner,
|
| ).first()
|
| if not local_cal:
|
| local_cal = CalendarCal(
|
| id=cal_id,
|
| owner=owner,
|
| name=display_name,
|
| color="#5b8abf",
|
| source="caldav",
|
| account_id=account_id or None,
|
| )
|
| db.add(local_cal)
|
| db.commit()
|
| else:
|
|
|
| changed = False
|
| if local_cal.name != display_name:
|
| local_cal.name = display_name
|
| changed = True
|
| if account_id and not local_cal.account_id:
|
| local_cal.account_id = account_id
|
| changed = True
|
| if changed:
|
| db.commit()
|
| result["calendars"] += 1
|
|
|
|
|
|
|
|
|
| from icalendar import Calendar as iCal
|
|
|
| seen_uids = set()
|
|
|
|
|
|
|
| pending: dict = {}
|
| parse_failed = False
|
| try:
|
| objs = remote_cal.date_search(start=start, end=end, expand=False)
|
| except Exception as e:
|
| result["errors"].append(f"{display_name}: date_search failed ({e})")
|
| continue
|
|
|
| for obj in objs:
|
| try:
|
| ical = iCal.from_ical(obj.data)
|
| except Exception as e:
|
| result["errors"].append(f"{display_name}: parse failed ({e})")
|
| parse_failed = True
|
| continue
|
|
|
| for comp in ical.walk():
|
| if comp.name != "VEVENT":
|
| continue
|
| uid_val = str(comp.get("uid", "")) or str(uuid.uuid4())
|
| seen_uids.add(uid_val)
|
|
|
| dtstart_p = comp.get("dtstart")
|
| if not dtstart_p:
|
| continue
|
| start_dt, all_day = _to_utc_naive(dtstart_p.dt)
|
|
|
| dtend_p = comp.get("dtend")
|
| if dtend_p:
|
| end_dt, _ = _to_utc_naive(dtend_p.dt)
|
| elif all_day:
|
| end_dt = start_dt + timedelta(days=1)
|
| else:
|
| end_dt = start_dt + timedelta(hours=1)
|
|
|
|
|
|
|
| row_is_utc = (
|
| not all_day
|
| and isinstance(dtstart_p.dt, datetime)
|
| and dtstart_p.dt.tzinfo is not None
|
| )
|
|
|
| summary = str(comp.get("summary", ""))
|
| description = str(comp.get("description", ""))
|
| location = str(comp.get("location", ""))
|
| rrule = (
|
| comp.get("rrule").to_ical().decode()
|
| if comp.get("rrule")
|
| else ""
|
| )
|
|
|
| existing = _find_existing_event(db, pending, uid_val, local_cal.id)
|
| if existing:
|
| existing.calendar_id = local_cal.id
|
| existing.summary = summary
|
| existing.description = description
|
| existing.location = location
|
| existing.dtstart = start_dt
|
| existing.dtend = end_dt
|
| existing.all_day = all_day
|
| existing.is_utc = row_is_utc
|
| existing.rrule = rrule
|
| existing.origin = "caldav"
|
| else:
|
| new_ev = CalendarEvent(
|
| uid=uid_val,
|
| calendar_id=local_cal.id,
|
| summary=summary,
|
| description=description,
|
| location=location,
|
| dtstart=start_dt,
|
| dtend=end_dt,
|
| all_day=all_day,
|
| is_utc=row_is_utc,
|
| rrule=rrule,
|
| origin="caldav",
|
| )
|
| db.add(new_ev)
|
| pending[uid_val] = new_ev
|
| result["events"] += 1
|
| db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| if _should_prune_window(seen_uids, parse_failed):
|
| stale = db.query(CalendarEvent).filter(
|
| CalendarEvent.calendar_id == local_cal.id,
|
| CalendarEvent.origin == "caldav",
|
| CalendarEvent.dtstart >= start,
|
| CalendarEvent.dtstart <= end,
|
| ~CalendarEvent.uid.in_(seen_uids) if seen_uids else CalendarEvent.uid.isnot(None),
|
| ).all()
|
| for ev in stale:
|
| db.delete(ev)
|
| result["deleted"] += len(stale)
|
| db.commit()
|
| except Exception as e:
|
| logger.exception("CalDAV sync failed for one calendar")
|
| result["errors"].append(str(e)[:200])
|
| db.rollback()
|
| finally:
|
| db.close()
|
|
|
| return result
|
|
|
|
|
| def _load_caldav_accounts(owner: str) -> list:
|
| """Return the list of CalDAV accounts for *owner*, auto-migrating the legacy
|
| single-account ``caldav`` key to the new ``caldav_accounts`` list on first call.
|
|
|
| The save step is best-effort: if ``_save_for_user`` is unavailable (e.g. in a
|
| test with a minimal prefs mock) the migrated accounts are still returned; the
|
| next real call will just re-run the cheap migration again.
|
| """
|
| import uuid as _uuid
|
| from routes.prefs_routes import _load_for_user
|
|
|
| prefs = _load_for_user(owner) or {}
|
| if "caldav_accounts" in prefs:
|
| return list(prefs["caldav_accounts"] or [])
|
|
|
| legacy = prefs.get("caldav", {}) or {}
|
| if legacy.get("url"):
|
| accounts = [{
|
| "id": str(_uuid.uuid4()),
|
| "label": "CalDAV",
|
| "url": legacy["url"],
|
| "username": legacy.get("username", ""),
|
| "password": legacy.get("password", ""),
|
| }]
|
| prefs["caldav_accounts"] = accounts
|
| prefs.pop("caldav", None)
|
| try:
|
| from routes.prefs_routes import _save_for_user
|
| _save_for_user(owner, prefs)
|
| except (ImportError, AttributeError):
|
| pass
|
| return accounts
|
| return []
|
|
|
|
|
| async def sync_caldav(owner: str) -> dict:
|
| """Pull CalDAV state into local DB for `owner` across all configured accounts.
|
| Returns aggregated counts + per-account errors."""
|
| from src.secret_storage import decrypt
|
|
|
| accounts = _load_caldav_accounts(owner)
|
| if not accounts:
|
| return {
|
| "calendars": 0, "events": 0, "deleted": 0,
|
| "errors": ["CalDAV is not configured"],
|
| }
|
|
|
| totals: dict = {"calendars": 0, "events": 0, "deleted": 0, "errors": []}
|
| for acc in accounts:
|
| url = (acc.get("url") or "").strip()
|
| user = (acc.get("username") or "").strip()
|
| pw = acc.get("password") or ""
|
| account_id = acc.get("id") or ""
|
| label = acc.get("label") or url or account_id
|
| try:
|
| pw = decrypt(pw)
|
| except Exception:
|
| pass
|
| if not (url and user and pw):
|
| totals["errors"].append(f"{label}: missing URL, username, or password")
|
| continue
|
| try:
|
| url = validate_caldav_url(url)
|
| result = await asyncio.to_thread(_sync_blocking, owner, url, user, pw, account_id)
|
| except ValueError as e:
|
| result = {"calendars": 0, "events": 0, "deleted": 0, "errors": [str(e)]}
|
| except Exception as e:
|
| logger.exception("CalDAV sync raised for account %s", label)
|
| result = {"calendars": 0, "events": 0, "deleted": 0, "errors": [str(e)[:200]]}
|
| totals["calendars"] += result.get("calendars", 0)
|
| totals["events"] += result.get("events", 0)
|
| totals["deleted"] += result.get("deleted", 0)
|
| for err in result.get("errors", []):
|
| totals["errors"].append(f"{label}: {err}")
|
| return totals
|
|
|