feat(calendar): support multiple CalDAV accounts (#2942)

* feat(calendar): support multiple CalDAV accounts

Replaces the single CalDAV credential slot with a named account list so
users can sync both a personal and work calendar simultaneously.

- Add `account_id` column to `CalendarCal` + startup migration
- `_load_caldav_accounts()` in caldav_sync.py reads `caldav_accounts`
  list from prefs, auto-migrating the legacy single `caldav` key on
  first use (no user action required)
- `sync_caldav()` iterates all accounts and aggregates counts/errors
- `writeback_event()` resolves credentials via `CalendarCal.account_id`,
  falling back to the first account for legacy rows
- New REST endpoints: GET/POST/PUT/DELETE `/api/calendar/config/accounts`
- Legacy GET/POST `/api/calendar/config` preserved for backward compat
- Settings UI: one card per account with Label, URL, Username, Password
  fields; Test button works for both unsaved (inline creds) and saved
  (by account_id) accounts; delete removes only that account
- Update test_caldav_url_hardening.py mock to include `_save_for_user`
  and updated `_sync_blocking` signature

* fix(calendar): restore #2765 PK scoping and #2819 writeback URL validation

Two regressions introduced by the multi-account refactor:

1. PK collision (#2765): _stable_cal_id was back to hashing only the URL,
   so two users — or one user with two accounts on the same server — would
   collide on the primary key. Restore owner+account_id in the hash key
   (format: "{owner}\n{account_id}\n{url}") and thread both values through
   _sync_blocking → _writeback_blocking → push_event → find_remote_calendar
   so the hash round-trips correctly on write-back.

2. URL validation dropped (#2819): _load_caldav_accounts imported
   _save_for_user at function scope, causing an ImportError on test mocks
   that only provide _load_for_user, which prevented writeback_event from
   reaching the validate_caldav_url call. Move the import inside the
   migration branch and wrap in try/except (best-effort save; next call
   re-migrates from the still-present legacy key).

Update fake_writeback_blocking in test_caldav_writeback.py to accept the
new owner/account_id optional params.
This commit is contained in:
Logan Davis
2026-06-05 14:32:50 -04:00
committed by GitHub
parent 545e692565
commit ad82ee1c83
7 changed files with 389 additions and 152 deletions
+27 -1
View File
@@ -1458,7 +1458,11 @@ class CalendarCal(TimestampMixin, Base):
owner = Column(String, nullable=True, index=True) owner = Column(String, nullable=True, index=True)
name = Column(String, nullable=False) name = Column(String, nullable=False)
color = Column(String, default="#5b8abf") color = Column(String, default="#5b8abf")
source = Column(String, default="local") # "local" or "timetree" source = Column(String, default="local") # "local" or "caldav"
# UUID of the CalDAV account in user prefs that owns this calendar.
# NULL for local calendars and for CalDAV calendars created before
# multi-account support was added (treated as "use any configured account").
account_id = Column(String, nullable=True, index=True)
events = relationship("CalendarEvent", back_populates="calendar", cascade="all, delete-orphan") events = relationship("CalendarEvent", back_populates="calendar", cascade="all, delete-orphan")
@@ -1622,6 +1626,7 @@ def init_db():
_migrate_add_calendar_metadata() _migrate_add_calendar_metadata()
_migrate_add_calendar_is_utc() _migrate_add_calendar_is_utc()
_migrate_add_calendar_origin() _migrate_add_calendar_origin()
_migrate_add_calendar_account_id()
_migrate_encrypt_email_passwords() _migrate_encrypt_email_passwords()
_migrate_encrypt_signatures() _migrate_encrypt_signatures()
_migrate_encrypt_endpoint_keys() _migrate_encrypt_endpoint_keys()
@@ -1786,6 +1791,27 @@ def _migrate_add_calendar_origin():
logging.getLogger(__name__).warning(f"calendar_events.origin migration failed: {e}") logging.getLogger(__name__).warning(f"calendar_events.origin migration failed: {e}")
def _migrate_add_calendar_account_id():
"""Add `account_id` to calendars so each CalDAV-backed calendar knows which
credential set (from caldav_accounts in user prefs) owns it. Idempotent."""
import sqlite3
db_path = DATABASE_URL.replace("sqlite:///", "")
if not os.path.exists(db_path):
return
try:
conn = sqlite3.connect(db_path)
cursor = conn.execute("PRAGMA table_info(calendars)")
columns = [row[1] for row in cursor.fetchall()]
if columns and "account_id" not in columns:
conn.execute("ALTER TABLE calendars ADD COLUMN account_id TEXT")
conn.execute("CREATE INDEX IF NOT EXISTS ix_calendars_account_id ON calendars(account_id)")
conn.commit()
logging.getLogger(__name__).info("Migrated: added 'account_id' column to calendars")
conn.close()
except Exception as e:
logging.getLogger(__name__).warning(f"calendars.account_id migration failed: {e}")
def _migrate_add_calendar_metadata(): def _migrate_add_calendar_metadata():
"""Add importance/event_type/last_pinged columns to calendar_events table.""" """Add importance/event_type/last_pinged columns to calendar_events table."""
import sqlite3 import sqlite3
+161 -50
View File
@@ -579,72 +579,178 @@ def _expand_rrule(
def setup_calendar_routes() -> APIRouter: def setup_calendar_routes() -> APIRouter:
router = APIRouter(prefix="/api/calendar", tags=["calendar"]) router = APIRouter(prefix="/api/calendar", tags=["calendar"])
# CalDAV connect form (Integrations → Calendar). Storage is local # ── CalDAV multi-account helpers ─────────────────────────────────────────
# SQLite; sync (src/caldav_sync.py) pulls remote events into it on
# calendar open and periodically via the scheduler. def _get_caldav_accounts(owner: str) -> list:
from src.caldav_sync import _load_caldav_accounts
return _load_caldav_accounts(owner)
def _save_caldav_accounts(owner: str, accounts: list) -> None:
from routes.prefs_routes import _load_for_user, _save_for_user
prefs = _load_for_user(owner) or {}
prefs["caldav_accounts"] = accounts
prefs.pop("caldav", None)
_save_for_user(owner, prefs)
# ── CalDAV config routes (backward-compat single-account API) ────────────
@router.get("/config") @router.get("/config")
async def get_config(request: Request): async def get_config(request: Request):
"""Legacy single-account endpoint — returns the first configured account."""
owner = _require_user(request) owner = _require_user(request)
from routes.prefs_routes import _load_for_user accounts = _get_caldav_accounts(owner)
cfg = (_load_for_user(owner) or {}).get("caldav", {}) or {} if not accounts:
caldav_password = cfg.get("password") or "" return {"url": "", "username": "", "password": "", "has_password": False, "local": True}
if caldav_password: first = accounts[0]
pw = first.get("password") or ""
has_pw = False
if pw:
try: try:
from src.secret_storage import decrypt from src.secret_storage import decrypt
caldav_password = decrypt(caldav_password) has_pw = bool(decrypt(pw))
except Exception: except Exception:
pass has_pw = bool(pw)
# Surface url+username but never hand the password back to the
# client — saved-state UI shouldn't leak the credential.
return { return {
"url": cfg.get("url", "") or "", "url": first.get("url", "") or "",
"username": cfg.get("username", "") or "", "username": first.get("username", "") or "",
"password": "", "password": "",
"has_password": bool(caldav_password), "has_password": has_pw,
"local": not bool(cfg.get("url")), "local": not bool(first.get("url")),
} }
@router.post("/config") @router.post("/config")
async def save_config(request: Request): async def save_config(request: Request):
"""Legacy single-account endpoint — upserts the first account."""
owner = _require_user(request) owner = _require_user(request)
from routes.prefs_routes import _load_for_user, _save_for_user
try: try:
body = await request.json() body = await request.json()
except Exception: except Exception:
body = {} body = {}
prefs = _load_for_user(owner) or {} accounts = _get_caldav_accounts(owner)
cfg = dict(prefs.get("caldav") or {})
# Empty url => clear the whole entry (treat as "remove integration").
if not (body.get("url") or "").strip(): if not (body.get("url") or "").strip():
prefs.pop("caldav", None) _save_caldav_accounts(owner, [])
_save_for_user(owner, prefs)
return {"ok": True, "cleared": True} return {"ok": True, "cleared": True}
from src.caldav_sync import validate_caldav_url from src.caldav_sync import validate_caldav_url
try: try:
cfg["url"] = validate_caldav_url(body.get("url", "")) validated_url = validate_caldav_url(body.get("url", ""))
except ValueError as e: except ValueError as e:
raise HTTPException(400, str(e)) raise HTTPException(400, str(e))
cfg["username"] = (body.get("username") or "").strip() if accounts:
# Preserve the stored password when the client sends an empty acc = dict(accounts[0])
# one (edit form re-submitted without re-typing the password). else:
# cfg already holds the existing (already-encrypted) password from import uuid as _uuid
# prefs, so we only touch it when a new password is supplied — acc = {"id": str(_uuid.uuid4()), "label": "CalDAV"}
# re-encrypting the stored value would double-encrypt it. acc["url"] = validated_url
acc["username"] = (body.get("username") or "").strip()
if body.get("password"): if body.get("password"):
from src.secret_storage import encrypt from src.secret_storage import encrypt
cfg["password"] = encrypt(body["password"]) acc["password"] = encrypt(body["password"])
prefs["caldav"] = cfg new_accounts = [acc] + (accounts[1:] if len(accounts) > 1 else [])
_save_for_user(owner, prefs) _save_caldav_accounts(owner, new_accounts)
return {"ok": True}
# ── CalDAV multi-account CRUD ─────────────────────────────────────────────
@router.get("/config/accounts")
async def list_caldav_accounts(request: Request):
"""Return all configured CalDAV accounts (passwords never returned)."""
owner = _require_user(request)
accounts = _get_caldav_accounts(owner)
safe = []
for acc in accounts:
pw = acc.get("password") or ""
has_pw = False
if pw:
try:
from src.secret_storage import decrypt
has_pw = bool(decrypt(pw))
except Exception:
has_pw = bool(pw)
safe.append({
"id": acc.get("id", ""),
"label": acc.get("label", "") or acc.get("url", ""),
"url": acc.get("url", "") or "",
"username": acc.get("username", "") or "",
"has_password": has_pw,
})
return {"accounts": safe}
@router.post("/config/accounts")
async def add_caldav_account(request: Request):
"""Add a new CalDAV account."""
import uuid as _uuid
owner = _require_user(request)
try:
body = await request.json()
except Exception:
body = {}
from src.caldav_sync import validate_caldav_url
try:
url = validate_caldav_url(body.get("url", ""))
except ValueError as e:
raise HTTPException(400, str(e))
if not body.get("password"):
raise HTTPException(400, "Password is required")
from src.secret_storage import encrypt
new_acc = {
"id": str(_uuid.uuid4()),
"label": (body.get("label") or "").strip() or "CalDAV",
"url": url,
"username": (body.get("username") or "").strip(),
"password": encrypt(body["password"]),
}
accounts = _get_caldav_accounts(owner)
accounts.append(new_acc)
_save_caldav_accounts(owner, accounts)
return {"ok": True, "id": new_acc["id"]}
@router.put("/config/accounts/{account_id}")
async def update_caldav_account(account_id: str, request: Request):
"""Update an existing CalDAV account by id."""
owner = _require_user(request)
try:
body = await request.json()
except Exception:
body = {}
accounts = _get_caldav_accounts(owner)
idx = next((i for i, a in enumerate(accounts) if a.get("id") == account_id), None)
if idx is None:
raise HTTPException(404, "Account not found")
acc = dict(accounts[idx])
if body.get("url"):
from src.caldav_sync import validate_caldav_url
try:
acc["url"] = validate_caldav_url(body["url"])
except ValueError as e:
raise HTTPException(400, str(e))
if body.get("label") is not None:
acc["label"] = (body.get("label") or "").strip() or "CalDAV"
if body.get("username") is not None:
acc["username"] = (body.get("username") or "").strip()
if body.get("password"):
from src.secret_storage import encrypt
acc["password"] = encrypt(body["password"])
accounts[idx] = acc
_save_caldav_accounts(owner, accounts)
return {"ok": True}
@router.delete("/config/accounts/{account_id}")
async def delete_caldav_account(account_id: str, request: Request):
"""Remove a CalDAV account by id."""
owner = _require_user(request)
accounts = _get_caldav_accounts(owner)
new_accounts = [a for a in accounts if a.get("id") != account_id]
if len(new_accounts) == len(accounts):
raise HTTPException(404, "Account not found")
_save_caldav_accounts(owner, new_accounts)
return {"ok": True} return {"ok": True}
@router.post("/test") @router.post("/test")
async def test_connection(request: Request): async def test_connection(request: Request):
"""Actually probe the configured CalDAV server with a PROPFIND """Probe a CalDAV server with a PROPFIND. Accepts an optional body:
request (the same handshake every CalDAV client uses). Accepts {url, username, password} to test before saving, or {account_id} to
an optional {url, username, password} body so the user can test test an already-saved account. Falls back to the first saved account
a configuration BEFORE saving it; falls back to the stored when nothing is provided."""
creds otherwise. Returns {ok, error?} with a useful message on
failure (status code, auth issue, network error)."""
owner = _require_user(request) owner = _require_user(request)
try: try:
body = await request.json() body = await request.json()
@@ -654,19 +760,24 @@ def setup_calendar_routes() -> APIRouter:
user = (body.get("username") or "").strip() user = (body.get("username") or "").strip()
pw = body.get("password") or "" pw = body.get("password") or ""
if not (url and user and pw): if not (url and user and pw):
# Fall back to saved settings for this user. # Look up a saved account: by id if supplied, else first account.
from routes.prefs_routes import _load_for_user accounts = _get_caldav_accounts(owner)
cfg = (_load_for_user(owner) or {}).get("caldav", {}) or {} acc = None
url = url or (cfg.get("url") or "") if body.get("account_id"):
user = user or (cfg.get("username") or "") acc = next((a for a in accounts if a.get("id") == body["account_id"]), None)
if not pw: if acc is None and accounts:
pw = cfg.get("password") or "" acc = accounts[0]
if pw: if acc:
try: url = url or (acc.get("url") or "")
from src.secret_storage import decrypt user = user or (acc.get("username") or "")
pw = decrypt(pw) if not pw:
except Exception: pw = acc.get("password") or ""
pass if pw:
try:
from src.secret_storage import decrypt
pw = decrypt(pw)
except Exception:
pass
if not (url and user and pw): if not (url and user and pw):
return {"ok": False, "error": "Missing URL, username, or password"} return {"ok": False, "error": "Missing URL, username, or password"}
from src.caldav_sync import validate_caldav_url from src.caldav_sync import validate_caldav_url
+87 -30
View File
@@ -128,10 +128,14 @@ def validate_caldav_url(raw_url: str) -> str:
return urlunparse(parsed._replace(fragment="")).rstrip("/") return urlunparse(parsed._replace(fragment="")).rstrip("/")
def _stable_cal_id(remote_url: str) -> str: def _stable_cal_id(remote_url: str, owner: str = "", account_id: str = "") -> str:
"""Deterministic local id for a remote CalDAV calendar — same URL """Deterministic local id for a remote CalDAV calendar, scoped to owner
always maps to the same local row across restarts and re-syncs.""" and account so two users — or one user with two accounts — pointing at
h = hashlib.sha256(remote_url.encode("utf-8")).hexdigest()[:24] 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}" return f"caldav-{h}"
@@ -212,7 +216,7 @@ def _open_url_as_calendar(client, url: str):
return client.calendar(url=target) return client.calendar(url=target)
def _sync_blocking(owner: str, url: str, username: str, password: str) -> dict: 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. """The actual sync — synchronous, intended to run in a threadpool.
Returns counts: {calendars, events, deleted, errors}.""" Returns counts: {calendars, events, deleted, errors}."""
# Lazy imports so a missing `caldav` dep doesn't break app startup — # Lazy imports so a missing `caldav` dep doesn't break app startup —
@@ -258,7 +262,7 @@ def _sync_blocking(owner: str, url: str, username: str, password: str) -> dict:
for remote_cal in calendars: for remote_cal in calendars:
try: try:
remote_url = str(remote_cal.url) remote_url = str(remote_cal.url)
cal_id = _stable_cal_id(remote_url) cal_id = _stable_cal_id(remote_url, owner=owner, account_id=account_id)
display_name = (remote_cal.name or "").strip() or "CalDAV" display_name = (remote_cal.name or "").strip() or "CalDAV"
local_cal = db.query(CalendarCal).filter( local_cal = db.query(CalendarCal).filter(
@@ -272,14 +276,20 @@ def _sync_blocking(owner: str, url: str, username: str, password: str) -> dict:
name=display_name, name=display_name,
color="#5b8abf", color="#5b8abf",
source="caldav", source="caldav",
account_id=account_id or None,
) )
db.add(local_cal) db.add(local_cal)
db.commit() db.commit()
else: else:
# Refresh the display name if the user renamed it # Refresh display name and stamp account_id if missing.
# remotely; preserve any local color override. changed = False
if local_cal.name != display_name: if local_cal.name != display_name:
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() db.commit()
result["calendars"] += 1 result["calendars"] += 1
@@ -401,31 +411,78 @@ def _sync_blocking(owner: str, url: str, username: str, password: str) -> dict:
return result return result
async def sync_caldav(owner: str) -> dict: def _load_caldav_accounts(owner: str) -> list:
"""Pull CalDAV state into local DB for `owner`. Returns counts + """Return the list of CalDAV accounts for *owner*, auto-migrating the legacy
errors. Loads credentials from the user's prefs; no-ops with a single-account ``caldav`` key to the new ``caldav_accounts`` list on first call.
clear error if CalDAV isn't configured."""
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 from routes.prefs_routes import _load_for_user
cfg = (_load_for_user(owner) or {}).get("caldav", {}) or {} prefs = _load_for_user(owner) or {}
url = (cfg.get("url") or "").strip() if "caldav_accounts" in prefs:
user = (cfg.get("username") or "").strip() return list(prefs["caldav_accounts"] or [])
pw = cfg.get("password") or "" # Migrate legacy single-account config to the list format.
try: legacy = prefs.get("caldav", {}) or {}
from src.secret_storage import decrypt if legacy.get("url"):
pw = decrypt(pw) accounts = [{
except Exception: "id": str(_uuid.uuid4()),
pass "label": "CalDAV",
if not (url and user and pw): "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 # best-effort; next call re-migrates from the still-present legacy key
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 { return {
"calendars": 0, "events": 0, "deleted": 0, "calendars": 0, "events": 0, "deleted": 0,
"errors": ["CalDAV is not configured"], "errors": ["CalDAV is not configured"],
} }
try:
url = validate_caldav_url(url) totals: dict = {"calendars": 0, "events": 0, "deleted": 0, "errors": []}
return await asyncio.to_thread(_sync_blocking, owner, url, user, pw) for acc in accounts:
except ValueError as e: url = (acc.get("url") or "").strip()
return {"calendars": 0, "events": 0, "deleted": 0, "errors": [str(e)]} user = (acc.get("username") or "").strip()
except Exception as e: pw = acc.get("password") or ""
logger.exception("CalDAV sync raised") account_id = acc.get("id") or ""
return {"calendars": 0, "events": 0, "deleted": 0, "errors": [str(e)[:200]]} 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
+49 -21
View File
@@ -23,11 +23,10 @@ from datetime import timezone
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _stable_cal_id(remote_url: str) -> str: def _stable_cal_id(remote_url: str, owner: str = "", account_id: str = "") -> str:
# Reuse the sync module's hashing so a local CalDAV calendar id maps back to # Reuse the sync module's hashing so owner+account_id scoping stays consistent.
# the same remote URL it was pulled from.
from src.caldav_sync import _stable_cal_id as _sync_id from src.caldav_sync import _stable_cal_id as _sync_id
return _sync_id(remote_url) return _sync_id(remote_url, owner=owner, account_id=account_id)
def build_event_ical(ev: dict) -> str: def build_event_ical(ev: dict) -> str:
@@ -76,28 +75,34 @@ def build_event_ical(ev: dict) -> str:
return cal.to_ical().decode("utf-8") return cal.to_ical().decode("utf-8")
def find_remote_calendar(calendars, local_cal_id: str): 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.""" """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: for cal in calendars:
try: try:
if _stable_cal_id(str(cal.url)) == local_cal_id: if _stable_cal_id(str(cal.url), owner=owner, account_id=account_id) == local_cal_id:
return cal return cal
except Exception: except Exception:
continue continue
return None return None
def push_event(calendars, local_cal_id: str, ev: dict, *, delete: bool = False) -> dict: 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. """Create/update (or delete) ``ev`` on the matching remote calendar.
Returns ``{"ok": bool, ...}``. ``calendars`` is the discovered caldav Returns ``{"ok": bool, ...}``. ``calendars`` is the discovered caldav
calendar list (injected so this is unit-testable with fakes). 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 uid = (ev or {}).get("uid") if isinstance(ev, dict) else None
if not uid: if not uid:
return {"ok": False, "error": "event uid is required"} return {"ok": False, "error": "event uid is required"}
remote = find_remote_calendar(calendars, local_cal_id) remote = find_remote_calendar(calendars, local_cal_id, owner=owner, account_id=account_id)
if remote is None: if remote is None:
return {"ok": False, "error": "remote calendar not found"} return {"ok": False, "error": "remote calendar not found"}
@@ -136,13 +141,15 @@ def _discover_calendars(client):
return [] return []
def _writeback_blocking(local_cal_id, ev, delete, url, username, password) -> dict: def _writeback_blocking(local_cal_id, ev, delete, url, username, password,
owner="", account_id="") -> dict:
import caldav import caldav
client = caldav.DAVClient(url=url, username=username, password=password) client = caldav.DAVClient(url=url, username=username, password=password)
calendars = _discover_calendars(client) calendars = _discover_calendars(client)
if not calendars: if not calendars:
return {"ok": False, "error": "no remote calendars discovered"} return {"ok": False, "error": "no remote calendars discovered"}
return push_event(calendars, local_cal_id, ev, delete=delete) return push_event(calendars, local_cal_id, ev, delete=delete,
owner=owner, account_id=account_id)
async def writeback_event(owner: str, calendar_source: str, calendar_id: str, async def writeback_event(owner: str, calendar_source: str, calendar_id: str,
@@ -156,24 +163,45 @@ async def writeback_event(owner: str, calendar_source: str, calendar_id: str,
if calendar_source != "caldav": if calendar_source != "caldav":
return {"skipped": "not a caldav calendar"} return {"skipped": "not a caldav calendar"}
try: try:
from routes.prefs_routes import _load_for_user from src.caldav_sync import _load_caldav_accounts
from src.secret_storage import decrypt from src.secret_storage import decrypt
cfg = (_load_for_user(owner) or {}).get("caldav", {}) or {} from core.database import CalendarCal, SessionLocal
url = (cfg.get("url") or "").strip()
user = (cfg.get("username") or "").strip() accounts = _load_caldav_accounts(owner)
# Stored encrypted by routes/calendar_routes; decrypt before use so if not accounts:
# the remote sees the real password (decrypt is a no-op on legacy
# plaintext). The pull path src/caldav_sync.py already does this.
pw = decrypt(cfg.get("password") or "")
if not (url and user and pw):
return {"skipped": "caldav not configured"} 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 from src.caldav_sync import validate_caldav_url
try: try:
url = validate_caldav_url(url) url = validate_caldav_url(url)
except ValueError as e: except ValueError as e:
logger.warning("CalDAV write-back URL rejected: %s", e) logger.warning("CalDAV write-back URL rejected: %s", e)
return {"ok": False, "error": str(e)[:200]} return {"ok": False, "error": str(e)[:200]}
result = await asyncio.to_thread(_writeback_blocking, calendar_id, ev, delete, url, user, pw) acc_id = acc.get("id") or ""
result = await asyncio.to_thread(
_writeback_blocking, calendar_id, ev, delete, url, user, pw, owner, acc_id
)
if not result.get("ok"): if not result.get("ok"):
logger.warning("CalDAV write-back did not apply: %s", result.get("error") or result) logger.warning("CalDAV write-back did not apply: %s", result.get("error") or result)
return result return result
+57 -46
View File
@@ -3199,7 +3199,7 @@ async function initUnifiedIntegrations() {
async function fetchAll() { async function fetchAll() {
const [apiRes, calRes, cardRes, contactsRes, emailAccountsRes, mcpRes, vaultRes, tokenRes, calendarsRes] = await Promise.all([ const [apiRes, calRes, cardRes, contactsRes, emailAccountsRes, mcpRes, vaultRes, tokenRes, calendarsRes] = await Promise.all([
fetch('/api/auth/integrations', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : { integrations: [] }).catch(() => ({ integrations: [] })), fetch('/api/auth/integrations', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : { integrations: [] }).catch(() => ({ integrations: [] })),
fetch('/api/calendar/config', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : {}).catch(() => ({})), fetch('/api/calendar/config/accounts', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : { accounts: [] }).catch(() => ({ accounts: [] })),
fetch('/api/contacts/config', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : {}).catch(() => ({})), fetch('/api/contacts/config', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : {}).catch(() => ({})),
fetch('/api/contacts/list', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : { contacts: [], count: 0 }).catch(() => ({ contacts: [], count: 0 })), fetch('/api/contacts/list', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : { contacts: [], count: 0 }).catch(() => ({ contacts: [], count: 0 })),
fetch('/api/email/accounts', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : { accounts: [] }).catch(() => ({ accounts: [] })), fetch('/api/email/accounts', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : { accounts: [] }).catch(() => ({ accounts: [] })),
@@ -3213,15 +3213,9 @@ async function initUnifiedIntegrations() {
for (const intg of (apiRes.integrations || [])) { for (const intg of (apiRes.integrations || [])) {
items.push({ type: 'api', id: intg.id, name: intg.name || 'Unnamed', detail: intg.base_url || '', enabled: intg.enabled !== false, data: intg }); items.push({ type: 'api', id: intg.id, name: intg.name || 'Unnamed', detail: intg.base_url || '', enabled: intg.enabled !== false, data: intg });
} }
// CalDAV — one card per synced calendar collection; fall back to the // CalDAV — one card per account
// server-level entry if calendars haven't been synced yet. for (const acc of (calRes.accounts || [])) {
const caldavCals = (calendarsRes.calendars || []).filter(c => c.source === 'caldav'); items.push({ type: 'caldav', id: acc.id, name: acc.label || 'Calendar (CalDAV)', detail: acc.url, enabled: true, data: acc });
if (caldavCals.length > 0) {
for (const cal of caldavCals) {
items.push({ type: 'caldav', id: cal.href, name: cal.name, detail: calRes.url || 'CalDAV', enabled: true, data: { ...cal, serverData: calRes } });
}
} else if (calRes.url) {
items.push({ type: 'caldav', id: '__caldav__', name: 'Calendar (CalDAV)', detail: calRes.url, enabled: true, data: calRes });
} }
// Contacts import first, then the optional CardDAV sync account. // Contacts import first, then the optional CardDAV sync account.
const contactCount = Number(contactsRes.count || (contactsRes.contacts || []).length || 0); const contactCount = Number(contactsRes.count || (contactsRes.contacts || []).length || 0);
@@ -3334,14 +3328,7 @@ async function initUnifiedIntegrations() {
const id = btn.dataset.intgId; const id = btn.dataset.intgId;
try { try {
if (type === 'api') await fetch(`/api/auth/integrations/${id}`, { method: 'DELETE', credentials: 'same-origin' }); if (type === 'api') await fetch(`/api/auth/integrations/${id}`, { method: 'DELETE', credentials: 'same-origin' });
else if (type === 'caldav') { else if (type === 'caldav') await fetch(`/api/calendar/config/accounts/${id}`, { method: 'DELETE', credentials: 'same-origin' });
if (id === '__caldav__') {
// Fallback card: server configured but never synced — clear credentials
await fetch('/api/calendar/config', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: '', username: '', password: '' }) });
} else {
await fetch(`/api/calendar/calendars/${id}`, { method: 'DELETE', credentials: 'same-origin' });
}
}
else if (type === 'contacts') { else if (type === 'contacts') {
await fetch('/api/contacts/clear', { method: 'DELETE', credentials: 'same-origin' }); await fetch('/api/contacts/clear', { method: 'DELETE', credentials: 'same-origin' });
} }
@@ -3363,7 +3350,7 @@ async function initUnifiedIntegrations() {
function showForm(type, editId) { function showForm(type, editId) {
formEl.style.display = ''; formEl.style.display = '';
if (type === 'api') showApiForm(editId); if (type === 'api') showApiForm(editId);
else if (type === 'caldav') showCalDavForm(); else if (type === 'caldav') showCalDavForm(editId);
else if (type === 'contacts' || type === 'carddav') showCardDavForm(); else if (type === 'contacts' || type === 'carddav') showCardDavForm();
else if (type === 'email') showEmailForm(editId); else if (type === 'email') showEmailForm(editId);
else if (type === 'mcp') showMcpForm(editId); else if (type === 'mcp') showMcpForm(editId);
@@ -3555,33 +3542,43 @@ async function initUnifiedIntegrations() {
}); });
} }
// ── CalDAV form ── // ── CalDAV form (supports add + edit per account) ──
async function showCalDavForm() { async function showCalDavForm(editId) {
const isNew = !editId || editId === 'new';
formEl.innerHTML = ` formEl.innerHTML = `
<div class="admin-card" style="margin-top:8px"> <div class="admin-card" style="margin-top:8px">
<h2 style="font-size:13px">Calendar (CalDAV)</h2> <h2 style="font-size:13px;display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent, var(--red));flex-shrink:0;"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>${isNew ? 'Add CalDAV Calendar' : 'Edit CalDAV Calendar'}</h2>
<div class="settings-col"> <div class="settings-col">
<div class="settings-row"><label class="settings-label">Server URL</label><input id="uf-caldav-url" class="settings-input" placeholder="http://localhost:5232/user"></div> <div class="settings-row"><label class="settings-label">Label</label><input id="uf-caldav-label" class="settings-input" placeholder="e.g. Work, Personal"></div>
<div class="settings-row"><label class="settings-label">Username</label><input id="uf-caldav-user" class="settings-input"></div> <div class="settings-row"><label class="settings-label">Server URL</label><input id="uf-caldav-url" class="settings-input" placeholder="https://www.google.com/calendar/dav/you@gmail.com/user/"></div>
<div class="settings-row"><label class="settings-label">Password</label><input id="uf-caldav-pass" class="settings-input" type="password"></div> <div class="settings-row"><label class="settings-label">Username</label><input id="uf-caldav-user" class="settings-input" placeholder="you@example.com"></div>
<div class="settings-row" style="margin-top:4px"><button class="admin-btn-sm" id="uf-caldav-save">Save</button><button class="admin-btn-sm" id="uf-caldav-test" style="opacity:0.7">Test</button><button class="admin-btn-sm" id="uf-caldav-cancel" style="opacity:0.7">Cancel</button><span id="uf-caldav-msg" style="font-size:11px"></span></div> <div class="settings-row"><label class="settings-label">Password</label><input id="uf-caldav-pass" class="settings-input" type="password" placeholder="${isNew ? '' : 'Leave blank to keep existing'}"></div>
<div class="settings-row" style="margin-top:4px"><button class="admin-btn-sm" id="uf-caldav-save">Save</button><button class="admin-btn-sm" id="uf-caldav-test" style="opacity:0.7">Test</button><button class="admin-btn-sm" id="uf-caldav-cancel" style="opacity:0.7">Cancel</button><span id="uf-caldav-msg" style="font-size:11px;margin-left:6px"></span></div>
</div> </div>
</div>`; </div>`;
try {
const r = await fetch('/api/calendar/config', { credentials: 'same-origin' }); const d = await r.json(); if (!isNew) {
el('uf-caldav-url').value = d.url || ''; el('uf-caldav-user').value = d.username || ''; try {
} catch (_) {} const r = await fetch('/api/calendar/config/accounts', { credentials: 'same-origin' });
const d = await r.json();
const acc = (d.accounts || []).find(a => a.id === editId);
if (acc) {
el('uf-caldav-label').value = acc.label || '';
el('uf-caldav-url').value = acc.url || '';
el('uf-caldav-user').value = acc.username || '';
}
} catch (_) {}
}
el('uf-caldav-cancel').addEventListener('click', () => { formEl.style.display = 'none'; }); el('uf-caldav-cancel').addEventListener('click', () => { formEl.style.display = 'none'; });
// Run a PROPFIND with the form's current url+user+pass. Used by
// both the Test button (visible result only) and by Save (refuse
// to persist a broken config). Returns the parsed {ok, error?}.
const _runCalDavTest = async () => { const _runCalDavTest = async () => {
const body = { const body = {
url: el('uf-caldav-url').value.trim(), url: el('uf-caldav-url').value.trim(),
username: el('uf-caldav-user').value.trim(), username: el('uf-caldav-user').value.trim(),
password: el('uf-caldav-pass').value, password: el('uf-caldav-pass').value,
}; };
if (!isNew && !body.password) body.account_id = editId;
try { try {
const r = await fetch('/api/calendar/test', { const r = await fetch('/api/calendar/test', {
method: 'POST', credentials: 'same-origin', method: 'POST', credentials: 'same-origin',
@@ -3593,6 +3590,7 @@ async function initUnifiedIntegrations() {
return { ok: false, error: 'Network error: ' + e.message }; return { ok: false, error: 'Network error: ' + e.message };
} }
}; };
const _setCalDavMsg = (text, ok) => { const _setCalDavMsg = (text, ok) => {
const msg = el('uf-caldav-msg'); const msg = el('uf-caldav-msg');
msg.textContent = text; msg.textContent = text;
@@ -3600,10 +3598,6 @@ async function initUnifiedIntegrations() {
}; };
el('uf-caldav-save').addEventListener('click', async () => { el('uf-caldav-save').addEventListener('click', async () => {
// Pre-validate by hitting the server with the same PROPFIND the
// Test button uses. If the CalDAV server rejects the creds/URL
// we won't persist garbage — the user gets the actual error
// (HTTP 401, "Not found", "Connection refused", etc.) in red.
_setCalDavMsg('Testing…', true); _setCalDavMsg('Testing…', true);
el('uf-caldav-msg').style.color = ''; el('uf-caldav-msg').style.color = '';
const d = await _runCalDavTest(); const d = await _runCalDavTest();
@@ -3612,15 +3606,31 @@ async function initUnifiedIntegrations() {
return; return;
} }
try { try {
await fetch('/api/calendar/config', { const payload = {
method: 'POST', credentials: 'same-origin', label: el('uf-caldav-label').value.trim(),
headers: { 'Content-Type': 'application/json' }, url: el('uf-caldav-url').value.trim(),
body: JSON.stringify({ username: el('uf-caldav-user').value.trim(),
url: el('uf-caldav-url').value, password: el('uf-caldav-pass').value,
username: el('uf-caldav-user').value, };
password: el('uf-caldav-pass').value, let resp;
}), if (isNew) {
}); resp = await fetch('/api/calendar/config/accounts', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
} else {
resp = await fetch(`/api/calendar/config/accounts/${editId}`, {
method: 'PUT', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
}
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
_setCalDavMsg(err.detail || 'Save failed', false);
return;
}
_setCalDavMsg('Saved', true); _setCalDavMsg('Saved', true);
formEl.style.display = 'none'; formEl.style.display = 'none';
await renderList(); await renderList();
@@ -3629,6 +3639,7 @@ async function initUnifiedIntegrations() {
_setCalDavMsg('Save failed', false); _setCalDavMsg('Save failed', false);
} }
}); });
el('uf-caldav-test').addEventListener('click', async () => { el('uf-caldav-test').addEventListener('click', async () => {
_setCalDavMsg('Testing…', true); _setCalDavMsg('Testing…', true);
el('uf-caldav-msg').style.color = ''; el('uf-caldav-msg').style.color = '';
+4 -2
View File
@@ -88,6 +88,7 @@ def test_sync_caldav_decrypts_stored_password_and_validates_url(monkeypatch):
"_resolve_caldav_host_ips", "_resolve_caldav_host_ips",
lambda host: [ipaddress.ip_address("93.184.216.34")], lambda host: [ipaddress.ip_address("93.184.216.34")],
) )
saved = {}
prefs_mod = types.ModuleType("routes.prefs_routes") prefs_mod = types.ModuleType("routes.prefs_routes")
prefs_mod._load_for_user = lambda owner: { prefs_mod._load_for_user = lambda owner: {
"caldav": { "caldav": {
@@ -96,6 +97,7 @@ def test_sync_caldav_decrypts_stored_password_and_validates_url(monkeypatch):
"password": "enc:stored", "password": "enc:stored",
} }
} }
prefs_mod._save_for_user = lambda owner, prefs: saved.update({"owner": owner, "prefs": prefs})
monkeypatch.setitem(sys.modules, "routes.prefs_routes", prefs_mod) monkeypatch.setitem(sys.modules, "routes.prefs_routes", prefs_mod)
secret_mod = types.ModuleType("src.secret_storage") secret_mod = types.ModuleType("src.secret_storage")
@@ -104,7 +106,7 @@ def test_sync_caldav_decrypts_stored_password_and_validates_url(monkeypatch):
captured = {} captured = {}
def fake_sync_blocking(owner, url, username, password): def fake_sync_blocking(owner, url, username, password, account_id=""):
captured.update( captured.update(
{ {
"owner": owner, "owner": owner,
@@ -136,7 +138,7 @@ def test_calendar_routes_use_hardened_caldav_client_and_secret_storage():
text = Path("routes/calendar_routes.py").read_text(encoding="utf-8") text = Path("routes/calendar_routes.py").read_text(encoding="utf-8")
assert "validate_caldav_url(body.get(\"url\", \"\"))" in text assert "validate_caldav_url(body.get(\"url\", \"\"))" in text
assert "cfg[\"password\"] = encrypt(body[\"password\"])" in text assert "encrypt(body[\"password\"])" in text
assert "pw = decrypt(pw)" in text assert "pw = decrypt(pw)" in text
assert "follow_redirects=False, trust_env=False" in text assert "follow_redirects=False, trust_env=False" in text
assert "Redirects are not followed for CalDAV safety" in text assert "Redirects are not followed for CalDAV safety" in text
+4 -2
View File
@@ -151,7 +151,8 @@ def test_writeback_validates_saved_url_before_remote_call(monkeypatch):
captured["validated_url"] = url captured["validated_url"] = url
return "https://dav.example.com/calendars/home" return "https://dav.example.com/calendars/home"
def fake_writeback_blocking(local_cal_id, ev, delete, url, username, password): def fake_writeback_blocking(local_cal_id, ev, delete, url, username, password,
owner="", account_id=""):
captured.update( captured.update(
{ {
"local_cal_id": local_cal_id, "local_cal_id": local_cal_id,
@@ -207,7 +208,8 @@ def test_writeback_rejects_unsafe_saved_url_before_remote_call(monkeypatch):
def fake_validate(_url): def fake_validate(_url):
raise ValueError("CalDAV URL host is not allowed") raise ValueError("CalDAV URL host is not allowed")
def fake_writeback_blocking(*_args, **_kwargs): def fake_writeback_blocking(local_cal_id, ev, delete, url, username, password,
owner="", account_id=""):
nonlocal called nonlocal called
called = True called = True
return {"ok": True} return {"ok": True}