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:
@@ -9,6 +9,7 @@ token revokes existing subscriptions.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
@@ -35,7 +36,28 @@ def bump_dav(cal, event=None) -> None:
|
|||||||
event.etag = new_tag()
|
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:
|
def caldav_url(request, token: str) -> str:
|
||||||
"""Absolute CalDAV collection URL for a token, based on the request origin."""
|
"""Absolute per-calendar CalDAV collection URL (secret token, no login)."""
|
||||||
base = str(request.base_url).rstrip("/")
|
return f"{public_base(request)}/dav/{token}/"
|
||||||
return f"{base}/dav/{token}/"
|
|
||||||
|
|
||||||
|
def caldav_login_url(request) -> str:
|
||||||
|
"""Absolute discovery URL for username/password (Basic Auth) CalDAV access."""
|
||||||
|
return f"{public_base(request)}/caldav/"
|
||||||
|
|||||||
@@ -1,30 +1,37 @@
|
|||||||
"""Minimal two-way CalDAV server for published local calendars.
|
"""Minimal two-way CalDAV server for published local calendars.
|
||||||
|
|
||||||
Each published local calendar is reachable as a CalDAV collection at
|
Two ways to reach a published local calendar:
|
||||||
``/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.
|
|
||||||
|
|
||||||
Reuses ``ical_io.build_ics`` / ``parse_ics`` for (de)serialisation. Note:
|
1. Secret token URL (no login) — ``/dav/{token}/``.
|
||||||
VALARM/reminders are not round-tripped (parse_ics ignores them).
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
import uuid
|
import uuid
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from urllib.parse import quote, unquote
|
from urllib.parse import quote, unquote
|
||||||
from xml.sax.saxutils import escape as xml_escape
|
from xml.sax.saxutils import escape as xml_escape
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import RedirectResponse, Response
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
import dav_util
|
import dav_util
|
||||||
import ical_io
|
import ical_io
|
||||||
import models
|
import models
|
||||||
|
from auth import verify_password
|
||||||
from database import get_db
|
from database import get_db
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -44,6 +51,7 @@ _NS_DECL = (
|
|||||||
|
|
||||||
_ALLOW = "OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, REPORT"
|
_ALLOW = "OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, REPORT"
|
||||||
_MULTISTATUS_CT = "application/xml; charset=utf-8"
|
_MULTISTATUS_CT = "application/xml; charset=utf-8"
|
||||||
|
_LOGIN_HREF = "/caldav/"
|
||||||
|
|
||||||
|
|
||||||
# ── Helpers ───────────────────────────────────────────────
|
# ── 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]:
|
def _events(cal: models.LocalCalendar, db: Session) -> list[models.LocalEvent]:
|
||||||
return (
|
return (
|
||||||
db.query(models.LocalEvent)
|
db.query(models.LocalEvent)
|
||||||
@@ -77,12 +123,8 @@ def _resource_name(ev: models.LocalEvent) -> str:
|
|||||||
return f"{quote(ev.uid, safe='')}.ics"
|
return f"{quote(ev.uid, safe='')}.ics"
|
||||||
|
|
||||||
|
|
||||||
def _collection_href(token: str) -> str:
|
def _event_href(base: str, ev: models.LocalEvent) -> str:
|
||||||
return f"/dav/{token}/"
|
return f"{base}{_resource_name(ev)}"
|
||||||
|
|
||||||
|
|
||||||
def _event_href(token: str, ev: models.LocalEvent) -> str:
|
|
||||||
return f"/dav/{token}/{_resource_name(ev)}"
|
|
||||||
|
|
||||||
|
|
||||||
def _name_cache(cal: models.LocalCalendar, db: Session) -> dict:
|
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 ──────────────────────────────────────────
|
# ── XML builders ──────────────────────────────────────────
|
||||||
|
|
||||||
def _collection_propstat(cal: models.LocalCalendar, token: str) -> str:
|
def _collection_propstat(cal: models.LocalCalendar, base: str, *,
|
||||||
href = _collection_href(token)
|
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>
|
return f""" <D:response>
|
||||||
<D:href>{href}</D:href>
|
<D:href>{base}</D:href>
|
||||||
<D:propstat>
|
<D:propstat>
|
||||||
<D:prop>
|
<D:prop>
|
||||||
<D:resourcetype><D:collection/><C:calendar/></D:resourcetype>
|
<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>
|
</D:supported-report-set>
|
||||||
<C:supported-calendar-component-set><C:comp name="VEVENT"/></C:supported-calendar-component-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>
|
<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:current-user-principal><D:href>{href}</D:href></D:current-user-principal>
|
||||||
<D:principal-URL><D:href>{href}</D:href></D:principal-URL>
|
<D:principal-URL><D:href>{href}</D:href></D:principal-URL>
|
||||||
<C:calendar-home-set><D:href>{href}</D:href></C:calendar-home-set>
|
<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>"""
|
</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:
|
ics: str | None = None) -> str:
|
||||||
href = _event_href(token, ev)
|
href = _event_href(base, ev)
|
||||||
data = ""
|
data = ""
|
||||||
if with_data and ics is not None:
|
if with_data and ics is not None:
|
||||||
data = f"\n <C:calendar-data>{xml_escape(ics)}</C:calendar-data>"
|
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)
|
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:
|
def _handle_options() -> Response:
|
||||||
return Response(status_code=200, headers={
|
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:
|
def _find_event(cal: models.LocalCalendar, resource: str, db: Session) -> models.LocalEvent | None:
|
||||||
name = resource.rsplit("/", 1)[-1]
|
name = resource.rsplit("/", 1)[-1]
|
||||||
if name.endswith(".ics"):
|
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,
|
def _handle_get(cal: models.LocalCalendar, resource: str, db: Session,
|
||||||
*, head: bool = False) -> Response:
|
*, head: bool = False) -> Response:
|
||||||
ev = _find_event(cal, resource, db)
|
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)
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
|
||||||
# ── Dispatch ──────────────────────────────────────────────
|
async def _dispatch_collection(request: Request, cal: models.LocalCalendar, base: str,
|
||||||
|
resource: str, db: Session, *,
|
||||||
async def _dispatch(request: Request, token: str, resource: str, db: Session) -> Response:
|
principal_href: str | None = None,
|
||||||
|
home_href: str | None = None) -> Response:
|
||||||
|
"""Serve a single calendar collection; auth/ownership already checked."""
|
||||||
method = request.method.upper()
|
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":
|
if method == "PROPFIND":
|
||||||
depth = request.headers.get("Depth", "0")
|
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":
|
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 method in ("GET", "HEAD"):
|
||||||
if not resource:
|
if not resource:
|
||||||
# A bare collection GET: hand back the whole calendar as one .ics.
|
|
||||||
ics = _build_ics(cal, _events(cal, db), db)
|
ics = _build_ics(cal, _events(cal, db), db)
|
||||||
return Response(content=ics, media_type="text/calendar; charset=utf-8")
|
return Response(content=ics, media_type="text/calendar; charset=utf-8")
|
||||||
return _handle_get(cal, resource, db, head=(method == "HEAD"))
|
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})
|
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"]
|
_METHODS = ["OPTIONS", "GET", "HEAD", "PUT", "DELETE", "PROPFIND", "REPORT"]
|
||||||
|
|
||||||
|
|
||||||
@router.api_route("/dav/{token}", methods=_METHODS, include_in_schema=False)
|
@router.api_route("/dav/{token}", methods=_METHODS, include_in_schema=False)
|
||||||
async def dav_collection(token: str, request: Request, db: Session = Depends(get_db)):
|
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)
|
@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)):
|
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)
|
||||||
|
|||||||
@@ -81,9 +81,10 @@ def _cal_dict(cal: models.LocalCalendar, *, owned: bool = True,
|
|||||||
"type": "local",
|
"type": "local",
|
||||||
"owned": owned,
|
"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:
|
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_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:
|
if shared_by is not None:
|
||||||
d["shared_by"] = shared_by
|
d["shared_by"] = shared_by
|
||||||
if permission is not None:
|
if permission is not None:
|
||||||
|
|||||||
@@ -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>
|
<button class="btn btn-ghost btn-sm ct-dav-rotate" data-ct-dav-id="${cal.id}">${t('caldav_token_rotate')}</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="ct-dav-hint">${t('caldav_hint')}</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>`;
|
</td></tr>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,12 +117,14 @@ const translations = {
|
|||||||
copy: 'Kopieren',
|
copy: 'Kopieren',
|
||||||
caldav_publish: 'CalDAV veröffentlichen',
|
caldav_publish: 'CalDAV veröffentlichen',
|
||||||
caldav_unpublish: 'CalDAV deaktivieren',
|
caldav_unpublish: 'CalDAV deaktivieren',
|
||||||
caldav_published_url: 'CalDAV-URL:',
|
caldav_published_url: 'CalDAV-URL (ohne Login):',
|
||||||
caldav_url_copied: 'CalDAV-URL kopiert',
|
caldav_url_copied: 'CalDAV-URL kopiert',
|
||||||
caldav_token_rotate: 'Token neu generieren',
|
caldav_token_rotate: 'Token neu generieren',
|
||||||
caldav_token_rotated: 'Neuer Token erzeugt – alte Abos müssen neu eingerichtet werden',
|
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_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',
|
share: 'Teilen',
|
||||||
import: 'Importieren',
|
import: 'Importieren',
|
||||||
export: 'Exportieren',
|
export: 'Exportieren',
|
||||||
@@ -419,12 +421,14 @@ const translations = {
|
|||||||
copy: 'Copy',
|
copy: 'Copy',
|
||||||
caldav_publish: 'Publish via CalDAV',
|
caldav_publish: 'Publish via CalDAV',
|
||||||
caldav_unpublish: 'Unpublish CalDAV',
|
caldav_unpublish: 'Unpublish CalDAV',
|
||||||
caldav_published_url: 'CalDAV URL:',
|
caldav_published_url: 'CalDAV URL (no login):',
|
||||||
caldav_url_copied: 'CalDAV URL copied',
|
caldav_url_copied: 'CalDAV URL copied',
|
||||||
caldav_token_rotate: 'Regenerate token',
|
caldav_token_rotate: 'Regenerate token',
|
||||||
caldav_token_rotated: 'New token generated – existing subscriptions must be re-added',
|
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_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',
|
share: 'Share',
|
||||||
import: 'Import',
|
import: 'Import',
|
||||||
export: 'Export',
|
export: 'Export',
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
// Increment APP_VERSION with every code change
|
// Increment APP_VERSION with every code change
|
||||||
export const APP_VERSION = 'v66';
|
export const APP_VERSION = 'v67';
|
||||||
|
|||||||
Reference in New Issue
Block a user