Files
Calendarr/backend/dav_util.py
Scarriffle fb32f0424f feat(caldav): username/password (Basic Auth) access with discovery + https URLs
- dav_router: add Basic-Auth principal-discovery tree at /caldav/ (and
  /.well-known/caldav) so clients can add a CalDAV account with server URL +
  username + password; lists all published calendars. Token URL /dav/{token}/
  still works without login. Handlers generalised over a base href.
- dav_util: derive the public origin from X-Forwarded-Proto/-Host (or
  PUBLIC_BASE_URL) so published URLs are https, not internal http:8080.
- local_router: expose caldav_login_url alongside caldav_url.
- frontend/i18n: show both the no-login token URL and the login URL + hint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 12:44:16 +02:00

64 lines
2.1 KiB
Python

"""Shared helpers for CalDAV publishing of local calendars.
Publishing is opt-in per calendar: a published calendar gets a secret
``dav_token`` and is reachable as a two-way CalDAV collection at
``/dav/{token}/``. ``dav_ctag`` changes on every event write so clients detect
changes; each event carries an ``etag`` that changes on write. Rotating the
token revokes existing subscriptions.
"""
from __future__ import annotations
import os
import secrets
import uuid
def new_token() -> str:
"""A URL-safe, unguessable token used as the CalDAV collection path."""
return secrets.token_urlsafe(24)
def new_tag() -> str:
"""A fresh ctag/etag value."""
return uuid.uuid4().hex
def bump_dav(cal, event=None) -> None:
"""Mark a calendar (and optionally an event) as changed for CalDAV clients.
Safe to call unconditionally on every local-event write — it only refreshes
opaque change tags, so unpublished calendars are unaffected.
"""
if cal is not None:
cal.dav_ctag = new_tag()
if event is not None:
event.etag = new_tag()
def public_base(request) -> str:
"""Public origin (scheme://host) as clients actually reach us.
Behind a reverse proxy (e.g. Nginx Proxy Manager) the app only sees
``http://…:8080`` internally, so honour ``X-Forwarded-Proto/-Host`` and an
optional ``PUBLIC_BASE_URL`` override so published URLs are the real https
ones.
"""
env = os.environ.get("PUBLIC_BASE_URL")
if env:
return env.rstrip("/")
h = request.headers
proto = (h.get("x-forwarded-proto") or request.url.scheme or "http").split(",")[0].strip()
host = (h.get("x-forwarded-host") or h.get("host") or request.url.netloc).split(",")[0].strip()
return f"{proto}://{host}"
def caldav_url(request, token: str) -> str:
"""Absolute per-calendar CalDAV collection URL (secret token, no login)."""
return f"{public_base(request)}/dav/{token}/"
def caldav_login_url(request) -> str:
"""Absolute discovery URL for username/password (Basic Auth) CalDAV access."""
return f"{public_base(request)}/caldav/"