mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-16 17:55:26 -04:00
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:
+161
-50
@@ -579,72 +579,178 @@ def _expand_rrule(
|
||||
def setup_calendar_routes() -> APIRouter:
|
||||
router = APIRouter(prefix="/api/calendar", tags=["calendar"])
|
||||
|
||||
# CalDAV connect form (Integrations → Calendar). Storage is local
|
||||
# SQLite; sync (src/caldav_sync.py) pulls remote events into it on
|
||||
# calendar open and periodically via the scheduler.
|
||||
# ── CalDAV multi-account helpers ─────────────────────────────────────────
|
||||
|
||||
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")
|
||||
async def get_config(request: Request):
|
||||
"""Legacy single-account endpoint — returns the first configured account."""
|
||||
owner = _require_user(request)
|
||||
from routes.prefs_routes import _load_for_user
|
||||
cfg = (_load_for_user(owner) or {}).get("caldav", {}) or {}
|
||||
caldav_password = cfg.get("password") or ""
|
||||
if caldav_password:
|
||||
accounts = _get_caldav_accounts(owner)
|
||||
if not accounts:
|
||||
return {"url": "", "username": "", "password": "", "has_password": False, "local": True}
|
||||
first = accounts[0]
|
||||
pw = first.get("password") or ""
|
||||
has_pw = False
|
||||
if pw:
|
||||
try:
|
||||
from src.secret_storage import decrypt
|
||||
caldav_password = decrypt(caldav_password)
|
||||
has_pw = bool(decrypt(pw))
|
||||
except Exception:
|
||||
pass
|
||||
# Surface url+username but never hand the password back to the
|
||||
# client — saved-state UI shouldn't leak the credential.
|
||||
has_pw = bool(pw)
|
||||
return {
|
||||
"url": cfg.get("url", "") or "",
|
||||
"username": cfg.get("username", "") or "",
|
||||
"url": first.get("url", "") or "",
|
||||
"username": first.get("username", "") or "",
|
||||
"password": "",
|
||||
"has_password": bool(caldav_password),
|
||||
"local": not bool(cfg.get("url")),
|
||||
"has_password": has_pw,
|
||||
"local": not bool(first.get("url")),
|
||||
}
|
||||
|
||||
@router.post("/config")
|
||||
async def save_config(request: Request):
|
||||
"""Legacy single-account endpoint — upserts the first account."""
|
||||
owner = _require_user(request)
|
||||
from routes.prefs_routes import _load_for_user, _save_for_user
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
prefs = _load_for_user(owner) or {}
|
||||
cfg = dict(prefs.get("caldav") or {})
|
||||
# Empty url => clear the whole entry (treat as "remove integration").
|
||||
accounts = _get_caldav_accounts(owner)
|
||||
if not (body.get("url") or "").strip():
|
||||
prefs.pop("caldav", None)
|
||||
_save_for_user(owner, prefs)
|
||||
_save_caldav_accounts(owner, [])
|
||||
return {"ok": True, "cleared": True}
|
||||
from src.caldav_sync import validate_caldav_url
|
||||
try:
|
||||
cfg["url"] = validate_caldav_url(body.get("url", ""))
|
||||
validated_url = validate_caldav_url(body.get("url", ""))
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
cfg["username"] = (body.get("username") or "").strip()
|
||||
# Preserve the stored password when the client sends an empty
|
||||
# one (edit form re-submitted without re-typing the password).
|
||||
# cfg already holds the existing (already-encrypted) password from
|
||||
# prefs, so we only touch it when a new password is supplied —
|
||||
# re-encrypting the stored value would double-encrypt it.
|
||||
if accounts:
|
||||
acc = dict(accounts[0])
|
||||
else:
|
||||
import uuid as _uuid
|
||||
acc = {"id": str(_uuid.uuid4()), "label": "CalDAV"}
|
||||
acc["url"] = validated_url
|
||||
acc["username"] = (body.get("username") or "").strip()
|
||||
if body.get("password"):
|
||||
from src.secret_storage import encrypt
|
||||
cfg["password"] = encrypt(body["password"])
|
||||
prefs["caldav"] = cfg
|
||||
_save_for_user(owner, prefs)
|
||||
acc["password"] = encrypt(body["password"])
|
||||
new_accounts = [acc] + (accounts[1:] if len(accounts) > 1 else [])
|
||||
_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}
|
||||
|
||||
@router.post("/test")
|
||||
async def test_connection(request: Request):
|
||||
"""Actually probe the configured CalDAV server with a PROPFIND
|
||||
request (the same handshake every CalDAV client uses). Accepts
|
||||
an optional {url, username, password} body so the user can test
|
||||
a configuration BEFORE saving it; falls back to the stored
|
||||
creds otherwise. Returns {ok, error?} with a useful message on
|
||||
failure (status code, auth issue, network error)."""
|
||||
"""Probe a CalDAV server with a PROPFIND. Accepts an optional body:
|
||||
{url, username, password} to test before saving, or {account_id} to
|
||||
test an already-saved account. Falls back to the first saved account
|
||||
when nothing is provided."""
|
||||
owner = _require_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
@@ -654,19 +760,24 @@ def setup_calendar_routes() -> APIRouter:
|
||||
user = (body.get("username") or "").strip()
|
||||
pw = body.get("password") or ""
|
||||
if not (url and user and pw):
|
||||
# Fall back to saved settings for this user.
|
||||
from routes.prefs_routes import _load_for_user
|
||||
cfg = (_load_for_user(owner) or {}).get("caldav", {}) or {}
|
||||
url = url or (cfg.get("url") or "")
|
||||
user = user or (cfg.get("username") or "")
|
||||
if not pw:
|
||||
pw = cfg.get("password") or ""
|
||||
if pw:
|
||||
try:
|
||||
from src.secret_storage import decrypt
|
||||
pw = decrypt(pw)
|
||||
except Exception:
|
||||
pass
|
||||
# Look up a saved account: by id if supplied, else first account.
|
||||
accounts = _get_caldav_accounts(owner)
|
||||
acc = None
|
||||
if body.get("account_id"):
|
||||
acc = next((a for a in accounts if a.get("id") == body["account_id"]), None)
|
||||
if acc is None and accounts:
|
||||
acc = accounts[0]
|
||||
if acc:
|
||||
url = url or (acc.get("url") or "")
|
||||
user = user or (acc.get("username") or "")
|
||||
if not pw:
|
||||
pw = acc.get("password") or ""
|
||||
if pw:
|
||||
try:
|
||||
from src.secret_storage import decrypt
|
||||
pw = decrypt(pw)
|
||||
except Exception:
|
||||
pass
|
||||
if not (url and user and pw):
|
||||
return {"ok": False, "error": "Missing URL, username, or password"}
|
||||
from src.caldav_sync import validate_caldav_url
|
||||
|
||||
Reference in New Issue
Block a user