From fb32f0424f369cffe2936172d1a854a41ce3d9cf Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 1 Jul 2026 12:44:16 +0200 Subject: [PATCH] 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 --- backend/dav_util.py | 28 ++- backend/routers/dav_router.py | 297 +++++++++++++++++++++++--------- backend/routers/local_router.py | 3 +- frontend/js/calendar.js | 6 + frontend/js/i18n.js | 12 +- frontend/js/version.js | 2 +- 6 files changed, 258 insertions(+), 90 deletions(-) diff --git a/backend/dav_util.py b/backend/dav_util.py index e9a7e82..a297e5e 100644 --- a/backend/dav_util.py +++ b/backend/dav_util.py @@ -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/" diff --git a/backend/routers/dav_router.py b/backend/routers/dav_router.py index 6e4285b..b846099 100644 --- a/backend/routers/dav_router.py +++ b/backend/routers/dav_router.py @@ -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""" - {href} + {base} @@ -113,6 +158,24 @@ def _collection_propstat(cal: models.LocalCalendar, token: str) -> str: {xml_escape(cal.color or "#34a853")} + {principal_href} + {principal_href} + {home_href} + + HTTP/1.1 200 OK + + """ + + +def _principal_propstat(user: models.User) -> str: + href = _LOGIN_HREF + name = user.display_name or user.username + return f""" + {href} + + + + {xml_escape(name)} {href} {href} {href} @@ -122,9 +185,9 @@ def _collection_propstat(cal: models.LocalCalendar, token: str) -> str: """ -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 {xml_escape(ics)}" @@ -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) diff --git a/backend/routers/local_router.py b/backend/routers/local_router.py index 3f3be28..b5fe0e4 100644 --- a/backend/routers/local_router.py +++ b/backend/routers/local_router.py @@ -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: diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index dcc6017..01eac7f 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -3471,6 +3471,12 @@ function renderCalendarTable() {
${t('caldav_hint')}
+
+ ${t('caldav_login_url')} + + +
+
${t('caldav_login_hint')}
`; } } diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js index 1d2a46d..9164424 100644 --- a/frontend/js/i18n.js +++ b/frontend/js/i18n.js @@ -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', diff --git a/frontend/js/version.js b/frontend/js/version.js index 7f1dbfc..4fd4600 100644 --- a/frontend/js/version.js +++ b/frontend/js/version.js @@ -1,2 +1,2 @@ // Increment APP_VERSION with every code change -export const APP_VERSION = 'v66'; +export const APP_VERSION = 'v67';