App passwords (SPEC App. D §D.5): one generated password per app, shown once, revocable on its own, revoked on a password reset; never accepted by the web sign-in. Settings → Security → App passwords (migration 0024). CalDAV / CardDAV server (SPEC Appendix E), built in: /dav/ with /.well-known/caldav|carddav, HTTP Basic with app passwords only (cached checks, throttled failures). The calendar and contacts plugins serve their own tables as collections (Plugin(dav=...)): - calendars as iCalendar: series with overrides, time zones, all-day, attendees and RSVP, alarms <-> reminders; MKCALENDAR / PROPPATCH / DELETE of calendars; subscriptions read-only; writes to upstream-synced calendars flow on upstream; - address books as vCard (3.0 served, 3.0/4.0 read), groups as CATEGORIES; - what a client stores is served back verbatim until the web app edits it; - sync tokens / CTags from change logs filled by triggers (calendar0008, contacts0002), so every write path counts; trimmed nightly after 90 days. REPORTs: calendar-query (time range), calendar-/addressbook-multiget, addressbook-query, sync-collection. nginx and the Vite proxy route /dav/. Settings shows the server and user name to enter; install guide §5.4. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
311 lines
12 KiB
Python
311 lines
12 KiB
Python
"""One event series as a CalDAV resource: iCalendar text <-> event rows
|
|
(SPEC App. E.3).
|
|
|
|
A resource holds every row of one UID: the series (no ``recurrence_id``) and
|
|
its moved or cancelled occurrences. :func:`build` writes the rows as a
|
|
VCALENDAR — time zones, all-day dates, recurrence, overrides, organizer and
|
|
attendees, alarms from the reminders, the event colour and video link (as X-
|
|
properties a client keeps); :func:`parse` reads what a client PUT back into row
|
|
fields. :func:`fingerprint` summarises the modelled fields, so a client's own
|
|
text can be served back verbatim for as long as they are unchanged.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
from collections.abc import Mapping, Sequence
|
|
from datetime import UTC, date, datetime, timedelta
|
|
from typing import Any
|
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
|
|
Row = Mapping[str, Any]
|
|
|
|
# The columns a resource is made of (the fingerprint covers exactly these).
|
|
FIELDS = (
|
|
"ical_uid",
|
|
"recurrence_id",
|
|
"sequence",
|
|
"summary",
|
|
"description",
|
|
"location",
|
|
"dtstart",
|
|
"dtend",
|
|
"all_day",
|
|
"tz",
|
|
"rrule",
|
|
"exdates",
|
|
"status",
|
|
"organizer",
|
|
"attendees",
|
|
"my_partstat",
|
|
"reminders",
|
|
"color",
|
|
"show_as",
|
|
"conference_url",
|
|
)
|
|
_PARTSTATS = {"needs-action", "accepted", "declined", "tentative", "delegated"}
|
|
|
|
|
|
class InvalidResource(ValueError):
|
|
"""Not a calendar object we can store (no VEVENT, several UIDs, …)."""
|
|
|
|
|
|
def _zone(name: str | None) -> ZoneInfo:
|
|
try:
|
|
return ZoneInfo(name or "UTC")
|
|
except (ZoneInfoNotFoundError, ValueError):
|
|
return ZoneInfo("UTC")
|
|
|
|
|
|
def _norm(value: Any) -> Any:
|
|
if isinstance(value, datetime):
|
|
return value.astimezone(UTC).isoformat()
|
|
if isinstance(value, date):
|
|
return value.isoformat()
|
|
if isinstance(value, list | tuple):
|
|
return [_norm(v) for v in value]
|
|
if isinstance(value, dict):
|
|
return {k: _norm(v) for k, v in sorted(value.items())}
|
|
return value
|
|
|
|
|
|
def fingerprint(rows: Sequence[Row]) -> str:
|
|
"""A digest of the modelled fields of a resource's rows."""
|
|
items = sorted(
|
|
({f: _norm(r.get(f)) for f in FIELDS} for r in rows),
|
|
key=lambda d: str(d["recurrence_id"] or ""),
|
|
)
|
|
return hashlib.sha1(json.dumps(items, sort_keys=True, default=str).encode()).hexdigest()
|
|
|
|
|
|
# ---- rows -> iCalendar ----------------------------------------------------------------
|
|
|
|
|
|
def _when(value: datetime, all_day: bool, tz: str) -> date | datetime:
|
|
if all_day:
|
|
return value.astimezone(UTC).date()
|
|
zone = _zone(tz)
|
|
return value.astimezone(zone) if tz and tz != "UTC" else value.astimezone(UTC)
|
|
|
|
|
|
def _address(prop_name: str, person: Mapping[str, Any], partstat: str | None = None) -> Any:
|
|
from icalendar import vCalAddress
|
|
|
|
addr = vCalAddress(f"mailto:{person.get('email', '')}")
|
|
if person.get("name"):
|
|
addr.params["CN"] = person["name"]
|
|
if prop_name == "attendee":
|
|
addr.params["PARTSTAT"] = (partstat or person.get("partstat") or "needs-action").upper()
|
|
addr.params["ROLE"] = "REQ-PARTICIPANT"
|
|
return addr
|
|
|
|
|
|
def build(rows: Sequence[Row], my_addresses: set[str]) -> str:
|
|
"""The VCALENDAR for one resource's rows (series first, then overrides)."""
|
|
from icalendar import Alarm, Calendar, Event
|
|
from icalendar.prop import vRecur
|
|
|
|
cal = Calendar()
|
|
cal.add("prodid", "-//Ultimo//Calendar//EN")
|
|
cal.add("version", "2.0")
|
|
cal.add("calscale", "GREGORIAN")
|
|
ordered = sorted(
|
|
rows,
|
|
key=lambda r: (
|
|
r.get("recurrence_id") is not None,
|
|
r.get("recurrence_id") or datetime.min.replace(tzinfo=UTC),
|
|
),
|
|
)
|
|
for r in ordered:
|
|
ev = Event()
|
|
all_day = bool(r.get("all_day"))
|
|
tz = str(r.get("tz") or "UTC")
|
|
ev.add("uid", r["ical_uid"])
|
|
stamp = r.get("modified_at") or datetime(2000, 1, 1, tzinfo=UTC)
|
|
ev.add("dtstamp", stamp.astimezone(UTC))
|
|
ev.add("last-modified", stamp.astimezone(UTC))
|
|
ev.add("sequence", int(r.get("sequence") or 0))
|
|
ev.add("summary", r.get("summary") or "")
|
|
if r.get("description"):
|
|
ev.add("description", r["description"])
|
|
if r.get("location"):
|
|
ev.add("location", r["location"])
|
|
ev.add("dtstart", _when(r["dtstart"], all_day, tz))
|
|
if r.get("dtend") is not None:
|
|
ev.add("dtend", _when(r["dtend"], all_day, tz))
|
|
if r.get("recurrence_id") is not None:
|
|
ev.add("recurrence-id", _when(r["recurrence_id"], all_day, tz))
|
|
if r.get("rrule") and r.get("recurrence_id") is None:
|
|
ev.add("rrule", vRecur.from_ical(str(r["rrule"])))
|
|
for ex in r.get("exdates") or []:
|
|
ev.add("exdate", _when(ex, all_day, tz))
|
|
status = str(r.get("status") or "confirmed").upper()
|
|
if status in ("CONFIRMED", "TENTATIVE", "CANCELLED"):
|
|
ev.add("status", status)
|
|
ev.add("transp", "TRANSPARENT" if r.get("show_as") == "free" else "OPAQUE")
|
|
organizer = r.get("organizer")
|
|
if isinstance(organizer, dict) and organizer.get("email"):
|
|
ev.add("organizer", _address("organizer", organizer))
|
|
for att in r.get("attendees") or []:
|
|
if not isinstance(att, dict) or not att.get("email"):
|
|
continue
|
|
mine = str(att["email"]).lower() in my_addresses
|
|
ev.add("attendee", _address("attendee", att, r.get("my_partstat") if mine else None))
|
|
if r.get("conference_url"):
|
|
ev.add("x-ultimo-conference", r["conference_url"])
|
|
if r.get("color"):
|
|
ev.add("x-ultimo-color", r["color"])
|
|
for minutes in sorted(set(r.get("reminders") or [])):
|
|
alarm = Alarm()
|
|
alarm.add("action", "DISPLAY")
|
|
alarm.add("description", "Reminder")
|
|
alarm.add("trigger", timedelta(minutes=-int(minutes)))
|
|
ev.add_component(alarm)
|
|
cal.add_component(ev)
|
|
cal.add_missing_timezones()
|
|
return str(cal.to_ical().decode())
|
|
|
|
|
|
# ---- iCalendar -> rows ----------------------------------------------------------------
|
|
|
|
|
|
def _aware(value: date | datetime, default_tz: str) -> tuple[datetime, bool, str]:
|
|
"""(instant, all-day, tz name) of a DTSTART-like value."""
|
|
if not isinstance(value, datetime):
|
|
return datetime(value.year, value.month, value.day, tzinfo=UTC), True, "UTC"
|
|
if value.tzinfo is None: # floating: the user's own zone
|
|
zone = _zone(default_tz)
|
|
return value.replace(tzinfo=zone), False, default_tz or "UTC"
|
|
name = getattr(value.tzinfo, "key", None) or getattr(value.tzinfo, "zone", None) or ""
|
|
try:
|
|
ZoneInfo(str(name))
|
|
except (ZoneInfoNotFoundError, ValueError):
|
|
name = "UTC"
|
|
return value, False, str(name) or "UTC"
|
|
|
|
|
|
def _person(prop: Any) -> dict[str, str] | None:
|
|
if prop is None:
|
|
return None
|
|
email = str(prop)
|
|
if email.lower().startswith("mailto:"):
|
|
email = email[7:]
|
|
email = email.strip().lower()
|
|
if not email:
|
|
return None
|
|
name = str(prop.params.get("CN", "")).strip() if hasattr(prop, "params") else ""
|
|
return {"email": email, "name": "" if name.lower() == email else name}
|
|
|
|
|
|
def _list(value: Any) -> list[Any]:
|
|
if value is None:
|
|
return []
|
|
return value if isinstance(value, list) else [value]
|
|
|
|
|
|
def _reminders(comp: Any, start: datetime) -> list[int]:
|
|
out: set[int] = set()
|
|
for alarm in comp.walk("VALARM"):
|
|
trigger = alarm.get("trigger")
|
|
if trigger is None:
|
|
continue
|
|
value = trigger.dt
|
|
if isinstance(value, timedelta):
|
|
if str(trigger.params.get("RELATED", "START")).upper() != "START":
|
|
continue
|
|
minutes = int(-value.total_seconds() // 60)
|
|
elif isinstance(value, datetime):
|
|
minutes = int((start - value.astimezone(UTC)).total_seconds() // 60)
|
|
else:
|
|
continue
|
|
if 0 <= minutes <= 60 * 24 * 28:
|
|
out.add(minutes)
|
|
return sorted(out)
|
|
|
|
|
|
def parse(ics: str, my_addresses: set[str], default_tz: str) -> list[dict[str, Any]]:
|
|
"""The rows a client's VCALENDAR describes (one UID). Raises InvalidResource."""
|
|
from icalendar import Calendar
|
|
|
|
try:
|
|
cal = Calendar.from_ical(ics)
|
|
except Exception as err: # noqa: BLE001 - any parse failure: not valid calendar data
|
|
raise InvalidResource(str(err)) from err
|
|
events = list(cal.walk("VEVENT"))
|
|
if not events:
|
|
raise InvalidResource("no VEVENT")
|
|
uids = {str(e.get("uid") or "") for e in events}
|
|
if len(uids) != 1 or "" in uids:
|
|
raise InvalidResource("one UID per resource")
|
|
rows: list[dict[str, Any]] = []
|
|
for comp in events:
|
|
start_prop = comp.get("dtstart")
|
|
if start_prop is None:
|
|
raise InvalidResource("VEVENT without DTSTART")
|
|
dtstart, all_day, tz = _aware(start_prop.dt, default_tz)
|
|
end_prop = comp.get("dtend")
|
|
if end_prop is not None:
|
|
dtend = _aware(end_prop.dt, tz)[0]
|
|
elif comp.get("duration") is not None:
|
|
dtend = dtstart + comp.get("duration").dt
|
|
else:
|
|
dtend = dtstart + (timedelta(days=1) if all_day else timedelta(0))
|
|
rid_prop = comp.get("recurrence-id")
|
|
rrule = comp.get("rrule")
|
|
exdates: list[datetime] = []
|
|
for ex in _list(comp.get("exdate")):
|
|
for d in getattr(ex, "dts", []):
|
|
exdates.append(_aware(d.dt, tz)[0])
|
|
organizer = _person(comp.get("organizer"))
|
|
attendees = []
|
|
my_partstat = None
|
|
for prop in _list(comp.get("attendee")):
|
|
who = _person(prop)
|
|
if who is None:
|
|
continue
|
|
stat = str(prop.params.get("PARTSTAT", "NEEDS-ACTION")).lower()
|
|
who["partstat"] = stat if stat in _PARTSTATS else "needs-action"
|
|
if who["email"] in my_addresses:
|
|
my_partstat = who["partstat"]
|
|
attendees.append(who)
|
|
conference = ""
|
|
for key in ("x-ultimo-conference", "x-google-conference", "conference", "url"):
|
|
value = comp.get(key)
|
|
if value and str(value).startswith(("https://", "http://")):
|
|
conference = str(value)
|
|
break
|
|
color = str(comp.get("x-ultimo-color") or "") or None
|
|
status = str(comp.get("status") or "confirmed").lower()
|
|
rows.append(
|
|
{
|
|
"ical_uid": str(comp.get("uid")),
|
|
"recurrence_id": _aware(rid_prop.dt, tz)[0] if rid_prop is not None else None,
|
|
"sequence": int(comp.get("sequence") or 0),
|
|
"summary": str(comp.get("summary") or ""),
|
|
"description": str(comp.get("description") or ""),
|
|
"location": str(comp.get("location") or ""),
|
|
"dtstart": dtstart,
|
|
"dtend": dtend,
|
|
"all_day": all_day,
|
|
"tz": tz,
|
|
"rrule": rrule.to_ical().decode()
|
|
if rrule is not None and rid_prop is None
|
|
else None,
|
|
"exdates": exdates,
|
|
"status": status
|
|
if status in ("confirmed", "tentative", "cancelled")
|
|
else "confirmed",
|
|
"organizer": organizer,
|
|
"attendees": attendees,
|
|
"my_partstat": my_partstat,
|
|
"reminders": _reminders(comp, dtstart.astimezone(UTC)),
|
|
"color": color,
|
|
"show_as": "free"
|
|
if str(comp.get("transp") or "").upper() == "TRANSPARENT"
|
|
else "busy",
|
|
"conference_url": conference,
|
|
}
|
|
)
|
|
return rows
|