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';