feat: publish local calendars via two-way CalDAV (opt-in, secret token URL)
Backend:
- LocalCalendar gains caldav_published + dav_token + dav_ctag; LocalEvent gains
etag (migrations in main.py). bump_dav() refreshes ctag/etag on every local
event write (create/update/delete/import).
- local_router: PUT /calendars/{id} accepts caldav_published (mints/revokes
token), new POST /calendars/{id}/dav-token/rotate, _cal_dict exposes
caldav_published + caldav_url.
- New dav_router mounted at root (/dav/{token}/...): a minimal two-way CalDAV
server (OPTIONS/PROPFIND/REPORT/GET/PUT/DELETE) reusing ical_io build/parse,
ctag-based change detection. Secret token = auth, no login.
Frontend:
- Settings calendar table: per-local-calendar publish toggle + subscribe URL
with copy and token-rotate; i18n (de/en) and styling.
Note: reverse proxy must allow WebDAV methods (PROPFIND/REPORT/PUT/DELETE).
VALARM/reminders are not round-tripped via CalDAV.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
41
backend/dav_util.py
Normal file
41
backend/dav_util.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"""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 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 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}/"
|
||||||
@@ -17,7 +17,7 @@ STATIC_CACHE = f"public, max-age={STATIC_MAX_AGE_SECONDS}, must-revalidate"
|
|||||||
sys.path.insert(0, str(Path(__file__).parent))
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
from database import Base, engine
|
from database import Base, engine
|
||||||
from routers import auth_router, caldav_router, google_router, groups_router, homeassistant_router, ical_router, local_router, profile_router, settings_router, users_router
|
from routers import auth_router, caldav_router, dav_router, google_router, groups_router, homeassistant_router, ical_router, local_router, profile_router, settings_router, users_router
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
@@ -225,6 +225,25 @@ def _migrate():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# CalDAV publishing of local calendars (opt-in, secret token URL).
|
||||||
|
for col, ddl in (
|
||||||
|
("caldav_published", "ALTER TABLE local_calendars ADD COLUMN caldav_published BOOLEAN DEFAULT 0"),
|
||||||
|
("dav_token", "ALTER TABLE local_calendars ADD COLUMN dav_token VARCHAR(64)"),
|
||||||
|
("dav_ctag", "ALTER TABLE local_calendars ADD COLUMN dav_ctag VARCHAR(32)"),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
conn.execute(text(ddl))
|
||||||
|
conn.commit()
|
||||||
|
logging.info("Migration: added %s to local_calendars", col)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute(text("ALTER TABLE local_events ADD COLUMN etag VARCHAR(32)"))
|
||||||
|
conn.commit()
|
||||||
|
logging.info("Migration: added etag to local_events")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
_migrate()
|
_migrate()
|
||||||
|
|
||||||
app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None)
|
app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None)
|
||||||
@@ -272,6 +291,9 @@ app.include_router(groups_router.router, prefix="/api/groups", tags=["groups"])
|
|||||||
app.include_router(ical_router.router, prefix="/api/ical", tags=["ical"])
|
app.include_router(ical_router.router, prefix="/api/ical", tags=["ical"])
|
||||||
app.include_router(google_router.router, prefix="/api/google", tags=["google"])
|
app.include_router(google_router.router, prefix="/api/google", tags=["google"])
|
||||||
app.include_router(homeassistant_router.router, prefix="/api/homeassistant", tags=["homeassistant"])
|
app.include_router(homeassistant_router.router, prefix="/api/homeassistant", tags=["homeassistant"])
|
||||||
|
# CalDAV publishing lives at root scope (no /api prefix) and must be registered
|
||||||
|
# before the SPA catch-all so /dav/... isn't swallowed by the index fallback.
|
||||||
|
app.include_router(dav_router.router, tags=["dav"])
|
||||||
|
|
||||||
FRONTEND_DIR = Path(__file__).parent.parent / "frontend"
|
FRONTEND_DIR = Path(__file__).parent.parent / "frontend"
|
||||||
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
|
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
|
||||||
|
|||||||
@@ -124,6 +124,12 @@ class LocalCalendar(Base):
|
|||||||
enabled = Column(Boolean, default=True)
|
enabled = Column(Boolean, default=True)
|
||||||
# Whether events of this calendar generate reminders/notifications on clients.
|
# Whether events of this calendar generate reminders/notifications on clients.
|
||||||
reminders_enabled = Column(Boolean, default=True, nullable=False)
|
reminders_enabled = Column(Boolean, default=True, nullable=False)
|
||||||
|
# CalDAV publishing (opt-in): expose this calendar as a two-way CalDAV
|
||||||
|
# collection reachable via a secret token URL. dav_ctag changes on every
|
||||||
|
# event write so clients detect changes; rotating the token revokes access.
|
||||||
|
caldav_published = Column(Boolean, default=False, nullable=False)
|
||||||
|
dav_token = Column(String(64), nullable=True, unique=True)
|
||||||
|
dav_ctag = Column(String(32), nullable=True)
|
||||||
|
|
||||||
user = relationship("User", back_populates="local_calendars")
|
user = relationship("User", back_populates="local_calendars")
|
||||||
events = relationship("LocalEvent", back_populates="calendar", cascade="all, delete-orphan")
|
events = relationship("LocalEvent", back_populates="calendar", cascade="all, delete-orphan")
|
||||||
@@ -152,6 +158,8 @@ class LocalEvent(Base):
|
|||||||
creator_name_external = Column(Text, nullable=True)
|
creator_name_external = Column(Text, nullable=True)
|
||||||
# Private events are filtered for other group members per their visibility setting.
|
# Private events are filtered for other group members per their visibility setting.
|
||||||
is_private = Column(Boolean, default=False)
|
is_private = Column(Boolean, default=False)
|
||||||
|
# CalDAV entity tag — changes on every write so CalDAV clients detect updates.
|
||||||
|
etag = Column(String(32), nullable=True)
|
||||||
|
|
||||||
calendar = relationship("LocalCalendar", back_populates="events")
|
calendar = relationship("LocalCalendar", back_populates="events")
|
||||||
creator = relationship("User")
|
creator = relationship("User")
|
||||||
|
|||||||
322
backend/routers/dav_router.py
Normal file
322
backend/routers/dav_router.py
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
"""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.
|
||||||
|
|
||||||
|
Reuses ``ical_io.build_ics`` / ``parse_ics`` for (de)serialisation. Note:
|
||||||
|
VALARM/reminders are not round-tripped (parse_ics ignores them).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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 sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
import dav_util
|
||||||
|
import ical_io
|
||||||
|
import models
|
||||||
|
from database import get_db
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# XML namespaces used across WebDAV / CalDAV.
|
||||||
|
NS_DAV = "DAV:"
|
||||||
|
NS_CAL = "urn:ietf:params:xml:ns:caldav"
|
||||||
|
NS_CS = "http://calendarserver.org/ns/"
|
||||||
|
NS_ICAL = "http://apple.com/ns/ical/"
|
||||||
|
|
||||||
|
_NS_DECL = (
|
||||||
|
'xmlns:D="DAV:" '
|
||||||
|
'xmlns:C="urn:ietf:params:xml:ns:caldav" '
|
||||||
|
'xmlns:CS="http://calendarserver.org/ns/" '
|
||||||
|
'xmlns:ICAL="http://apple.com/ns/ical/"'
|
||||||
|
)
|
||||||
|
|
||||||
|
_ALLOW = "OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, REPORT"
|
||||||
|
_MULTISTATUS_CT = "application/xml; charset=utf-8"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _resolve(token: str, db: Session) -> models.LocalCalendar | None:
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
db.query(models.LocalCalendar)
|
||||||
|
.filter(
|
||||||
|
models.LocalCalendar.dav_token == token,
|
||||||
|
models.LocalCalendar.caldav_published == True, # noqa: E712
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _events(cal: models.LocalCalendar, db: Session) -> list[models.LocalEvent]:
|
||||||
|
return (
|
||||||
|
db.query(models.LocalEvent)
|
||||||
|
.filter(models.LocalEvent.calendar_id == cal.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _etag(ev: models.LocalEvent) -> str:
|
||||||
|
return ev.etag or "0"
|
||||||
|
|
||||||
|
|
||||||
|
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 _name_cache(cal: models.LocalCalendar, db: Session) -> dict:
|
||||||
|
owner = db.query(models.User).filter(models.User.id == cal.user_id).first()
|
||||||
|
if owner:
|
||||||
|
return {owner.id: (owner.display_name or owner.username)}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_ics(cal: models.LocalCalendar, evs: list[models.LocalEvent], db: Session) -> str:
|
||||||
|
return ical_io.build_ics(cal, evs, name_cache=_name_cache(cal, db))
|
||||||
|
|
||||||
|
|
||||||
|
# ── XML builders ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def _collection_propstat(cal: models.LocalCalendar, token: str) -> str:
|
||||||
|
href = _collection_href(token)
|
||||||
|
return f""" <D:response>
|
||||||
|
<D:href>{href}</D:href>
|
||||||
|
<D:propstat>
|
||||||
|
<D:prop>
|
||||||
|
<D:resourcetype><D:collection/><C:calendar/></D:resourcetype>
|
||||||
|
<D:displayname>{xml_escape(cal.name or "")}</D:displayname>
|
||||||
|
<CS:getctag>{xml_escape(cal.dav_ctag or "0")}</CS:getctag>
|
||||||
|
<D:supported-report-set>
|
||||||
|
<D:supported-report><D:report><C:calendar-query/></D:report></D:supported-report>
|
||||||
|
<D:supported-report><D:report><C:calendar-multiget/></D:report></D:supported-report>
|
||||||
|
</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>{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>
|
||||||
|
</D:prop>
|
||||||
|
<D:status>HTTP/1.1 200 OK</D:status>
|
||||||
|
</D:propstat>
|
||||||
|
</D:response>"""
|
||||||
|
|
||||||
|
|
||||||
|
def _event_propstat(token: str, ev: models.LocalEvent, *, with_data: bool = False,
|
||||||
|
ics: str | None = None) -> str:
|
||||||
|
href = _event_href(token, ev)
|
||||||
|
data = ""
|
||||||
|
if with_data and ics is not None:
|
||||||
|
data = f"\n <C:calendar-data>{xml_escape(ics)}</C:calendar-data>"
|
||||||
|
return f""" <D:response>
|
||||||
|
<D:href>{href}</D:href>
|
||||||
|
<D:propstat>
|
||||||
|
<D:prop>
|
||||||
|
<D:resourcetype/>
|
||||||
|
<D:getetag>"{xml_escape(_etag(ev))}"</D:getetag>
|
||||||
|
<D:getcontenttype>text/calendar; charset=utf-8; component=VEVENT</D:getcontenttype>{data}
|
||||||
|
</D:prop>
|
||||||
|
<D:status>HTTP/1.1 200 OK</D:status>
|
||||||
|
</D:propstat>
|
||||||
|
</D:response>"""
|
||||||
|
|
||||||
|
|
||||||
|
def _multistatus(body: str) -> Response:
|
||||||
|
xml = f'<?xml version="1.0" encoding="utf-8"?>\n<D:multistatus {_NS_DECL}>\n{body}\n</D:multistatus>'
|
||||||
|
return Response(content=xml, status_code=207, media_type=_MULTISTATUS_CT)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Method handlers ───────────────────────────────────────
|
||||||
|
|
||||||
|
def _handle_options() -> Response:
|
||||||
|
return Response(status_code=200, headers={
|
||||||
|
"DAV": "1, 2, 3, calendar-access",
|
||||||
|
"Allow": _ALLOW,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
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"):
|
||||||
|
name = name[:-4]
|
||||||
|
uid = unquote(name)
|
||||||
|
return (
|
||||||
|
db.query(models.LocalEvent)
|
||||||
|
.filter(
|
||||||
|
models.LocalEvent.calendar_id == cal.id,
|
||||||
|
models.LocalEvent.uid == uid,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_get(cal: models.LocalCalendar, resource: str, db: Session,
|
||||||
|
*, head: bool = False) -> Response:
|
||||||
|
ev = _find_event(cal, resource, db)
|
||||||
|
if not ev:
|
||||||
|
return Response(status_code=404)
|
||||||
|
ics = _build_ics(cal, [ev], db)
|
||||||
|
headers = {"ETag": f'"{_etag(ev)}"'}
|
||||||
|
return Response(
|
||||||
|
content=b"" if head else ics,
|
||||||
|
media_type="text/calendar; charset=utf-8",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_put(cal: models.LocalCalendar, resource: str, body: bytes, db: Session) -> Response:
|
||||||
|
try:
|
||||||
|
parsed = ical_io.parse_ics(body)
|
||||||
|
except ValueError:
|
||||||
|
return Response(status_code=400)
|
||||||
|
items = parsed.get("events") or []
|
||||||
|
if not items:
|
||||||
|
return Response(status_code=400)
|
||||||
|
item = items[0]
|
||||||
|
|
||||||
|
# Key by the VEVENT UID; fall back to the resource name.
|
||||||
|
uid = item.get("uid")
|
||||||
|
if not uid:
|
||||||
|
name = resource.rsplit("/", 1)[-1]
|
||||||
|
uid = unquote(name[:-4] if name.endswith(".ics") else name) or str(uuid.uuid4())
|
||||||
|
|
||||||
|
ev = (
|
||||||
|
db.query(models.LocalEvent)
|
||||||
|
.filter(models.LocalEvent.uid == uid)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
created = ev is None
|
||||||
|
if created:
|
||||||
|
ev = models.LocalEvent(calendar_id=cal.id, uid=uid, creator_id=cal.user_id)
|
||||||
|
db.add(ev)
|
||||||
|
ev.title = item.get("title") or "(ohne Titel)"
|
||||||
|
ev.start = item["start"]
|
||||||
|
ev.end = item["end"]
|
||||||
|
ev.all_day = item.get("all_day", False)
|
||||||
|
ev.location = item.get("location")
|
||||||
|
ev.description = item.get("description")
|
||||||
|
ev.rrule = item.get("rrule")
|
||||||
|
ev.exdate = item.get("exdate")
|
||||||
|
dav_util.bump_dav(cal, ev)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(ev)
|
||||||
|
return Response(status_code=201 if created else 204, headers={"ETag": f'"{_etag(ev)}"'})
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_delete(cal: models.LocalCalendar, resource: str, db: Session) -> Response:
|
||||||
|
ev = _find_event(cal, resource, db)
|
||||||
|
if not ev:
|
||||||
|
return Response(status_code=404)
|
||||||
|
dav_util.bump_dav(cal)
|
||||||
|
db.delete(ev)
|
||||||
|
db.commit()
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Dispatch ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _dispatch(request: Request, token: str, resource: str, db: Session) -> Response:
|
||||||
|
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)
|
||||||
|
if method == "REPORT":
|
||||||
|
return _handle_report(cal, token, 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"))
|
||||||
|
if method == "PUT":
|
||||||
|
return _handle_put(cal, resource, await request.body(), db)
|
||||||
|
if method == "DELETE":
|
||||||
|
if not resource:
|
||||||
|
return Response(status_code=403) # don't delete the collection itself
|
||||||
|
return _handle_delete(cal, resource, db)
|
||||||
|
return Response(status_code=405, headers={"Allow": _ALLOW})
|
||||||
|
|
||||||
|
|
||||||
|
_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)
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
@@ -2,11 +2,12 @@ import uuid
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Form, HTTPException, Query, UploadFile, File
|
from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request, UploadFile, File
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
import dav_util
|
||||||
import ical_io
|
import ical_io
|
||||||
import models
|
import models
|
||||||
import permissions
|
import permissions
|
||||||
@@ -31,6 +32,7 @@ class CalendarUpdate(BaseModel):
|
|||||||
color: Optional[str] = None
|
color: Optional[str] = None
|
||||||
enabled: Optional[bool] = None
|
enabled: Optional[bool] = None
|
||||||
reminders_enabled: Optional[bool] = None
|
reminders_enabled: Optional[bool] = None
|
||||||
|
caldav_published: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
class EventCreate(BaseModel):
|
class EventCreate(BaseModel):
|
||||||
@@ -67,16 +69,21 @@ class ShareCreate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
def _cal_dict(cal: models.LocalCalendar, *, owned: bool = True,
|
def _cal_dict(cal: models.LocalCalendar, *, owned: bool = True,
|
||||||
shared_by: Optional[str] = None, permission: Optional[str] = None) -> dict:
|
shared_by: Optional[str] = None, permission: Optional[str] = None,
|
||||||
|
request: Optional[Request] = None) -> dict:
|
||||||
d = {
|
d = {
|
||||||
"id": cal.id,
|
"id": cal.id,
|
||||||
"name": cal.name,
|
"name": cal.name,
|
||||||
"color": cal.color,
|
"color": cal.color,
|
||||||
"enabled": cal.enabled,
|
"enabled": cal.enabled,
|
||||||
"reminders_enabled": bool(cal.reminders_enabled),
|
"reminders_enabled": bool(cal.reminders_enabled),
|
||||||
|
"caldav_published": bool(cal.caldav_published),
|
||||||
"type": "local",
|
"type": "local",
|
||||||
"owned": owned,
|
"owned": owned,
|
||||||
}
|
}
|
||||||
|
# Only the owner may publish; expose the subscribe URL 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
|
||||||
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:
|
||||||
@@ -92,6 +99,7 @@ def _event_dict(ev: models.LocalEvent, cal: models.LocalCalendar, db: Session) -
|
|||||||
|
|
||||||
@router.get("/calendars")
|
@router.get("/calendars")
|
||||||
def list_calendars(
|
def list_calendars(
|
||||||
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user),
|
current_user: models.User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
@@ -117,7 +125,7 @@ def list_calendars(
|
|||||||
)
|
)
|
||||||
result = []
|
result = []
|
||||||
for c in own:
|
for c in own:
|
||||||
d = _cal_dict(c, owned=True)
|
d = _cal_dict(c, owned=True, request=request)
|
||||||
if c.id in group_cal_map:
|
if c.id in group_cal_map:
|
||||||
d["group"] = True
|
d["group"] = True
|
||||||
d["shared_by"] = group_cal_map[c.id] # group name, for labelling
|
d["shared_by"] = group_cal_map[c.id] # group name, for labelling
|
||||||
@@ -182,6 +190,7 @@ def create_calendar(
|
|||||||
def update_calendar(
|
def update_calendar(
|
||||||
calendar_id: int,
|
calendar_id: int,
|
||||||
data: CalendarUpdate,
|
data: CalendarUpdate,
|
||||||
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user),
|
current_user: models.User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
@@ -203,8 +212,47 @@ def update_calendar(
|
|||||||
cal.enabled = data.enabled
|
cal.enabled = data.enabled
|
||||||
if data.reminders_enabled is not None:
|
if data.reminders_enabled is not None:
|
||||||
cal.reminders_enabled = data.reminders_enabled
|
cal.reminders_enabled = data.reminders_enabled
|
||||||
|
if data.caldav_published is not None:
|
||||||
|
cal.caldav_published = data.caldav_published
|
||||||
|
if data.caldav_published:
|
||||||
|
# First publish: mint a token + initial ctag so clients can sync.
|
||||||
|
if not cal.dav_token:
|
||||||
|
cal.dav_token = dav_util.new_token()
|
||||||
|
if not cal.dav_ctag:
|
||||||
|
cal.dav_ctag = dav_util.new_tag()
|
||||||
|
else:
|
||||||
|
# Unpublishing revokes access: drop the token so the URL 404s.
|
||||||
|
cal.dav_token = None
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True}
|
db.refresh(cal)
|
||||||
|
return _cal_dict(cal, owned=True, request=request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/calendars/{calendar_id}/dav-token/rotate")
|
||||||
|
def rotate_dav_token(
|
||||||
|
calendar_id: int,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Issue a fresh CalDAV token — the old subscribe URL stops working."""
|
||||||
|
cal = (
|
||||||
|
db.query(models.LocalCalendar)
|
||||||
|
.filter(
|
||||||
|
models.LocalCalendar.id == calendar_id,
|
||||||
|
models.LocalCalendar.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not cal:
|
||||||
|
raise HTTPException(404, "Calendar not found")
|
||||||
|
if not cal.caldav_published:
|
||||||
|
raise HTTPException(422, "Calendar is not published")
|
||||||
|
cal.dav_token = dav_util.new_token()
|
||||||
|
cal.dav_ctag = dav_util.new_tag()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(cal)
|
||||||
|
return _cal_dict(cal, owned=True, request=request)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/calendars/{calendar_id}")
|
@router.delete("/calendars/{calendar_id}")
|
||||||
@@ -257,6 +305,7 @@ def create_event(
|
|||||||
creator_id=current_user.id, # server-side, never from the client
|
creator_id=current_user.id, # server-side, never from the client
|
||||||
)
|
)
|
||||||
db.add(ev)
|
db.add(ev)
|
||||||
|
dav_util.bump_dav(cal, ev)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(ev)
|
db.refresh(ev)
|
||||||
return _event_dict(ev, cal, db)
|
return _event_dict(ev, cal, db)
|
||||||
@@ -305,6 +354,7 @@ def update_event(
|
|||||||
ev.exdate = ",".join(dates)
|
ev.exdate = ",".join(dates)
|
||||||
if data.reminders is not None:
|
if data.reminders is not None:
|
||||||
ev.reminders = ",".join(str(m) for m in data.reminders) if data.reminders else None
|
ev.reminders = ",".join(str(m) for m in data.reminders) if data.reminders else None
|
||||||
|
dav_util.bump_dav(ev.calendar, ev)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
@@ -316,6 +366,7 @@ def delete_event(
|
|||||||
current_user: models.User = Depends(get_current_user),
|
current_user: models.User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
ev = _writable_event(db, current_user, uid)
|
ev = _writable_event(db, current_user, uid)
|
||||||
|
dav_util.bump_dav(ev.calendar)
|
||||||
db.delete(ev)
|
db.delete(ev)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
@@ -441,9 +492,12 @@ def _import_ics_into(cal: models.LocalCalendar, raw: bytes, db: Session) -> dict
|
|||||||
rrule=item.get("rrule"),
|
rrule=item.get("rrule"),
|
||||||
exdate=item.get("exdate"),
|
exdate=item.get("exdate"),
|
||||||
creator_name_external=item.get("organizer"),
|
creator_name_external=item.get("organizer"),
|
||||||
|
etag=dav_util.new_tag(),
|
||||||
)
|
)
|
||||||
db.add(ev)
|
db.add(ev)
|
||||||
imported += 1
|
imported += 1
|
||||||
|
if imported:
|
||||||
|
dav_util.bump_dav(cal)
|
||||||
try:
|
try:
|
||||||
db.commit()
|
db.commit()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -1462,6 +1462,16 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
.ct-dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; margin-right: 5px; vertical-align: middle; flex-shrink: 0; }
|
.ct-dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; margin-right: 5px; vertical-align: middle; flex-shrink: 0; }
|
||||||
.ct-empty { color: var(--text-3); font-size: 13px; padding: 16px 0; text-align: center; }
|
.ct-empty { color: var(--text-3); font-size: 13px; padding: 16px 0; text-align: center; }
|
||||||
.ct-toggle { cursor: pointer; }
|
.ct-toggle { cursor: pointer; }
|
||||||
|
.ct-dav-toggle.on { color: var(--primary); }
|
||||||
|
.ct-dav-row td { padding-top: 4px; padding-bottom: 12px; }
|
||||||
|
.ct-dav-box { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.ct-dav-label { font-size: 12px; color: var(--text-3); }
|
||||||
|
.ct-dav-url {
|
||||||
|
flex: 1; min-width: 220px; font-size: 12px; padding: 5px 8px;
|
||||||
|
border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
background: var(--surface-2); color: var(--text-1);
|
||||||
|
}
|
||||||
|
.ct-dav-hint { font-size: 11px; color: var(--text-3); margin-top: 6px; max-width: 640px; }
|
||||||
.ct-eye, .ct-bell { opacity: .45; transition: opacity .15s; }
|
.ct-eye, .ct-bell { opacity: .45; transition: opacity .15s; }
|
||||||
.ct-eye[data-ct-visible="1"], .ct-bell[data-ct-on="1"] { opacity: 1; }
|
.ct-eye[data-ct-visible="1"], .ct-bell[data-ct-on="1"] { opacity: 1; }
|
||||||
.ct-eye:hover, .ct-bell:hover { opacity: 1; }
|
.ct-eye:hover, .ct-bell:hover { opacity: 1; }
|
||||||
|
|||||||
@@ -3447,6 +3447,7 @@ function renderCalendarTable() {
|
|||||||
if (cal.group) continue;
|
if (cal.group) continue;
|
||||||
const owned = cal.owned !== false;
|
const owned = cal.owned !== false;
|
||||||
const canWrite = owned || cal.permission === 'read_write';
|
const canWrite = owned || cal.permission === 'read_write';
|
||||||
|
const pub = !!cal.caldav_published;
|
||||||
rows += `<tr>
|
rows += `<tr>
|
||||||
<td>${dot(cal.color, '#34a853')}${escHtml(cal.name)}</td>
|
<td>${dot(cal.color, '#34a853')}${escHtml(cal.name)}</td>
|
||||||
<td class="ct-src">Lokal</td>
|
<td class="ct-src">Lokal</td>
|
||||||
@@ -3455,11 +3456,23 @@ function renderCalendarTable() {
|
|||||||
<td>
|
<td>
|
||||||
<button class="btn btn-ghost btn-sm" data-ct-export="local" data-ct-id="${cal.id}" data-ct-name="${escHtml(cal.name)}">Export</button>
|
<button class="btn btn-ghost btn-sm" data-ct-export="local" data-ct-id="${cal.id}" data-ct-name="${escHtml(cal.name)}">Export</button>
|
||||||
${canWrite ? `<button class="btn btn-ghost btn-sm" data-ct-import="local" data-ct-id="${cal.id}">Import</button>` : ''}
|
${canWrite ? `<button class="btn btn-ghost btn-sm" data-ct-import="local" data-ct-id="${cal.id}">Import</button>` : ''}
|
||||||
|
${owned ? `<button class="btn btn-ghost btn-sm ct-dav-toggle${pub ? ' on' : ''}" data-ct-dav-id="${cal.id}" data-ct-dav-on="${pub ? '1' : '0'}">${pub ? t('caldav_unpublish') : t('caldav_publish')}</button>` : ''}
|
||||||
</td>
|
</td>
|
||||||
<td>—</td>
|
<td>—</td>
|
||||||
<td>${owned ? `<button class="icon-btn mini-btn" data-ct-del="local" data-ct-id="${cal.id}">${TRASH}</button>` : ''}</td>
|
<td>${owned ? `<button class="icon-btn mini-btn" data-ct-del="local" data-ct-id="${cal.id}">${TRASH}</button>` : ''}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
rowCount++;
|
rowCount++;
|
||||||
|
if (owned && pub) {
|
||||||
|
rows += `<tr class="ct-dav-row"><td colspan="7">
|
||||||
|
<div class="ct-dav-box">
|
||||||
|
<span class="ct-dav-label">${t('caldav_published_url')}</span>
|
||||||
|
<input class="ct-dav-url" type="text" readonly value="${escHtml(cal.caldav_url || '')}">
|
||||||
|
<button class="btn btn-ghost btn-sm ct-dav-copy">${t('copy')}</button>
|
||||||
|
<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>
|
||||||
|
</td></tr>`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// iCal subscriptions
|
// iCal subscriptions
|
||||||
@@ -3656,6 +3669,42 @@ function renderCalendarTable() {
|
|||||||
btn.addEventListener('click', () => triggerIcsImport(parseInt(btn.dataset.ctId)));
|
btn.addEventListener('click', () => triggerIcsImport(parseInt(btn.dataset.ctId)));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// CalDAV publishing
|
||||||
|
container.querySelectorAll('.ct-dav-toggle').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const id = parseInt(btn.dataset.ctDavId);
|
||||||
|
const newVal = btn.dataset.ctDavOn !== '1';
|
||||||
|
const cal = state.localCalendars.find(c => c.id === id);
|
||||||
|
if (!cal) return;
|
||||||
|
try {
|
||||||
|
const updated = await api.put(`/local/calendars/${id}`, { caldav_published: newVal });
|
||||||
|
cal.caldav_published = updated.caldav_published;
|
||||||
|
cal.caldav_url = updated.caldav_url;
|
||||||
|
renderCalendarTable();
|
||||||
|
} catch (e) { showToast(e.message, true); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
container.querySelectorAll('.ct-dav-copy').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const input = btn.parentElement.querySelector('.ct-dav-url');
|
||||||
|
if (!input || !input.value) return;
|
||||||
|
navigator.clipboard.writeText(input.value).then(() => showToast(t('caldav_url_copied')));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
container.querySelectorAll('.ct-dav-rotate').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const id = parseInt(btn.dataset.ctDavId);
|
||||||
|
const cal = state.localCalendars.find(c => c.id === id);
|
||||||
|
if (!cal || !confirm(t('caldav_rotate_confirm'))) return;
|
||||||
|
try {
|
||||||
|
const updated = await api.post(`/local/calendars/${id}/dav-token/rotate`);
|
||||||
|
cal.caldav_url = updated.caldav_url;
|
||||||
|
renderCalendarTable();
|
||||||
|
showToast(t('caldav_token_rotated'));
|
||||||
|
} catch (e) { showToast(e.message, true); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Delete
|
// Delete
|
||||||
container.querySelectorAll('[data-ct-del]').forEach(btn => {
|
container.querySelectorAll('[data-ct-del]').forEach(btn => {
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
|
|||||||
@@ -114,6 +114,15 @@ const translations = {
|
|||||||
reminders_calendar_off: 'Für diesen Kalender sind Benachrichtigungen deaktiviert – Erinnerungen werden nicht ausgeführt.',
|
reminders_calendar_off: 'Für diesen Kalender sind Benachrichtigungen deaktiviert – Erinnerungen werden nicht ausgeführt.',
|
||||||
calendar_reminders_on: 'Benachrichtigungen aktiviert',
|
calendar_reminders_on: 'Benachrichtigungen aktiviert',
|
||||||
calendar_reminders_off: 'Benachrichtigungen deaktiviert',
|
calendar_reminders_off: 'Benachrichtigungen deaktiviert',
|
||||||
|
copy: 'Kopieren',
|
||||||
|
caldav_publish: 'CalDAV veröffentlichen',
|
||||||
|
caldav_unpublish: 'CalDAV deaktivieren',
|
||||||
|
caldav_published_url: 'CalDAV-URL:',
|
||||||
|
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.',
|
||||||
share: 'Teilen',
|
share: 'Teilen',
|
||||||
import: 'Importieren',
|
import: 'Importieren',
|
||||||
export: 'Exportieren',
|
export: 'Exportieren',
|
||||||
@@ -407,6 +416,15 @@ const translations = {
|
|||||||
reminders_calendar_off: 'Reminders are disabled for this calendar – they will not fire.',
|
reminders_calendar_off: 'Reminders are disabled for this calendar – they will not fire.',
|
||||||
calendar_reminders_on: 'Reminders enabled',
|
calendar_reminders_on: 'Reminders enabled',
|
||||||
calendar_reminders_off: 'Reminders disabled',
|
calendar_reminders_off: 'Reminders disabled',
|
||||||
|
copy: 'Copy',
|
||||||
|
caldav_publish: 'Publish via CalDAV',
|
||||||
|
caldav_unpublish: 'Unpublish CalDAV',
|
||||||
|
caldav_published_url: 'CalDAV URL:',
|
||||||
|
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).',
|
||||||
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 = 'v63';
|
export const APP_VERSION = 'v64';
|
||||||
|
|||||||
Reference in New Issue
Block a user