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:
Scarriffle
2026-07-01 10:19:01 +02:00
parent a591eb51d3
commit 47944ed952
9 changed files with 530 additions and 6 deletions

View File

@@ -124,6 +124,12 @@ class LocalCalendar(Base):
enabled = Column(Boolean, default=True)
# Whether events of this calendar generate reminders/notifications on clients.
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")
events = relationship("LocalEvent", back_populates="calendar", cascade="all, delete-orphan")
@@ -152,6 +158,8 @@ class LocalEvent(Base):
creator_name_external = Column(Text, nullable=True)
# Private events are filtered for other group members per their visibility setting.
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")
creator = relationship("User")