feat(calendar): Phase 1 – CalDAV lesend und schreibend, ein Kalender pro Nutzer

Neues, eigenständiges Modul internal/calendar: implementiert
caldav.Backend (emersion/go-webdav) gegen zwei neue Tabellen
(cal_calendars, cal_objects). Endpunkt /caldav/ mit eigener Basic Auth
(nur App-Passwörter, wie /dav), plus /.well-known/caldav für die
automatische Einrichtung nach RFC 6764.

Gegen echte Container getestet (nicht nur go build): Discovery-Kette
Principal -> Home-Set -> automatisch angelegter Standardkalender,
PUT/GET/DELETE eines Termins, REPORT mit Zeitraumfilter (Treffer und
Nicht-Treffer), If-Match/If-None-Match-Vorbedingungen (412), und dass
ein fremder Nutzer auf einen Kalender-Pfad mit 404 statt 403 abgewiesen
wird.

Zwei Fallen unterwegs gefunden und dokumentiert:
- go-webdav vergleicht von CurrentUserPrincipal/CalendarHomeSetPath
  zurückgegebene Pfade direkt gegen r.URL.Path (inklusive Prefix) und
  bestimmt den Ressourcentyp allein an der Segmentzahl dahinter – der
  Nutzername beim Principal steht deshalb in einem Segment
  (principals-max), nicht als Unterpfad.
- chi kennt REPORT/MKCALENDAR nicht von Haus aus (nur PROPFIND & Co.
  waren für /dav bereits registriert) und hätte sie sonst mit 405
  abgewiesen, bevor der CalDAV-Handler überhaupt lief.

Migration 000021 nur mit CREATE TABLE IF NOT EXISTS, wie in
kalender-plan.md festgelegt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Sonnet 5 committed 2026-09-03 22:27:33 +02:00
1 parent e44bed0a34
commit 87788817f8
11 files changed
+819 -1

No files matched your search

@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS cal_objects;
DROP TABLE IF EXISTS cal_calendars;
@@ -0,0 +1,52 @@
-- Kalender, Phase 1: ein Kalender pro Nutzer, CalDAV lesend und schreibend.
--
-- Nur CREATE TABLE, nichts an bestehenden Tabellen geändert. Siehe
-- kalender-plan.md, Abschnitt "Migrationen": eine fehlschlagende Migration
-- verhindert den Start der ganzen Cloud, deshalb hier ausschließlich die
-- risikoärmste Operation, die es gibt – idempotent mit IF NOT EXISTS.
CREATE TABLE IF NOT EXISTS cal_calendars (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
-- calendar-color (Apple-Namensraum, kein RFC, aber von Nextcloud,
-- Thunderbird und DAVx5 unterstützt) – siehe kalender-plan.md.
color TEXT NOT NULL DEFAULT '#4285f4',
description TEXT NOT NULL DEFAULT '',
-- Meldet der Kalender VTODO von Anfang an, muss ein Client sein Konto
-- nicht neu einrichten, wenn Aufgaben später dazukommen.
supports_todo BOOLEAN NOT NULL DEFAULT TRUE,
-- Aendert sich bei jeder Aenderung an einem Termin dieses Kalenders.
-- Daran erkennt der Client "hier gibt es etwas Neues", ohne jedes
-- einzelne Objekt abzufragen.
ctag BIGINT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS cal_objects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
calendar_id UUID NOT NULL REFERENCES cal_calendars(id) ON DELETE CASCADE,
-- UID aus der .ics-Datei – der Ausweis des Termins, bleibt beim
-- Verschieben/Aendern gleich. Eindeutig je Kalender, nicht global.
uid TEXT NOT NULL,
-- 'VEVENT' oder 'VTODO'. Fuer die Ansicht spaeter, nicht fuer CalDAV
-- selbst - der Client fragt nach Zeitraum, nicht nach Typ.
component TEXT NOT NULL,
-- Das vollstaendige .ics, byte-genau wie vom Client geschickt. Siehe
-- kalender-plan.md: nie zerlegen und neu zusammensetzen, sonst gehen
-- Felder verloren, die der Server nicht kennt (Anhaenge, Herstellerfelder).
data TEXT NOT NULL,
-- Aendert sich bei jedem Schreiben. Clients schicken es beim Aendern
-- mit ("nur wenn es noch das hier ist") - das loest gleichzeitiges
-- Schreiben zweier Geraete/Nutzer ohne zusaetzlichen Code.
etag TEXT NOT NULL,
-- Abgeleitet aus DTSTART/DTEND, nur zum Vorsortieren. Massgeblich fuer
-- den Zeitraumfilter bleibt "data" selbst (RRULE eingeschlossen).
dtstart TIMESTAMPTZ,
dtend TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (calendar_id, uid)
);
CREATE INDEX IF NOT EXISTS cal_objects_calendar_idx ON cal_objects(calendar_id);