mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-18 10:45:31 -04:00
ffc0f1dccc
Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
299 lines
11 KiB
Python
299 lines
11 KiB
Python
"""CalDAV write-back: push local create/update/delete out to the remote (#800).
|
|
|
|
``src/caldav_sync.py`` is a one-way pull (remote → local). So events created,
|
|
edited, or deleted in Odysseus on a CalDAV-backed calendar only changed the local
|
|
SQLite copy and never reached the server (iCloud/Nextcloud/Radicale/Fastmail) —
|
|
they'd silently disappear on the next pull and never show on the user's phone.
|
|
|
|
This adds the missing write half. The remote calendar URL isn't stored locally
|
|
(the local calendar id is a one-way hash of it), so we re-discover the remote
|
|
calendar by matching that same hash, then PUT/DELETE the VEVENT by its UID via
|
|
the `caldav` lib. Writes are best-effort: the local DB stays the source of truth,
|
|
and a remote failure is reported, never fatal to the local operation.
|
|
|
|
The pure pieces (``build_event_ical``, ``find_remote_calendar``, ``push_event``)
|
|
take their inputs by argument so they unit-test against a fake client with no
|
|
network.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
from datetime import timezone
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _stable_cal_id(remote_url: str, owner: str = "", account_id: str = "") -> str:
|
|
# Reuse the sync module's hashing so owner+account_id scoping stays consistent.
|
|
from src.caldav_sync import _stable_cal_id as _sync_id
|
|
return _sync_id(remote_url, owner=owner, account_id=account_id)
|
|
|
|
|
|
def build_event_ical(ev: dict) -> str:
|
|
"""Serialize a local event dict to a VCALENDAR/VEVENT iCalendar string.
|
|
|
|
``ev`` keys: uid, summary, description, location, dtstart (datetime),
|
|
dtend (datetime), all_day (bool), is_utc (bool), rrule (str).
|
|
Mirrors how the pull path interprets is_utc/all_day so a round-trip is stable.
|
|
"""
|
|
from icalendar import Calendar, Event as iEvent
|
|
from icalendar.prop import vRecur
|
|
|
|
cal = Calendar()
|
|
cal.add("prodid", "-//Odysseus//CalDAV write-back//EN")
|
|
cal.add("version", "2.0")
|
|
|
|
ve = iEvent()
|
|
ve.add("uid", ev["uid"])
|
|
ve.add("summary", ev.get("summary") or "")
|
|
if ev.get("description"):
|
|
ve.add("description", ev["description"])
|
|
if ev.get("location"):
|
|
ve.add("location", ev["location"])
|
|
|
|
dtstart = ev["dtstart"]
|
|
dtend = ev["dtend"]
|
|
if ev.get("all_day"):
|
|
ve.add("dtstart", dtstart.date())
|
|
ve.add("dtend", dtend.date())
|
|
elif ev.get("is_utc"):
|
|
# Stored as naive-UTC instants — re-attach UTC so the server gets a Z time.
|
|
ve.add("dtstart", dtstart.replace(tzinfo=timezone.utc))
|
|
ve.add("dtend", dtend.replace(tzinfo=timezone.utc))
|
|
else:
|
|
# Legacy naive-local ("floating") time — emit without a TZ.
|
|
ve.add("dtstart", dtstart)
|
|
ve.add("dtend", dtend)
|
|
|
|
if ev.get("rrule"):
|
|
try:
|
|
ve.add("rrule", vRecur.from_ical(ev["rrule"]))
|
|
except Exception:
|
|
logger.debug("CalDAV write-back: skipping unparseable rrule %r", ev.get("rrule"))
|
|
|
|
cal.add_component(ve)
|
|
return cal.to_ical().decode("utf-8")
|
|
|
|
|
|
def find_remote_calendar(calendars, local_cal_id: str, owner: str = "", account_id: str = ""):
|
|
"""Find the remote calendar whose URL hashes to ``local_cal_id``, or None.
|
|
|
|
``owner`` and ``account_id`` must match what was used when the local calendar
|
|
id was originally computed in ``_sync_blocking`` so the hash round-trips."""
|
|
for cal in calendars:
|
|
try:
|
|
if _stable_cal_id(str(cal.url), owner=owner, account_id=account_id) == local_cal_id:
|
|
return cal
|
|
except Exception:
|
|
continue
|
|
return None
|
|
|
|
|
|
def _resource_href(obj) -> str:
|
|
try:
|
|
return str(getattr(obj, "url", "") or "")
|
|
except Exception:
|
|
return ""
|
|
|
|
|
|
def _resource_etag(obj) -> str:
|
|
try:
|
|
etag = getattr(obj, "etag", None)
|
|
if callable(etag):
|
|
etag = etag()
|
|
return str(etag or "")
|
|
except Exception:
|
|
return ""
|
|
|
|
|
|
def push_event(calendars, local_cal_id: str, ev: dict, *, delete: bool = False,
|
|
owner: str = "", account_id: str = "") -> dict:
|
|
"""Create/update (or delete) ``ev`` on the matching remote calendar.
|
|
|
|
Returns ``{"ok": bool, ...}``. ``calendars`` is the discovered caldav
|
|
calendar list (injected so this is unit-testable with fakes).
|
|
``owner`` and ``account_id`` are forwarded to ``find_remote_calendar``
|
|
so the URL hash round-trips correctly (#2765).
|
|
"""
|
|
uid = (ev or {}).get("uid") if isinstance(ev, dict) else None
|
|
if not uid:
|
|
return {"ok": False, "error": "event uid is required"}
|
|
|
|
remote = find_remote_calendar(calendars, local_cal_id, owner=owner, account_id=account_id)
|
|
if remote is None:
|
|
return {"ok": False, "error": "remote calendar not found"}
|
|
remote_url = str(getattr(remote, "url", "") or "")
|
|
|
|
try:
|
|
existing = remote.event_by_uid(uid)
|
|
except Exception:
|
|
existing = None
|
|
|
|
if delete:
|
|
if existing is None:
|
|
return {"ok": True, "note": "already absent on remote", "calendar_url": remote_url}
|
|
existing.delete()
|
|
return {
|
|
"ok": True,
|
|
"calendar_url": remote_url,
|
|
"remote_href": _resource_href(existing),
|
|
"remote_etag": _resource_etag(existing),
|
|
}
|
|
|
|
ical = build_event_ical(ev)
|
|
if existing is not None:
|
|
existing.data = ical
|
|
existing.save()
|
|
return {
|
|
"ok": True,
|
|
"updated": True,
|
|
"calendar_url": remote_url,
|
|
"remote_href": _resource_href(existing),
|
|
"remote_etag": _resource_etag(existing),
|
|
}
|
|
created = remote.save_event(ical)
|
|
return {
|
|
"ok": True,
|
|
"created": True,
|
|
"calendar_url": remote_url,
|
|
"remote_href": _resource_href(created),
|
|
"remote_etag": _resource_etag(created),
|
|
}
|
|
|
|
|
|
def _discover_calendars(client):
|
|
"""Discover the principal's calendars, falling back to the URL itself —
|
|
same strategy as the pull path."""
|
|
from caldav.lib.error import AuthorizationError, NotFoundError
|
|
try:
|
|
return client.principal().calendars()
|
|
except (AuthorizationError, NotFoundError):
|
|
raise
|
|
except Exception:
|
|
try:
|
|
return [client.calendar(url=str(client.url))]
|
|
except Exception:
|
|
return []
|
|
|
|
|
|
def _writeback_blocking(local_cal_id, ev, delete, url, username, password,
|
|
owner="", account_id="") -> dict:
|
|
from src.caldav_sync import _build_dav_client
|
|
# Redirects disabled here too: the write-back path opens its own DAVClient,
|
|
# so it needs the same SSRF-via-redirect protection as the pull path.
|
|
client = _build_dav_client(url, username, password)
|
|
calendars = _discover_calendars(client)
|
|
if not calendars:
|
|
return {"ok": False, "error": "no remote calendars discovered"}
|
|
return push_event(calendars, local_cal_id, ev, delete=delete,
|
|
owner=owner, account_id=account_id)
|
|
|
|
|
|
def _persist_writeback_result(owner: str, calendar_id: str, uid: str, result: dict, *, delete: bool) -> None:
|
|
from core.database import CalendarCal, CalendarDeletedEvent, CalendarEvent, SessionLocal
|
|
|
|
if not uid or not isinstance(result, dict):
|
|
return
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
calendar = db.query(CalendarCal).filter(
|
|
CalendarCal.id == calendar_id,
|
|
CalendarCal.owner == owner,
|
|
).first()
|
|
if calendar and result.get("calendar_url"):
|
|
calendar.caldav_base_url = result.get("calendar_url")
|
|
|
|
if delete:
|
|
tombstone = db.query(CalendarDeletedEvent).filter(
|
|
CalendarDeletedEvent.uid == uid,
|
|
CalendarDeletedEvent.owner == owner,
|
|
).first()
|
|
if result.get("ok"):
|
|
if tombstone:
|
|
db.delete(tombstone)
|
|
elif tombstone:
|
|
tombstone.last_error = str(result.get("error") or result)[:500]
|
|
db.commit()
|
|
return
|
|
|
|
event = (
|
|
db.query(CalendarEvent)
|
|
.join(CalendarCal)
|
|
.filter(CalendarEvent.uid == uid, CalendarCal.owner == owner)
|
|
.first()
|
|
)
|
|
if event and result.get("ok"):
|
|
if result.get("remote_href"):
|
|
event.remote_href = result.get("remote_href")
|
|
if result.get("remote_etag"):
|
|
event.remote_etag = result.get("remote_etag")
|
|
event.caldav_sync_pending = None
|
|
db.commit()
|
|
except Exception:
|
|
db.rollback()
|
|
logger.exception("CalDAV write-back metadata persistence failed")
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
async def writeback_event(owner: str, calendar_source: str, calendar_id: str,
|
|
ev: dict, *, delete: bool = False) -> dict:
|
|
"""Best-effort push of a local change to the remote CalDAV server.
|
|
|
|
No-ops (``{"skipped": ...}``) when the calendar isn't CalDAV-backed or no
|
|
credentials are configured. Never raises — a remote failure is logged and
|
|
returned, the local DB remaining the source of truth.
|
|
"""
|
|
if calendar_source != "caldav":
|
|
return {"skipped": "not a caldav calendar"}
|
|
try:
|
|
from src.caldav_sync import _load_caldav_accounts
|
|
from src.secret_storage import decrypt
|
|
from core.database import CalendarCal, SessionLocal
|
|
|
|
accounts = _load_caldav_accounts(owner)
|
|
if not accounts:
|
|
return {"skipped": "caldav not configured"}
|
|
|
|
# Find which account owns this calendar.
|
|
acc = None
|
|
if len(accounts) > 1:
|
|
db = SessionLocal()
|
|
try:
|
|
cal_row = db.query(CalendarCal).filter(CalendarCal.id == calendar_id).first()
|
|
cal_account_id = cal_row.account_id if cal_row else None
|
|
finally:
|
|
db.close()
|
|
if cal_account_id:
|
|
acc = next((a for a in accounts if a.get("id") == cal_account_id), None)
|
|
# Fall back to first account (covers single-account and legacy rows with
|
|
# no account_id stamped).
|
|
if acc is None:
|
|
acc = accounts[0]
|
|
|
|
url = (acc.get("url") or "").strip()
|
|
user = (acc.get("username") or "").strip()
|
|
pw = decrypt(acc.get("password") or "")
|
|
if not (url and user and pw):
|
|
return {"skipped": "caldav account credentials incomplete"}
|
|
from src.caldav_sync import validate_caldav_url
|
|
try:
|
|
url = validate_caldav_url(url)
|
|
except ValueError as e:
|
|
logger.warning("CalDAV write-back URL rejected: %s", e)
|
|
return {"ok": False, "error": str(e)[:200]}
|
|
acc_id = acc.get("id") or ""
|
|
result = await asyncio.to_thread(
|
|
_writeback_blocking, calendar_id, ev, delete, url, user, pw, owner, acc_id
|
|
)
|
|
_persist_writeback_result(owner, calendar_id, (ev or {}).get("uid", ""), result, delete=delete)
|
|
if not result.get("ok"):
|
|
logger.warning("CalDAV write-back did not apply: %s", result.get("error") or result)
|
|
return result
|
|
except Exception as e:
|
|
logger.exception("CalDAV write-back raised")
|
|
result = {"ok": False, "error": str(e)[:200]}
|
|
_persist_writeback_result(owner, calendar_id, (ev or {}).get("uid", ""), result, delete=delete)
|
|
return result
|