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>
This commit is contained in:
Scarriffle
2026-07-01 12:44:16 +02:00
parent 34e701fa78
commit fb32f0424f
6 changed files with 258 additions and 90 deletions

View File

@@ -9,6 +9,7 @@ token revokes existing subscriptions.
from __future__ import annotations
import os
import secrets
import uuid
@@ -35,7 +36,28 @@ def bump_dav(cal, event=None) -> 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 CalDAV collection URL for a token, based on the request origin."""
base = str(request.base_url).rstrip("/")
return f"{base}/dav/{token}/"
"""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/"

View File

@@ -1,30 +1,37 @@
"""Minimal two-way CalDAV server for published local calendars.
Each published local calendar is reachable as a CalDAV collection at
``/dav/{token}/`` (secret token = auth). Supports the subset real clients
(Thunderbird, DAVx5, Apple Calendar) need: OPTIONS, PROPFIND, REPORT
(calendar-query / calendar-multiget), GET, PUT and DELETE. Change detection is
ctag-based (CS:getctag on the collection + getetag per event), which avoids
maintaining deletion tombstones.
Two ways to reach a published local calendar:
Reuses ``ical_io.build_ics`` / ``parse_ics`` for (de)serialisation. Note:
VALARM/reminders are not round-tripped (parse_ics ignores them).
1. Secret token URL (no login) — ``/dav/{token}/``.
2. Username + password (HTTP Basic Auth) with principal discovery —
``/caldav/`` advertises the user's calendar-home-set and lists every
published calendar as ``/caldav/{id}/``. This is what account-based clients
(Apple Calendar, DAVx5, Thunderbird) use when you enter server + credentials.
Supported methods: OPTIONS, PROPFIND, REPORT (calendar-query / calendar-multiget),
GET, PUT, DELETE. Change detection is ctag-based (CS:getctag on the collection +
getetag per event), avoiding deletion tombstones.
Reuses ``ical_io.build_ics`` / ``parse_ics``. Note: VALARM/reminders are not
round-tripped (parse_ics ignores them).
"""
from __future__ import annotations
import base64
import uuid
import xml.etree.ElementTree as ET
from urllib.parse import quote, unquote
from xml.sax.saxutils import escape as xml_escape
from fastapi import APIRouter, Depends, Request
from fastapi.responses import Response
from fastapi.responses import RedirectResponse, Response
from sqlalchemy.orm import Session
import dav_util
import ical_io
import models
from auth import verify_password
from database import get_db
router = APIRouter()
@@ -44,6 +51,7 @@ _NS_DECL = (
_ALLOW = "OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, REPORT"
_MULTISTATUS_CT = "application/xml; charset=utf-8"
_LOGIN_HREF = "/caldav/"
# ── Helpers ───────────────────────────────────────────────
@@ -61,6 +69,44 @@ def _resolve(token: str, db: Session) -> models.LocalCalendar | None:
)
def _basic_auth_user(request: Request, db: Session) -> models.User | None:
"""Validate an HTTP Basic Authorization header against a Calendarr account."""
hdr = request.headers.get("Authorization", "")
if not hdr.lower().startswith("basic "):
return None
try:
raw = base64.b64decode(hdr.split(" ", 1)[1]).decode("utf-8")
except Exception:
return None
username, sep, password = raw.partition(":")
if not sep:
return None
user = db.query(models.User).filter(models.User.username == username).first()
if not user:
return None
try:
if not verify_password(password, user.password_hash):
return None
except Exception:
return None
return user
def _unauthorized() -> Response:
return Response(status_code=401, headers={"WWW-Authenticate": 'Basic realm="Calendarr CalDAV"'})
def _published_calendars(user: models.User, db: Session) -> list[models.LocalCalendar]:
return (
db.query(models.LocalCalendar)
.filter(
models.LocalCalendar.user_id == user.id,
models.LocalCalendar.caldav_published == True, # noqa: E712
)
.all()
)
def _events(cal: models.LocalCalendar, db: Session) -> list[models.LocalEvent]:
return (
db.query(models.LocalEvent)
@@ -77,12 +123,8 @@ def _resource_name(ev: models.LocalEvent) -> str:
return f"{quote(ev.uid, safe='')}.ics"
def _collection_href(token: str) -> str:
return f"/dav/{token}/"
def _event_href(token: str, ev: models.LocalEvent) -> str:
return f"/dav/{token}/{_resource_name(ev)}"
def _event_href(base: str, ev: models.LocalEvent) -> str:
return f"{base}{_resource_name(ev)}"
def _name_cache(cal: models.LocalCalendar, db: Session) -> dict:
@@ -98,10 +140,13 @@ def _build_ics(cal: models.LocalCalendar, evs: list[models.LocalEvent], db: Sess
# ── XML builders ──────────────────────────────────────────
def _collection_propstat(cal: models.LocalCalendar, token: str) -> str:
href = _collection_href(token)
def _collection_propstat(cal: models.LocalCalendar, base: str, *,
principal_href: str | None = None,
home_href: str | None = None) -> str:
principal_href = principal_href or base
home_href = home_href or base
return f""" <D:response>
<D:href>{href}</D:href>
<D:href>{base}</D:href>
<D:propstat>
<D:prop>
<D:resourcetype><D:collection/><C:calendar/></D:resourcetype>
@@ -113,6 +158,24 @@ def _collection_propstat(cal: models.LocalCalendar, token: str) -> str:
</D:supported-report-set>
<C:supported-calendar-component-set><C:comp name="VEVENT"/></C:supported-calendar-component-set>
<ICAL:calendar-color>{xml_escape(cal.color or "#34a853")}</ICAL:calendar-color>
<D:current-user-principal><D:href>{principal_href}</D:href></D:current-user-principal>
<D:principal-URL><D:href>{principal_href}</D:href></D:principal-URL>
<C:calendar-home-set><D:href>{home_href}</D:href></C:calendar-home-set>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>"""
def _principal_propstat(user: models.User) -> str:
href = _LOGIN_HREF
name = user.display_name or user.username
return f""" <D:response>
<D:href>{href}</D:href>
<D:propstat>
<D:prop>
<D:resourcetype><D:collection/><D:principal/></D:resourcetype>
<D:displayname>{xml_escape(name)}</D:displayname>
<D:current-user-principal><D:href>{href}</D:href></D:current-user-principal>
<D:principal-URL><D:href>{href}</D:href></D:principal-URL>
<C:calendar-home-set><D:href>{href}</D:href></C:calendar-home-set>
@@ -122,9 +185,9 @@ def _collection_propstat(cal: models.LocalCalendar, token: str) -> str:
</D:response>"""
def _event_propstat(token: str, ev: models.LocalEvent, *, with_data: bool = False,
def _event_propstat(base: str, ev: models.LocalEvent, *, with_data: bool = False,
ics: str | None = None) -> str:
href = _event_href(token, ev)
href = _event_href(base, ev)
data = ""
if with_data and ics is not None:
data = f"\n <C:calendar-data>{xml_escape(ics)}</C:calendar-data>"
@@ -146,7 +209,7 @@ def _multistatus(body: str) -> Response:
return Response(content=xml, status_code=207, media_type=_MULTISTATUS_CT)
# ── Method handlers ───────────────────────────────────────
# ── Method handlers (shared by token and Basic-Auth paths) ──
def _handle_options() -> Response:
return Response(status_code=200, headers={
@@ -155,49 +218,6 @@ def _handle_options() -> Response:
})
def _handle_propfind(cal: models.LocalCalendar, token: str, resource: str,
depth: str, db: Session) -> Response:
# PROPFIND on a single event resource.
if resource:
ev = _find_event(cal, resource, db)
if not ev:
return Response(status_code=404)
return _multistatus(_event_propstat(token, ev))
# Collection: always include collection props; Depth:1 adds each event.
parts = [_collection_propstat(cal, token)]
if depth != "0":
for ev in _events(cal, db):
parts.append(_event_propstat(token, ev))
return _multistatus("\n".join(parts))
def _handle_report(cal: models.LocalCalendar, token: str, body: bytes, db: Session) -> Response:
report_type = None
hrefs: list[str] = []
if body:
try:
root = ET.fromstring(body)
report_type = root.tag.split("}")[-1] # calendar-query | calendar-multiget
hrefs = [el.text for el in root.iter(f"{{{NS_DAV}}}href") if el.text]
except ET.ParseError:
pass
if report_type == "calendar-multiget" and hrefs:
# Decode each requested href to a bare "{uid}.ics" for encoding-safe match.
wanted = {unquote(h.rstrip("/").rsplit("/", 1)[-1]) for h in hrefs}
evs = [ev for ev in _events(cal, db) if f"{ev.uid}.ics" in wanted]
else:
# calendar-query (or unknown) → return the whole calendar.
evs = _events(cal, db)
parts = []
for ev in evs:
ics = _build_ics(cal, [ev], db)
parts.append(_event_propstat(token, ev, with_data=True, ics=ics))
return _multistatus("\n".join(parts) if parts else "")
def _find_event(cal: models.LocalCalendar, resource: str, db: Session) -> models.LocalEvent | None:
name = resource.rsplit("/", 1)[-1]
if name.endswith(".ics"):
@@ -213,6 +233,49 @@ def _find_event(cal: models.LocalCalendar, resource: str, db: Session) -> models
)
def _handle_propfind(cal: models.LocalCalendar, base: str, resource: str, depth: str,
db: Session, *, principal_href: str | None = None,
home_href: str | None = None) -> Response:
# PROPFIND on a single event resource.
if resource:
ev = _find_event(cal, resource, db)
if not ev:
return Response(status_code=404)
return _multistatus(_event_propstat(base, ev))
# Collection: always include collection props; Depth:1 adds each event.
parts = [_collection_propstat(cal, base, principal_href=principal_href, home_href=home_href)]
if depth != "0":
for ev in _events(cal, db):
parts.append(_event_propstat(base, ev))
return _multistatus("\n".join(parts))
def _handle_report(cal: models.LocalCalendar, base: str, body: bytes, db: Session) -> Response:
report_type = None
hrefs: list[str] = []
if body:
try:
root = ET.fromstring(body)
report_type = root.tag.split("}")[-1] # calendar-query | calendar-multiget
hrefs = [el.text for el in root.iter(f"{{{NS_DAV}}}href") if el.text]
except ET.ParseError:
pass
if report_type == "calendar-multiget" and hrefs:
wanted = {unquote(h.rstrip("/").rsplit("/", 1)[-1]) for h in hrefs}
evs = [ev for ev in _events(cal, db) if f"{ev.uid}.ics" in wanted]
else:
# calendar-query (or unknown) → return the whole calendar.
evs = _events(cal, db)
parts = []
for ev in evs:
ics = _build_ics(cal, [ev], db)
parts.append(_event_propstat(base, ev, with_data=True, ics=ics))
return _multistatus("\n".join(parts) if parts else "")
def _handle_get(cal: models.LocalCalendar, resource: str, db: Session,
*, head: bool = False) -> Response:
ev = _find_event(cal, resource, db)
@@ -276,27 +339,20 @@ def _handle_delete(cal: models.LocalCalendar, resource: str, db: Session) -> Res
return Response(status_code=204)
# ── Dispatch ──────────────────────────────────────────────
async def _dispatch(request: Request, token: str, resource: str, db: Session) -> Response:
async def _dispatch_collection(request: Request, cal: models.LocalCalendar, base: str,
resource: str, db: Session, *,
principal_href: str | None = None,
home_href: str | None = None) -> Response:
"""Serve a single calendar collection; auth/ownership already checked."""
method = request.method.upper()
# OPTIONS advertises capabilities without needing a valid token.
if method == "OPTIONS":
return _handle_options()
cal = _resolve(token, db)
if not cal:
return Response(status_code=404)
if method == "PROPFIND":
depth = request.headers.get("Depth", "0")
return _handle_propfind(cal, token, resource, depth, db)
return _handle_propfind(cal, base, resource, depth, db,
principal_href=principal_href, home_href=home_href)
if method == "REPORT":
return _handle_report(cal, token, await request.body(), db)
return _handle_report(cal, base, await request.body(), db)
if method in ("GET", "HEAD"):
if not resource:
# A bare collection GET: hand back the whole calendar as one .ics.
ics = _build_ics(cal, _events(cal, db), db)
return Response(content=ics, media_type="text/calendar; charset=utf-8")
return _handle_get(cal, resource, db, head=(method == "HEAD"))
@@ -309,14 +365,93 @@ async def _dispatch(request: Request, token: str, resource: str, db: Session) ->
return Response(status_code=405, headers={"Allow": _ALLOW})
# ── Token path (no login): /dav/{token}/… ─────────────────
async def _dispatch_token(request: Request, token: str, resource: str, db: Session) -> Response:
if request.method.upper() == "OPTIONS":
return _handle_options()
cal = _resolve(token, db)
if not cal:
return Response(status_code=404)
base = f"/dav/{token}/"
return await _dispatch_collection(request, cal, base, resource, db)
_METHODS = ["OPTIONS", "GET", "HEAD", "PUT", "DELETE", "PROPFIND", "REPORT"]
@router.api_route("/dav/{token}", methods=_METHODS, include_in_schema=False)
async def dav_collection(token: str, request: Request, db: Session = Depends(get_db)):
return await _dispatch(request, token, "", db)
return await _dispatch_token(request, token, "", db)
@router.api_route("/dav/{token}/{resource:path}", methods=_METHODS, include_in_schema=False)
async def dav_resource(token: str, resource: str, request: Request, db: Session = Depends(get_db)):
return await _dispatch(request, token, resource, db)
return await _dispatch_token(request, token, resource, db)
# ── Basic-Auth path (username/password): /caldav/… ────────
async def _dispatch_home(request: Request, db: Session) -> Response:
"""Principal + calendar-home-set: lists the user's published calendars."""
if request.method.upper() == "OPTIONS":
return _handle_options()
user = _basic_auth_user(request, db)
if not user:
return _unauthorized()
if request.method.upper() != "PROPFIND":
return Response(status_code=405, headers={"Allow": _ALLOW})
depth = request.headers.get("Depth", "0")
parts = [_principal_propstat(user)]
if depth != "0":
for cal in _published_calendars(user, db):
parts.append(_collection_propstat(
cal, f"/caldav/{cal.id}/",
principal_href=_LOGIN_HREF, home_href=_LOGIN_HREF))
return _multistatus("\n".join(parts))
async def _dispatch_auth_calendar(request: Request, cal_id: int, resource: str, db: Session) -> Response:
if request.method.upper() == "OPTIONS":
return _handle_options()
user = _basic_auth_user(request, db)
if not user:
return _unauthorized()
cal = (
db.query(models.LocalCalendar)
.filter(
models.LocalCalendar.id == cal_id,
models.LocalCalendar.user_id == user.id,
models.LocalCalendar.caldav_published == True, # noqa: E712
)
.first()
)
if not cal:
return Response(status_code=404)
base = f"/caldav/{cal.id}/"
return await _dispatch_collection(request, cal, base, resource, db,
principal_href=_LOGIN_HREF, home_href=_LOGIN_HREF)
@router.api_route("/.well-known/caldav", methods=["OPTIONS", "GET", "PROPFIND"], include_in_schema=False)
async def wellknown_caldav(request: Request):
if request.method.upper() == "OPTIONS":
return _handle_options()
# Point discovery at the principal/home collection.
return RedirectResponse(url=_LOGIN_HREF, status_code=301)
@router.api_route("/caldav", methods=_METHODS, include_in_schema=False)
@router.api_route("/caldav/", methods=_METHODS, include_in_schema=False)
async def caldav_home(request: Request, db: Session = Depends(get_db)):
return await _dispatch_home(request, db)
@router.api_route("/caldav/{cal_id:int}", methods=_METHODS, include_in_schema=False)
async def caldav_calendar(cal_id: int, request: Request, db: Session = Depends(get_db)):
return await _dispatch_auth_calendar(request, cal_id, "", db)
@router.api_route("/caldav/{cal_id:int}/{resource:path}", methods=_METHODS, include_in_schema=False)
async def caldav_calendar_resource(cal_id: int, resource: str, request: Request, db: Session = Depends(get_db)):
return await _dispatch_auth_calendar(request, cal_id, resource, db)

View File

@@ -81,9 +81,10 @@ def _cal_dict(cal: models.LocalCalendar, *, owned: bool = True,
"type": "local",
"owned": owned,
}
# Only the owner may publish; expose the subscribe URL only when active.
# Only the owner may publish; expose the subscribe URLs only when active.
if owned and cal.caldav_published and cal.dav_token:
d["caldav_url"] = dav_util.caldav_url(request, cal.dav_token) if request else None
d["caldav_login_url"] = dav_util.caldav_login_url(request) if request else None
if shared_by is not None:
d["shared_by"] = shared_by
if permission is not None:

View File

@@ -3471,6 +3471,12 @@ function renderCalendarTable() {
<button class="btn btn-ghost btn-sm ct-dav-rotate" data-ct-dav-id="${cal.id}">${t('caldav_token_rotate')}</button>
</div>
<div class="ct-dav-hint">${t('caldav_hint')}</div>
<div class="ct-dav-box">
<span class="ct-dav-label">${t('caldav_login_url')}</span>
<input class="ct-dav-url" type="text" readonly value="${escHtml(cal.caldav_login_url || '')}">
<button class="btn btn-ghost btn-sm ct-dav-copy">${t('copy')}</button>
</div>
<div class="ct-dav-hint">${t('caldav_login_hint')}</div>
</td></tr>`;
}
}

View File

@@ -117,12 +117,14 @@ const translations = {
copy: 'Kopieren',
caldav_publish: 'CalDAV veröffentlichen',
caldav_unpublish: 'CalDAV deaktivieren',
caldav_published_url: 'CalDAV-URL:',
caldav_published_url: 'CalDAV-URL (ohne Login):',
caldav_url_copied: 'CalDAV-URL kopiert',
caldav_token_rotate: 'Token neu generieren',
caldav_token_rotated: 'Neuer Token erzeugt alte Abos müssen neu eingerichtet werden',
caldav_rotate_confirm: 'Neuen Token erzeugen? Die bisherige URL wird ungültig und bestehende Abos müssen mit der neuen URL neu eingerichtet werden.',
caldav_hint: 'Jeder mit dieser URL kann diesen Kalender abonnieren und bearbeiten. Über CalDAV-fähige Clients (Apple Kalender, Thunderbird, DAVx5) einbinden.',
caldav_hint: 'Jeder mit dieser URL kann diesen Kalender abonnieren und bearbeiten kein Login nötig. Über CalDAV-fähige Clients (Apple Kalender, Thunderbird, DAVx5) einbinden.',
caldav_login_url: 'CalDAV-URL (mit Login):',
caldav_login_hint: 'Alternativ im Client ein „CalDAV-Konto" mit dieser Server-URL sowie deinem Benutzernamen und Passwort hinzufügen dann werden alle deine veröffentlichten Kalender gefunden.',
share: 'Teilen',
import: 'Importieren',
export: 'Exportieren',
@@ -419,12 +421,14 @@ const translations = {
copy: 'Copy',
caldav_publish: 'Publish via CalDAV',
caldav_unpublish: 'Unpublish CalDAV',
caldav_published_url: 'CalDAV URL:',
caldav_published_url: 'CalDAV URL (no login):',
caldav_url_copied: 'CalDAV URL copied',
caldav_token_rotate: 'Regenerate token',
caldav_token_rotated: 'New token generated existing subscriptions must be re-added',
caldav_rotate_confirm: 'Generate a new token? The current URL will stop working and existing subscriptions must be re-added with the new URL.',
caldav_hint: 'Anyone with this URL can subscribe to and edit this calendar. Add it in a CalDAV-capable client (Apple Calendar, Thunderbird, DAVx5).',
caldav_hint: 'Anyone with this URL can subscribe to and edit this calendar — no login required. Add it in a CalDAV-capable client (Apple Calendar, Thunderbird, DAVx5).',
caldav_login_url: 'CalDAV URL (with login):',
caldav_login_hint: 'Alternatively add a "CalDAV account" in your client using this server URL plus your username and password — it will discover all your published calendars.',
share: 'Share',
import: 'Import',
export: 'Export',

View File

@@ -1,2 +1,2 @@
// Increment APP_VERSION with every code change
export const APP_VERSION = 'v66';
export const APP_VERSION = 'v67';