merge: 12c CalDAV

This commit is contained in:
Jannis committed 2026-10-10 22:31:28 +02:00
commit 6f11f12c90
35 files changed
+6207 -17

No files matched your search

+18 -1
View File
@@ -124,8 +124,25 @@ unter **Verwaltung › Kalender**.
* **Google Kalender:** *Weitere Kalender „+“ › Per URL*.
Voraussetzungen: Die App muss für die Geräte per **HTTPS** mit gültigem Zertifikat erreichbar sein (z. B. Caddy-Profil `proxy`).
Abos sind nur lesend; Änderungen erfolgen in der App (bidirektionale Synchronisation: [ADR-0017](docs/adr/0017-caldav-und-graph-spaeter.md)).
Abos sind nur lesend; für Änderungen vom Handy aus gibt es die Zwei-Wege-Synchronisation (siehe nächster Abschnitt).
Der Feed-Endpunkt ist auf 60 Abrufe pro Minute und IP begrenzt (`RateLimiting__FeedPermitLimit`).
## Kalender mit dem Handy verbinden (CalDAV, Zwei-Wege)
Unter **Einstellungen › Kalender verbinden** (auch im Kalender-Menü: *Mit Handy verbinden*) erscheinen Server-Adresse
(`https://<Domain>/caldav/`) und Benutzername. Dort ein **App-Passwort** pro Gerät erstellen (wird nur einmal angezeigt, jederzeit
widerrufbar) und in der Kalender-App eintragen – **nie das normale Passwort**. Die Familienkalender erscheinen dann in
**iPhone/iPad/Mac** (*Account hinzufügen › Andere › CalDAV-Account*), **Thunderbird** (*Neuer Kalender › Im Netzwerk › CalDAV*) und
**Android** (App *DAVx5*); Termine, die dort angelegt oder geändert werden, landen im Familienplaner und umgekehrt. Es gelten dieselben
Rechte wie in der App (Kinder ändern nur eigene Termine, Gäste lesen nur).
* **HTTPS ist Pflicht** (`CALDAV_REQUIRE_HTTPS=true`, Standard): Kalender-Apps senden das App-Passwort bei jeder Abfrage per HTTP Basic.
Nur in einem reinen Heimnetz ohne Zertifikat kann es auf `false` gestellt werden (Klartext im LAN).
* Bei falschen Zugangsdaten sperrt der Server weitere Versuche kurz (429); iOS stellt beim ersten Abgleich viele Anfragen
(`RateLimiting__CalDavPermitLimit`, Standard 600 pro Minute und IP).
* **Outlook** kann kein CalDAV: dort bleibt nur das lesende ICS-Abo (Zwei-Wege-Abgleich mit Outlook wäre nur über Microsoft Graph möglich,
siehe Backlog). Nicht gespeichert werden Erinnerungen, Teilnehmer und Anhänge; Wiederholungen außerhalb der unterstützten Regeln
(z. B. stündlich) und Aufgaben werden vom Server abgelehnt. Details und Grenzen: [ADR-0017](docs/adr/0017-caldav-und-graph-spaeter.md).
**ICS-Import** (Schulferien, Müllabfuhr …): *Kalender › Filter-Menü › .ics importieren* bzw. *Verwaltung › Kalender*. Bereits importierte
Termine (gleiche UID) werden übersprungen; nicht unterstützte Wiederholungsregeln werden im Ergebnis genannt.
+4
View File
@@ -24,6 +24,10 @@ VAPID_SUBJECT=mailto:admin@example.org
# API-Schlüssel von developers.giphy.com. Es geht nur der Suchbegriff über den Server an Giphy (Altersstufe „g“). Leer = kein GIF-Button.
GIPHY_API_KEY=
# --- Kalender-Synchronisation (CalDAV) ------------------------------------------
# true (Standard): Kalender-Apps dürfen sich nur über HTTPS anmelden (App-Passwort wird bei jeder Abfrage gesendet). false nur im Heimnetz ohne Zertifikat.
CALDAV_REQUIRE_HTTPS=true
# --- Netzwerk -------------------------------------------------------------------
# Ohne Caddy: Port des Web-Containers auf dem Host (127.0.0.1 = nur lokal bzw. für eigenen Reverse Proxy).
WEB_BIND=127.0.0.1
+2
View File
@@ -70,6 +70,8 @@ services:
WebPush__Subject: ${VAPID_SUBJECT:-mailto:admin@localhost}
# GIF-Suche im Chat (optional, ADR-0022) – leer = GIF-Button ausgeblendet
Giphy__ApiKey: ${GIPHY_API_KEY:-}
# Kalender-Synchronisation (CalDAV, ADR-0017): Basic-Auth mit App-Passwort nur über HTTPS (true). false nur im reinen Heimnetz ohne Zertifikat.
CalDav__RequireHttps: ${CALDAV_REQUIRE_HTTPS:-true}
depends_on:
migrations:
condition: service_completed_successfully
+6 -3
View File
@@ -13,7 +13,7 @@ Kalender mit ICS-Abo, Chat). Lokal wird alles über **.NET Aspire** orchestriert
┌───────────────────────────────────────────────────────────────┐
│ FamilyPlanner.Web (BFF) │
│ • liefert WASM-App, Manifest, Service Worker (statisch) │
│ • YARP: /api/**, /hubs/**, /ics/** ──────────────┐ │
│ • YARP: /api/**, /hubs/**, /ics/**, /caldav/** ──┐ │
│ • Security-Header (CSP, HSTS …) │ │
└───────────────────────────────────────────────────┼───────────┘
▼
@@ -22,7 +22,7 @@ Kalender mit ICS-Abo, Chat). Lokal wird alles über **.NET Aspire** orchestriert
│ • Minimal APIs (Vertical Slices pro Feature) │
│ • ASP.NET Core Identity + Cookie-Auth, Policies │
│ • SignalR Hubs (Chat, Live-Updates) │
│ • ICS-Feeds (Token-gesichert), ICS-Import │
│ • ICS-Feeds (Token-gesichert), ICS-Import, CalDAV (/caldav) │
└───────────────────────────┬───────────────────────────────────┘
│ EF Core 10
▼
@@ -64,6 +64,7 @@ FamilyPlanner.Api/
Users/… # /api/admin/users/* (Phase 2)
Shopping/… # /api/shopping/* (Phase 3)
Meals/… Tasks/… Calendar/… Chat/… Dashboard/…
CalDav/… # /caldav/* (CalDAV-Server) + /api/caldav/* (App-Passwörter), Phase 12c
Hubs/ # SignalR
Infrastructure/ # Auth-Setup, Security, Rate Limiting
```
@@ -84,7 +85,7 @@ Alle Zeitpunkte werden als UTC (`DateTimeOffset`/`DateTime` UTC) gespeichert und
| Einkauf ✅ | `ShoppingList` (Name, IsDefault, SortOrder, Soft Delete), `ShoppingItem` (Client-Id, Name, Quantity, Category, IsDone, DoneAt, DoneBy, RowVersion, Soft Delete), `ShoppingSuggestion` (Name, Category, CategoryIsManual, UseCount, LastUsedAt). Die Items-DTOs liefern `CreatedByName` („hinzugefügt von …“); für Kinder prüft `ShoppingPlausibility` (Sonderzeichen/Ziffern, Zeichenwiederholung, Tastaturmuster, kleine Quatschliste) Neu-Einträge: verdächtige liefern 409 mit `needsConfirmation`, der Client fragt nach und sendet `Confirm = true` |
| Essen ✅ | `MealPlanEntry` (Date als `DateOnly`, MealType, DishName, Note, RecipeUrl, Ingredients, WishId, RowVersion, Soft Delete; eindeutig je Tag + Mahlzeit), `Dish` (Name, Note, RecipeUrl, Ingredients, UseCount = Planungen, WishCount, WishedBy, LastPlannedAt, Soft Delete; eindeutiger NormalizedName – dauerhafte Wunschliste und Autovervollständigung, `/api/meals/wishlist`), `MealWish` (DishName, Note, Wunschtag/-mahlzeit, Status offen/übernommen/abgelehnt, Soft Delete) |
| Aufgaben ✅ | `TodoTask` (Title, Notes, DueDate + DueTime → DueAt, ReminderMinutesBefore → RemindAt, Priority, Status inkl. „wartet auf Bestätigung“, Category, IsPrivate, IsChore, Points, NeedsApproval, AssignedToFamily, RecurrenceRule + RecurrenceMode, SeriesId/SeriesStart/SeriesIndex, CompletedBy/At, ApprovedBy/At, RowVersion, Soft Delete), `TaskAssignment`, `TaskChecklistItem`, `TaskHistoryEntry` (Verlauf), `PointsLedgerEntry` (Punktebuch). Kategorien kommen aus `FamilySettings.TaskCategories`; „wer/wann/genehmigt“ steht an der Aufgabe und im Verlauf (ADR-0014) |
| Kalender ✅ | `FamilyCalendar` (Name, Color, IsDefault, Soft Delete), `CalendarPermission` (Rolle **oder** Benutzer → None/Read/WriteOwn/Write), `CalendarEvent` (Title, Location, Notes, IsAllDay, StartUtc, EndUtc, TimeZoneId, RecurrenceRule, ExceptionDates, Uid, PersonIds, ForWholeFamily, Sequence, RowVersion, Soft Delete; Overrides als eigene Zeile mit `SeriesMasterId` + `RecurrenceId`), `IcsFeed` (Owner, Scope, CalendarId, TaskMode, TokenHash, TokenPrefix, LastAccessAt, RevokedAt) – ADR-0015/0016 |
| Kalender ✅ | `FamilyCalendar` (Name, Color, IsDefault, Soft Delete), `CalendarPermission` (Rolle **oder** Benutzer → None/Read/WriteOwn/Write), `CalendarEvent` (Title, Location, Notes, IsAllDay, StartUtc, EndUtc, TimeZoneId, RecurrenceRule, ExceptionDates, Uid, PersonIds, ForWholeFamily, Sequence, RowVersion, Soft Delete; Overrides als eigene Zeile mit `SeriesMasterId` + `RecurrenceId`), `IcsFeed` (Owner, Scope, CalendarId, TaskMode, TokenHash, TokenPrefix, LastAccessAt, RevokedAt), `CalDavCredential` (UserId, Label, SecretHash, SecretPrefix, LastUsedAt, RevokedAt; App-Passwort für CalDAV) – ADR-0015/0016/0017 |
| KI ✅ | `AiProviderConfig` (Name, Type Ollama/Claude/OpenAiCompatible, BaseUrl, Model, ApiKeyProtected, TimeoutSeconds, IsActive, AllowImages, AllowWebSearch, Purposes, DailyLimitPerUser, RowVersion, Soft Delete), `AiUsage` (Person, Anbieter, Zweck, Dauer, Tokens, Erfolg/Fehlerart – ohne Inhalt), `AiExchange` ✅ (12b: KI-Protokoll je Aufruf – Person, Anbieter, Modell, Zweck, Beginn, Dauer, Erfolg/Fehler, Bildanzahl, Online-Suche, Tokens, `ContentStored`, Prompt, Antwort; Inhalt des privaten KI-Chats nur mit `FamilySettings.AiLogChatContent`; Aufbewahrung `FamilySettings.AiLogRetentionDays`, Standard 90) – ADR-0020; Zwecke Standard, Lernaufgaben, Foto-Prüfung, KI-Chat (letztere beiden ohne Rückfall auf den Standard, ADR-0022) |
| Chat ✅ | `ChatChannel` (Family / Direct / Ai – privater KI-Kanal je Person, `DirectKey = ai:{userId}`), `ChatMember` (LastReadAt), `ChatMessage` (Text, HasImage; KI-Antworten mit fester Absender-Id `ChatRules.AiSenderId`, kein Benutzerkonto), `ChatAttachment` (PNG/JPEG/WebP; GIF nur aus der Server-GIF-Suche), `ChatReaction` – ADR-0019, ADR-0022 |
| Lernen ✅ (10a) | `LearningSet` (Titel, Fach Mathe/Deutsch, Klassenstufe 1–6, Zielkind, Fälligkeit, Status, Modus Übung/Prüfung, MaxAttempts, TimeLimitMinutes, ShowSolutionsAfterSubmit, ShuffleItems, TotalPoints, GradeThresholds, Source Manual/Ai + AiPrompt/AiProvider/AiModel (von 10b für KI-Entwürfe gesetzt, nie für Kinder sichtbar), RowVersion, Soft Delete), `LearningItem` (Frage, Antworttyp Number/Text/SingleChoice/Paper (Paper je Aufgabe oder für das ganze Blatt in einem Schritt, 12a), CorrectAnswer, NumberTolerance, Options, Hint, Explanation, Difficulty, Points, RowVersion, Soft Delete), `LearningAttempt` (Nummer, Status, StartedAt, DeadlineAt, SubmittedAt, AutoSubmitted, ItemOrder, Ergebnis/Note, RowVersion), `LearningAnswer` (Antwort, Verdict Open/Correct/Partial/Wrong, Grader Auto/Ai/Parent, AwardedPoints, Reason, AiConfidence, PhotoPurgedAt), `LearningPhoto` ✅ (10c: Foto der Papierlösung je Versuch und Aufgabe, Bytes in der Datenbank); `LearningSchedule` ✅ (11c: Zeitplan für automatische Blätter – Kind, Ersteller, Fach, Klasse, Anzahl, Startschwierigkeit + Steigerung, Wochentage/Uhrzeit, Direkt-Zuweisen, `HandledThroughAt`, Retry/Sperre, Soft Delete, RowVersion) und `LearningScheduleRun` (Protokoll ohne Inhalt, eindeutig je Plantermin); `LearningSet.ScheduleId`; `FamilySettings.LearningPhotoRetentionDays`; `PointsLedgerEntry.LearningSetId` – ADR-0021 |
@@ -139,6 +140,7 @@ Das UI bekommt die wirksamen Berechtigungen über `/api/auth/me` und blendet anh
* Login per Benutzername **oder** E-Mail, „Angemeldet bleiben“ (persistentes Cookie, 30 Tage gleitend), Lockout (5 Fehlversuche → 15 min), Rate Limiting (Fixed Window pro IP auf `/api/auth/login`).
* Seed-Admin aus Konfiguration (`Seed:AdminUserName`, `Seed:AdminPassword`, `Seed:AdminEmail`), `MustChangePassword = true`.
* Kalender-Abos (ICS) verwenden geheime Tokens in der URL (nur Hash in DB), da Kalender-Clients keine Cookies können (ADR-0005).
* CalDAV (`/caldav`, ADR-0017) verwendet **HTTP Basic mit App-Passwörtern** (`CalDavCredential`: je Benutzer/Gerät, einmalig angezeigt, nur SHA-256 in der DB, widerrufbar) über ein eigenes, nicht standardmäßiges Auth-Schema `CalDavBasic`: das Konto-Passwort wird nie akzeptiert, App-Passwörter öffnen kein `/api`, Cookies kein `/caldav`. Basic nur über HTTPS (`CalDav:RequireHttps`), Fehlversuche werden gedrosselt (429); Kalenderrechte werden pro Anfrage serverseitig geprüft.
## 6. API-Übersicht (Ziel, ✅ = umgesetzt)
@@ -156,6 +158,7 @@ Das UI bekommt die wirksamen Berechtigungen über `/api/auth/me` und blendet anh
| Kalender-Abos ✅ | `GET/POST /api/calendar/feeds`, `POST /api/calendar/feeds/{id}/regenerate`, `DELETE /api/calendar/feeds/{id}`; Admin: `GET/DELETE /api/admin/calendar-feeds` |
| Admin – Kalender ✅ | `GET/POST /api/admin/calendars`, `PUT/DELETE /api/admin/calendars/{id}` |
| ICS-Feed | `GET /ics/{token}.ics` (öffentlich, Token-gesichert, ETag/Last-Modified) |
| CalDAV ✅ | `/.well-known/caldav` → 301, `/caldav/**` (`OPTIONS`, `PROPFIND`, `PROPPATCH`, `REPORT` calendar-query/calendar-multiget/sync-collection, `GET`, `PUT`, `DELETE`; Basic mit App-Passwort), `GET /api/caldav/info`, `GET/POST /api/caldav/credentials`, `DELETE /api/caldav/credentials/{id}` (ADR-0017) |
| Chat ✅ | `GET /api/chat/channels`, `POST /api/chat/direct`, `GET /api/chat/channels/{id}/messages?before=&take=`, `POST …/messages`, `POST …/images` (Multipart), `POST …/read`, `DELETE /api/chat/messages/{id}`, `PUT /api/chat/messages/{id}/reactions`, `GET /api/chat/attachments/{id}` (ADR-0019); Erweiterungen (ADR-0022): `GET /api/chat/features`, `POST /api/chat/ai` (eigener KI-Kanal, get-or-create), `GET /api/chat/gifs?q=`, `GET /api/chat/gifs/{id}/preview`, `POST …/channels/{id}/gifs`; Konfiguration `Giphy__ApiKey` (leer = keine GIF-Suche) |
| KI ✅ | Admin: `GET/POST /api/admin/ai/providers`, `PUT/DELETE …/{id}`, `POST …/{id}/test`, `GET /api/admin/ai/usage`, KI-Protokoll (12b): `GET /api/admin/ai/exchanges?purpose=&success=&userId=&from=&to=&page=&pageSize=`, `GET …/exchanges/people`, `GET/DELETE …/exchanges/{id}`, `DELETE …/exchanges` (alle); Eltern: `GET /api/ai/status` (inkl. `LearningWebSearchAvailable` für „Online recherchieren“; ADR-0020) |
| Lernen ✅ (10a) | Eltern (`LearningManage`): `GET/POST /api/learning/sets`, `GET/PUT/DELETE …/sets/{id}`, `POST …/sets/{id}/assign`, `POST …/unassign`, `POST …/sets/{id}/items`, `PUT/DELETE …/items/{itemId}`, `POST …/items/order`, `GET /api/learning/review`, `GET /api/learning/attempts/{id}`, `PUT …/attempts/{id}/answers/{itemId}/review`, `POST …/attempts/{id}/confirm`, `POST …/attempts/{id}/reopen`; Kinder (`LearningSolve`): `GET /api/learning/my`, `POST …/my/{setId}/attempts`, `GET …/my/attempts/{id}`, `PUT …/my/attempts/{id}/answers/{itemId}`, `POST …/my/attempts/{id}/submit`; Fotos (10c): `POST/DELETE /api/learning/my/attempts/{id}/items/{itemId}/photo` (Kind), `GET /api/learning/attempts/{id}/items/{itemId}/photo` (Eltern nach Abgabe, Kind; sonst 404), `POST /api/learning/attempts/{id}/ai-grade` (`LearningManage` + `AiUse`, KI-Vorschlag für Fotos und unklare Zahlenantworten); Automatik (11c, alle Routen `LearningManage` + `AiUse`): `GET/POST /api/learning/schedules`, `PUT/DELETE …/schedules/{id}`, `POST …/schedules/{id}/run` (ADR-0021) |
+12 -2
View File
@@ -176,6 +176,16 @@ Legende: `[x]` erledigt, `[ ]` offen. Nach jeder Phase: Build ohne Warnungen, Te
- [ ] Backup-/Restore-Skripte
- [ ] README: Voraussetzungen, Entwicklung, Konfiguration, Deployment, Update, Backup/Restore, Kalender-Abos
## Phase 12c – CalDAV-Zwei-Wege-Sync
- [x] Kalender freigeben und Zwei-Wege-Synchronisation mit iOS/macOS, Thunderbird und DAVx5 (CalDAV-Server in der Api unter `/caldav`, `/.well-known/caldav` → 301; BFF leitet beides weiter; ADR-0017 Nachtrag)
- [x] Protokoll: Entdeckung (Principal, calendar-home-set), `OPTIONS`/`PROPFIND` (Depth 0/1)/`REPORT` (calendar-query inkl. time-range, calendar-multiget, sync-collection)/`GET`/`PUT`/`DELETE`, ETag je Termin (Serie + Ausnahmen), CTag/Sync-Token je Kalender, If-Match/If-None-Match mit 412
- [x] Abbildung auf die vorhandenen Termine: ein `.ics` je UID (Serie + RECURRENCE-ID), Import-/Export-Code aus ADR-0016 wiederverwendet; nicht unterstützte Wiederholungen und VTODO/VJOURNAL werden mit 403 und Vorbedingung abgelehnt; Erhalt von Ersteller/Personen-Tags
- [x] Anmeldung nur per App-Passwort (`CalDavCredential`: gehasht, einmalig angezeigt, widerrufbar, je Benutzer/Gerät, zuletzt benutzt), HTTP Basic nur über HTTPS (`CalDav:RequireHttps`), eigenes Auth-Schema ohne Zugriff auf `/api`, Drosselung von Fehlversuchen, Audit-Log; Migration `Phase12cCalDav`
- [x] Rechte je Kalender und Benutzer bei jeder Anfrage (Kind nur eigene Termine, Gast nur lesen, unsichtbare Kalender), `current-user-privilege-set` je Kalender und Termin
- [x] Oberfläche „Kalender freigeben / Mit Handy verbinden“ (Einstellungen › Kalender verbinden, Link im Kalender-Menü): Server-Adresse, Benutzername, App-Passwort erstellen/widerrufen, Anleitungen iPhone/Mac/Android (DAVx5)/Thunderbird, ehrlicher Outlook-Hinweis (nur ICS-Abo lesend)
- [x] Tests: Unit (Pfade/Hrefs, ETag, Gruppierung, Sync-Token, XML, Filter, PUT-Prüfung, Export-Roundtrip, Drosselung), bUnit (Verbindungsseite), Integration über den BFF (Entdeckung, Round-Trip, 412, Serien, Sync, Rechte, Zugangsdaten)
- [ ] Praxistest mit echten Clients (iOS/macOS, Thunderbird, DAVx5) – noch nicht erfolgt
## Backlog
- PIN-Login-Oberfläche für ein Familien-Tablet (Profilauswahl + Ziffernblock) – Api ist vorbereitet
- Selbst-Upload des eigenen Profilbilds, Profilseite für Anzeigename/Farbe
@@ -185,9 +195,9 @@ Legende: `[x]` erledigt, `[ ]` offen. Nach jeder Phase: Build ohne Warnungen, Te
- Belohnungskatalog für Ämtchen-Punkte
- Web-Push für Termine und Chat (Infrastruktur aus Phase 5 vorhanden)
- Chat: Nachrichten bearbeiten, Antworten, Suche, Aufräumen alter Nachrichten/Bilder
- CalDAV-Server bzw. Microsoft-Graph-Synchronisation (ADR-0017)
- Kalender: Stunden-Raster mit Drag & Drop, „dieser und folgende“, Erinnerungen per Web-Push für Termine
- Rezeptdatenbank
- Geburtstage und Jahrestage
- Wand-Tablet-Modus (Kiosk-Dashboard)
- Kalender: Freigabe und Zwei-Wege-Synchronisation mit iOS/macOS (CalDAV-Server, ADR-0017) – Outlook über Microsoft Graph als eigener Folgeschritt
- Outlook-Synchronisation über Microsoft Graph (ADR-0017): Zwei-Wege-Abgleich mit Outlook/Microsoft 365 (OAuth, Delta-Abfragen, Webhooks); Outlook kann kein CalDAV, bis dahin nur lesend per ICS-Abo
- CalDAV: Praxistest mit echten Clients (iOS/macOS, Thunderbird, DAVx5), VALARM/Teilnehmer erhalten, Admin-Übersicht und Widerruf der App-Passwörter aller Benutzer
+67 -1
View File
@@ -1,6 +1,6 @@
# ADR-0017: Bidirektionale Synchronisation über CalDAV / Microsoft Graph – später
* Status: vorgeschlagen (Backlog)
* Status: CalDAV umgesetzt (Phase 12c, siehe Nachtrag); Microsoft Graph vorgeschlagen (Backlog)
* Datum: 2026-10-10
## Kontext
@@ -26,3 +26,69 @@ CalDAV nur über Zusatzprogramme – dafür bleibt das ICS-Abo.
## Offene Fragen
App-Passwörter (Datenmodell, Verwaltung), Konfliktbehandlung (If-Match/412), Umgang mit nicht unterstützten RRULE-Teilen aus Clients.
## Nachtrag (Phase 12c): CalDAV umgesetzt, Outlook bleibt offen
* Status: **CalDAV angenommen und umgesetzt**; Microsoft Graph bleibt Backlog (Datum: 2026-10-10).
### Umfang
Ein minimaler, standardkonformer CalDAV-Server in der Api (`Features/CalDav/`), erreichbar unter `/caldav/` (BFF/YARP leitet `/caldav` und
`/.well-known/caldav` an die Api; letzteres antwortet mit 301 auf `/caldav/`, RFC 6764). Gegen die Protokolle getestet, nicht gegen alle Clients (siehe unten).
| Pfad | Bedeutung |
|---|---|
| `/caldav/` | Wurzel (`current-user-principal`) |
| `/caldav/principals/{userId}/` | Principal (nur der eigene; `calendar-home-set`) |
| `/caldav/calendars/` | Calendar-Home |
| `/caldav/calendars/{calendarId}/` | Ein Familienkalender (`resourcetype` calendar, nur VEVENT, `getctag`, `sync-token`, `calendar-color`, `current-user-privilege-set`) |
| `/caldav/calendars/{calendarId}/{uid}.ics` | Ein Termin **samt aller Ausnahmen** (Serie + RECURRENCE-ID-Überschreibungen = ein iCalendar-Objekt je UID) |
* Methoden: `OPTIONS` (`DAV: 1, calendar-access`), `PROPFIND` (Depth 0/1; `infinity` → 403), `PROPPATCH` (je Eigenschaft 403, damit Clients nicht hängen),
`REPORT` (`calendar-query` mit time-range inkl. Serien-Expansion und prop-filter UID/SUMMARY/LOCATION/DESCRIPTION, `calendar-multiget`, `sync-collection`),
`GET`/`HEAD`/`PUT`/`DELETE` mit `If-Match`/`If-None-Match` (412 bei Konflikt). `MKCALENDAR`/`MKCOL`/`MOVE`/`COPY` → 403 (Kalender verwaltet die Administration).
* **ETag** je Termin = Hash aus Id, `RowVersion` und `UpdatedAt` aller Zeilen der UID (Überschreibungen zählen mit). **CTag/Sync-Token** je Kalender =
jüngste Änderung (`UpdatedAt`) aller Zeilen inklusive gelöschter (Soft Delete setzt `UpdatedAt`): `urn:familyplanner:sync:{ticks}`. Zustandslos, keine Token-Tabelle;
`sync-collection` liefert Änderungen strikt „neuer als Token“ und gelöschte Serien als 404.
* **PUT** nutzt den vorhandenen `IcsImporter`; Änderungen gleichen Zeilen über (UID, RECURRENCE-ID) ab (Ids, Ersteller, Personen-Tags bleiben erhalten). Neue Termine
erhalten den Anleger als Personen-Tag. Die Antwort enthält bewusst **keinen ETag** (die gespeicherte Fassung ist normalisiert, RFC 4791 §5.3.4); Clients laden per GET nach.
Die CalDAV-Ausgabe (`IcsBuilder` mit `resourceMode`) enthält weder die „Für: …“-Zeile noch Feed-Kopfzeilen/Kategorien, damit Notizen nicht bei jedem Speichern
durch einen Client wachsen.
* **Bekannte Verluste (bewusst):** VALARM (Erinnerungen), ATTENDEE/ORGANIZER, Anhänge, Kategorien und X-Eigenschaften werden nicht gespeichert; Personen-Tags
sind in CalDAV unsichtbar (bleiben bei Änderungen aber erhalten). Nicht unterstützte Wiederholungen (außerhalb der RRULE-Teilmenge, ADR-0014) werden mit
403 `valid-calendar-object-resource` **abgelehnt**, statt sie still zu Einzelterminen zu machen (für den Client wäre das Datenverlust); VTODO/VJOURNAL/VFREEBUSY → 403
`supported-calendar-component`. Der Dateiname muss `<UID>.ics` entsprechen (so benennen iOS, Thunderbird und DAVx5). UIDs mit `/` überstehen den Proxy nur dank
einer Heuristik (YARP kodiert `%2F` doppelt); ein wörtliches `%2F` in einer UID ist nicht darstellbar.
* **Offene Randfälle:** Verschiebt jemand in der Web-Oberfläche einen Termin in einen anderen Kalender, sieht der alte CalDAV-Kalender kein Löschen
(bis zur nächsten Vollsynchronisation). Der Änderungszeitpunkt wird vor dem Commit gesetzt – eine parallel committende Änderung mit knapp früherem Zeitstempel kann
einem Sync-Token theoretisch entgehen (bei Familiengröße vernachlässigbar, ein Neu-Sync behebt es).
### Rechte
Es gelten dieselben Regeln wie in der Web-Oberfläche (ADR-0015), **bei jeder Anfrage serverseitig**: `CalendarAccessService` ermittelt je Kalender und Benutzer `None/Read/WriteOwn/Write`.
Kalender ohne Lesezugriff sind unsichtbar (404, nicht in Listen). `PUT` neuer Termine braucht `CanCreate`, `PUT`/`DELETE` bestehender `CanModify` (Kinder: nur eigene, bei
Serien zählt der Ersteller der Serie; Gäste: nie). Der Server meldet `current-user-privilege-set` je Kalender **und je Termin**, damit Clients Schreibschutz anzeigen.
Fremde Principals sind nicht erreichbar. Änderungen lösen `CalendarChanged` für die Web-Oberfläche aus.
### Sicherheit (App-Passwörter, Basic nur über HTTPS)
* Authentifizierung per **HTTP Basic mit App-Passwort** (Entität `CalDavCredential`, Migration `Phase12cCalDav`): je Benutzer mehrere, mit Bezeichnung, einmalig angezeigt
(`xxxx-xxxx-xxxx-xxxx-xxxx`, ca. 99 Bit Zufall), nur als SHA-256 gespeichert (hohe Entropie, daher genügt ein schneller Hash; Basic sendet das Geheimnis bei jeder Anfrage,
PBKDF2 wäre dort untragbar), jederzeit widerrufbar, „zuletzt benutzt“ (höchstens alle 5 Minuten geschrieben) samt User-Agent. Höchstens 10 aktive je Benutzer.
Erstellen und Widerrufen stehen im Audit-Log (ohne Geheimnis).
* **Niemals das normale Passwort:** Das Schema akzeptiert ausschließlich App-Passwörter (ein falsches Format wird gar nicht erst in der Datenbank gesucht). Das Schema `CalDavBasic` ist
**nicht Standard** und nur an die Policy `CalDav` gebunden: Ein App-Passwort öffnet keine `/api`-Endpunkte, ein Login-Cookie öffnet `/caldav` nicht (kein CSRF-Risiko).
Der Benutzername muss zum App-Passwort gehören; deaktivierte, abgelaufene und gelöschte Konten sowie Konten mit offenem Passwortwechsel werden abgewiesen.
* **HTTPS-Pflicht:** `CalDav:RequireHttps` (Standard `true`; in `appsettings.Development.json` aus). Hinter Caddy/YARP kommt das Schema über `X-Forwarded-Proto`. Ohne HTTPS antwortet
die Api 403 – das Geheimnis wird dann nicht geprüft. Für ein reines Heimnetz ohne Zertifikat kann `CalDav__RequireHttps=false` gesetzt werden (Risiko: Klartext-Passwort im LAN; die Oberfläche warnt).
* **Schutz vor Raten:** Fehlversuche werden je IP (30) und je IP + Benutzername (8) in 10 Minuten gezählt, danach 429 mit `Retry-After` (bewusst nicht je Benutzername allein,
damit niemand Dritte aussperren kann). Zusätzlich ein großzügiges Anfragelimit je IP (`RateLimiting__CalDavPermitLimit`, 600/min), weil iOS beim ersten Abgleich viele Anfragen stellt.
* Anfragekörper sind auf 1 MB begrenzt; XML wird ohne DTD und externe Entitäten gelesen.
### Outlook: ehrlich
Klassisches Outlook (Windows) und das neue Outlook sprechen **kein CalDAV**; es gibt keine eingebaute Möglichkeit, einen CalDAV-Kalender zwei-wege einzubinden
(nur Zusatzprogramme wie „CalDav Synchronizer“, die hier weder getestet noch Teil der Lieferung sind). Eine echte Zwei-Wege-Synchronisation mit Outlook/Microsoft 365 wäre nur über
**Microsoft Graph** möglich (OAuth/App-Registrierung, Delta-Abfragen, Webhooks, Cloud-Konto) – das bleibt ein eigener Folgeschritt im Backlog („Outlook-Synchronisation über Microsoft Graph“).
Bis dahin kann Outlook die Familienkalender über das bestehende **ICS-Abo nur lesend** einbinden (ADR-0016; Outlook aktualisiert Abos nur alle paar Stunden).
### Getestet / nicht getestet
Automatisiert: Unit (Pfade, ETag, Gruppierung, Sync-Token/Delta, XML, calendar-query, PUT-Prüfung, Export-Roundtrip, Drosselung, App-Passwort-Format) und Integration über den BFF
(Entdeckung, Round-Trip, 412, Serien mit Ausnahmen, Sync/multiget/query, Rechte für Kind/Gast/versteckte und schreibgeschützte Kalender, falsche/widerrufene Zugangsdaten, Drosselung).
**Nicht** mit echten Clients ausprobiert: iOS/macOS Kalender, Thunderbird, DAVx5/Android. Die Eigenheiten dieser Clients sind aus der Protokollkenntnis umgesetzt;
Rückmeldungen aus dem Praxiseinsatz können kleine Anpassungen nötig machen.
@@ -0,0 +1,266 @@
using System.Collections.Concurrent;
using System.Globalization;
using System.Security.Claims;
using System.Text;
using System.Text.Encodings.Web;
using FamilyPlanner.Domain.Calendar;
using FamilyPlanner.Infrastructure.Persistence;
using FamilyPlanner.Shared;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using FamilyPlanner.Infrastructure.Identity;
namespace FamilyPlanner.Api.Features.CalDav;
public sealed class CalDavOptions
{
public const string SectionName = "CalDav";
/// <summary>Basic-Auth nur über HTTPS annehmen (ADR-0017). Nur für lokale Entwicklung/Tests abschalten.</summary>
public bool RequireHttps { get; set; } = true;
/// <summary>Fehlversuche je IP und Fenster, bevor weitere Versuche gesperrt werden.</summary>
public int MaxFailuresPerIp { get; set; } = 30;
/// <summary>Fehlversuche je IP + Benutzername und Fenster.</summary>
public int MaxFailuresPerUser { get; set; } = 8;
public int FailureWindowMinutes { get; set; } = 10;
}
/// <summary>
/// Bremst das Raten von App-Passwörtern: zählt Fehlversuche je IP und je IP + Benutzername in einem Zeitfenster. Bewusst nicht je
/// Benutzername allein, damit Fremde niemanden aussperren können. Ein normales Anfragelimit ist ungeeignet, weil iOS bei der ersten
/// Synchronisation viele Anfragen schnell hintereinander stellt.
/// </summary>
public sealed class CalDavFailureThrottle(TimeProvider timeProvider, IOptions<CalDavOptions> options)
{
private readonly ConcurrentDictionary<string, (int Count, DateTimeOffset WindowStart)> _failures = new();
private TimeSpan Window => TimeSpan.FromMinutes(Math.Max(1, options.Value.FailureWindowMinutes));
/// <summary>Wie lange noch gesperrt? <c>null</c>, wenn der Versuch zulässig ist.</summary>
public TimeSpan? BlockedFor(string ip, string userName)
{
var now = timeProvider.GetUtcNow();
return Max(Remaining(IpKey(ip), options.Value.MaxFailuresPerIp, now), Remaining(UserKey(ip, userName), options.Value.MaxFailuresPerUser, now));
}
public void RegisterFailure(string ip, string userName)
{
var now = timeProvider.GetUtcNow();
Increment(IpKey(ip), now);
Increment(UserKey(ip, userName), now);
if (_failures.Count > 10_000)
{
foreach (var (key, value) in _failures)
{
if (now - value.WindowStart >= Window)
{
_failures.TryRemove(key, out _);
}
}
}
}
public void Reset(string ip, string userName) => _failures.TryRemove(UserKey(ip, userName), out _);
private static string IpKey(string ip) => "ip|" + ip;
private static string UserKey(string ip, string userName) => "user|" + ip + "|" + userName.ToUpperInvariant();
private static TimeSpan? Max(TimeSpan? a, TimeSpan? b) => (a, b) switch
{
(null, _) => b,
(_, null) => a,
_ => a > b ? a : b,
};
private TimeSpan? Remaining(string key, int limit, DateTimeOffset now)
{
if (!_failures.TryGetValue(key, out var entry) || entry.Count < limit)
{
return null;
}
var remaining = entry.WindowStart + Window - now;
return remaining > TimeSpan.Zero ? remaining : null;
}
private void Increment(string key, DateTimeOffset now) =>
_failures.AddOrUpdate(
key,
_ => (1, now),
(_, old) => now - old.WindowStart >= Window ? (1, now) : (old.Count + 1, old.WindowStart));
}
/// <summary>
/// HTTP Basic mit App-Passwort für <c>/caldav</c> (ADR-0017). Eigenes Schema, nicht Standard: Cookies gelten hier nicht, und ein
/// App-Passwort öffnet nie die <c>/api</c>-Endpunkte. Das Passwort des Kontos wird nicht akzeptiert.
/// </summary>
public sealed class CalDavBasicHandler(
IOptionsMonitor<AuthenticationSchemeOptions> optionsMonitor,
ILoggerFactory logger,
UrlEncoder encoder,
AppDbContext db,
ILookupNormalizer normalizer,
TimeProvider timeProvider,
CalDavFailureThrottle throttle,
IOptions<CalDavOptions> calDavOptions)
: AuthenticationHandler<AuthenticationSchemeOptions>(optionsMonitor, logger, encoder)
{
public const string SchemeName = "CalDavBasic";
public const string Realm = "FamilyPlanner";
private const string RejectionKey = "caldav.rejection";
private static readonly TimeSpan LastUsedInterval = TimeSpan.FromMinutes(5);
private enum Rejection
{
Credentials,
HttpsRequired,
Throttled,
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue("Authorization", out var header) || !TryDecode(header.ToString(), out var userName, out var secret))
{
return AuthenticateResult.NoResult();
}
if (calDavOptions.Value.RequireHttps && !Request.IsHttps)
{
Context.Items[RejectionKey] = Rejection.HttpsRequired;
return AuthenticateResult.Fail("HTTPS erforderlich");
}
var ip = Context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
if (throttle.BlockedFor(ip, userName) is { } blockedFor)
{
Context.Items[RejectionKey] = Rejection.Throttled;
Response.Headers.RetryAfter = Math.Ceiling(blockedFor.TotalSeconds).ToString(CultureInfo.InvariantCulture);
return AuthenticateResult.Fail("Zu viele Fehlversuche");
}
var principal = await TryAuthenticateAsync(userName, secret);
if (principal is null)
{
throttle.RegisterFailure(ip, userName);
Context.Items[RejectionKey] = Rejection.Credentials;
return AuthenticateResult.Fail("Ungültige Zugangsdaten");
}
throttle.Reset(ip, userName);
return AuthenticateResult.Success(new AuthenticationTicket(principal, SchemeName));
}
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
{
var rejection = Context.Items.TryGetValue(RejectionKey, out var value) && value is Rejection r ? r : Rejection.Credentials;
switch (rejection)
{
case Rejection.HttpsRequired:
Response.StatusCode = StatusCodes.Status403Forbidden;
break;
case Rejection.Throttled:
Response.StatusCode = StatusCodes.Status429TooManyRequests;
break;
default:
Response.StatusCode = StatusCodes.Status401Unauthorized;
Response.Headers.WWWAuthenticate = $"Basic realm=\"{Realm}\", charset=\"UTF-8\"";
break;
}
return Task.CompletedTask;
}
/// <summary>Liest <c>Basic base64(Benutzer:Passwort)</c>; der Benutzername darf kein <c>:</c> enthalten (Identity erlaubt es nicht).</summary>
internal static bool TryDecode(string header, out string userName, out string secret)
{
userName = secret = string.Empty;
const string prefix = "Basic ";
if (!header.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
return false;
}
try
{
var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(header[prefix.Length..].Trim()));
var separator = decoded.IndexOf(':', StringComparison.Ordinal);
if (separator <= 0 || separator == decoded.Length - 1)
{
return false;
}
(userName, secret) = (decoded[..separator], decoded[(separator + 1)..]);
return true;
}
catch (FormatException)
{
return false;
}
}
private async Task<ClaimsPrincipal?> TryAuthenticateAsync(string userName, string secret)
{
// Falsche Länge: gar nicht erst die Datenbank fragen (das normale Passwort wird so ohnehin nie geprüft).
if (!CalDavSecret.IsWellFormed(secret))
{
return null;
}
var now = timeProvider.GetUtcNow();
var normalized = normalizer.NormalizeName(userName);
var user = await db.Users.AsNoTracking().SingleOrDefaultAsync(u => u.NormalizedUserName == normalized, Context.RequestAborted);
if (user is null || !user.CanSignIn(now) || user.MustChangePassword)
{
return null;
}
var hash = CalDavSecret.Hash(secret);
var credential = await db.CalDavCredentials.AsNoTracking()
.SingleOrDefaultAsync(c => c.SecretHash == hash && c.UserId == user.Id && c.RevokedAt == null, Context.RequestAborted);
if (credential is null)
{
return null;
}
var roles = await (from userRole in db.UserRoles
join role in db.Roles on userRole.RoleId equals role.Id
where userRole.UserId == user.Id
select role.Name).ToListAsync(Context.RequestAborted);
if (roles.Count == 0)
{
return null;
}
if (credential.LastUsedAt is null || now - credential.LastUsedAt > LastUsedInterval)
{
var client = Request.Headers.UserAgent.ToString();
client = client.Length > 100 ? client[..100] : client;
await db.CalDavCredentials.Where(c => c.Id == credential.Id)
.ExecuteUpdateAsync(s => s.SetProperty(c => c.LastUsedAt, now).SetProperty(c => c.LastUsedClient, client), Context.RequestAborted);
}
// Dieselben Claims wie beim Cookie-Login (AppClaimsPrincipalFactory), damit Berechtigungen, Kalenderrechte und das Audit-Feld
// CreatedBy unverändert funktionieren.
var identity = new ClaimsIdentity(SchemeName, ClaimTypes.Name, ClaimTypes.Role);
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()));
identity.AddClaim(new Claim(ClaimTypes.Name, user.UserName ?? userName));
identity.AddClaim(new Claim(ApiConventions.DisplayNameClaim, user.DisplayName));
foreach (var role in roles)
{
identity.AddClaim(new Claim(ClaimTypes.Role, role!));
}
if (user.ExpiresAt is { } expiresAt)
{
identity.AddClaim(new Claim(ApiConventions.ExpiresAtClaim, expiresAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)));
}
return new ClaimsPrincipal(identity);
}
}
@@ -0,0 +1,82 @@
using System.Security.Claims;
using FamilyPlanner.Api.Infrastructure;
using FamilyPlanner.Domain.Audit;
using FamilyPlanner.Domain.Authorization;
using FamilyPlanner.Domain.Calendar;
using FamilyPlanner.Infrastructure.Audit;
using FamilyPlanner.Infrastructure.Persistence;
using FamilyPlanner.Shared.Calendar;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace FamilyPlanner.Api.Features.CalDav;
/// <summary>Verwaltung der App-Passwörter für Kalender-Apps durch den Benutzer selbst („Meine Einstellungen“, ADR-0017).</summary>
public static class CalDavCredentialEndpoints
{
public static IEndpointRouteBuilder MapCalDavCredentialEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/caldav").WithTags("CalDAV").RequirePermission(Permission.CalendarRead);
group.MapGet("/info", GetInfo);
group.MapGet("/credentials", ListAsync);
group.MapPost("/credentials", CreateAsync).Validate<CreateCalDavCredentialRequest>();
group.MapDelete("/credentials/{id:guid}", RevokeAsync);
return app;
}
internal static Ok<CalDavInfoDto> GetInfo(ClaimsPrincipal user, HttpContext context)
{
var request = context.Request;
return TypedResults.Ok(new CalDavInfoDto($"{request.Scheme}://{request.Host}{request.PathBase}{DavPath.RootPath}", user.Identity?.Name ?? string.Empty, request.IsHttps));
}
internal static async Task<Ok<List<CalDavCredentialDto>>> ListAsync(ClaimsPrincipal user, AppDbContext db, CancellationToken ct)
{
var userId = user.UserId() ?? Guid.Empty;
var items = await db.CalDavCredentials.AsNoTracking().Where(c => c.UserId == userId)
.OrderBy(c => c.RevokedAt != null).ThenByDescending(c => c.CreatedAt).ToListAsync(ct);
return TypedResults.Ok(items.Select(ToDto).ToList());
}
internal static async Task<Results<Created<CalDavCredentialSecretDto>, ProblemHttpResult>> CreateAsync(
CreateCalDavCredentialRequest request, ClaimsPrincipal user, AppDbContext db, IAuditLogger audit, CancellationToken ct)
{
var userId = user.UserId() ?? Guid.Empty;
if (await db.CalDavCredentials.CountAsync(c => c.UserId == userId && c.RevokedAt == null, ct) >= CalDavCredential.MaxActivePerUser)
{
return EndpointHelpers.Conflict($"Es sind höchstens {CalDavCredential.MaxActivePerUser} App-Passwörter gleichzeitig möglich. Bitte zuerst nicht mehr genutzte widerrufen.");
}
var secret = CalDavSecret.Generate();
var credential = new CalDavCredential
{
UserId = userId,
Label = request.Label.Trim(),
SecretHash = CalDavSecret.Hash(secret),
SecretPrefix = CalDavSecret.Prefix(secret),
};
db.CalDavCredentials.Add(credential);
await db.SaveChangesAsync(ct);
await audit.WriteAsync(user.ActorName(), AuditActions.CalDavCredentialCreated, "CalDavCredential", credential.Id.ToString(), credential.Label, null, ct);
return TypedResults.Created($"/api/caldav/credentials/{credential.Id}", new CalDavCredentialSecretDto(ToDto(credential), secret));
}
internal static async Task<NoContent> RevokeAsync(Guid id, ClaimsPrincipal user, AppDbContext db, IAuditLogger audit, TimeProvider timeProvider, CancellationToken ct)
{
var userId = user.UserId() ?? Guid.Empty;
var credential = await db.CalDavCredentials.SingleOrDefaultAsync(c => c.Id == id && c.UserId == userId, ct);
if (credential is { RevokedAt: null })
{
credential.RevokedAt = timeProvider.GetUtcNow();
credential.RevokedBy = userId;
await db.SaveChangesAsync(ct);
await audit.WriteAsync(user.ActorName(), AuditActions.CalDavCredentialRevoked, "CalDavCredential", credential.Id.ToString(), credential.Label, null, ct);
}
return TypedResults.NoContent();
}
internal static CalDavCredentialDto ToDto(CalDavCredential c) =>
new(c.Id, c.Label, c.SecretPrefix, c.CreatedAt, c.LastUsedAt, c.LastUsedClient, c.RevokedAt);
}
@@ -0,0 +1,707 @@
using System.Globalization;
using System.Security.Claims;
using System.Text;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using FamilyPlanner.Api.Features.Calendar;
using FamilyPlanner.Api.Features.Calendar.Ics;
using FamilyPlanner.Api.Infrastructure;
using FamilyPlanner.Domain.Calendar;
using FamilyPlanner.Infrastructure.Persistence;
using FamilyPlanner.Infrastructure.Settings;
using FamilyPlanner.Shared;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.EntityFrameworkCore;
using Microsoft.Net.Http.Headers;
using static FamilyPlanner.Api.Features.CalDav.CalDavXml;
namespace FamilyPlanner.Api.Features.CalDav;
/// <summary>Antwort mit Statuscode, optionalem Körper und Headern – für WebDAV-Methoden, die Minimal APIs nicht kennen.</summary>
internal sealed class DavResult(int status, string? body = null, string contentType = "application/xml; charset=utf-8", Action<IHeaderDictionary>? headers = null) : IResult
{
public async Task ExecuteAsync(HttpContext httpContext)
{
var response = httpContext.Response;
response.StatusCode = status;
headers?.Invoke(response.Headers);
if (body is not null && status is not (StatusCodes.Status204NoContent or StatusCodes.Status304NotModified))
{
response.ContentType = contentType;
var bytes = Encoding.UTF8.GetBytes(body);
response.ContentLength = bytes.Length;
if (!HttpMethods.IsHead(httpContext.Request.Method))
{
await response.Body.WriteAsync(bytes, httpContext.RequestAborted);
}
}
}
}
/// <summary>
/// Minimaler CalDAV-Server (RFC 4791, 6578; ADR-0017): Entdeckung, PROPFIND, REPORT (calendar-query, calendar-multiget, sync-collection),
/// GET/PUT/DELETE von .ics-Terminen mit ETag-Prüfung. Zugriff per App-Passwort (HTTP Basic); die Rechte je Kalender und Benutzer
/// (<see cref="CalendarAccessService"/>) werden bei jeder Anfrage serverseitig geprüft.
/// </summary>
public static class CalDavEndpoints
{
public const string PolicyName = "CalDav";
private const string ContentTypeEvent = "text/calendar; charset=utf-8; component=VEVENT";
private static readonly string[] Methods =
["OPTIONS", "PROPFIND", "PROPPATCH", "REPORT", "GET", "HEAD", "PUT", "DELETE", "MKCOL", "MKCALENDAR", "MOVE", "COPY", "LOCK", "UNLOCK", "POST"];
private static readonly Regex UnsupportedComponent = new(@"^BEGIN:(VTODO|VJOURNAL|VFREEBUSY)\b", RegexOptions.Multiline | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
public static IEndpointRouteBuilder MapCalDavEndpoints(this IEndpointRouteBuilder app)
{
// Entdeckung (RFC 6764): Clients fragen mit der Domain /.well-known/caldav, anonym.
app.MapMethods("/.well-known/caldav", Methods, () => Results.Redirect(DavPath.RootPath, permanent: true))
.AllowAnonymous()
.ExcludeFromDescription();
app.MapMethods("/caldav", Methods, HandleAsync).RequireAuthorization(PolicyName).RequireRateLimiting(HostingSetup.CalDavRateLimitPolicy).ExcludeFromDescription();
app.MapMethods("/caldav/{**path}", Methods, HandleAsync).RequireAuthorization(PolicyName).RequireRateLimiting(HostingSetup.CalDavRateLimitPolicy).ExcludeFromDescription();
return app;
}
private sealed record Ctx(
HttpContext Http,
Guid UserId,
string UserName,
string DisplayName,
string Role,
IReadOnlyDictionary<Guid, CalendarWithAccess> Calendars,
AppDbContext Db,
string TimeZoneId,
CancellationToken Ct);
/// <summary>Ein Eintrag einer Multistatus-Antwort: Adresse und (verzögert berechnete) Eigenschaften.</summary>
private sealed record Node(string Href, IReadOnlyDictionary<XName, Func<XElement>> Props);
private static async Task<IResult> HandleAsync(
HttpContext http,
ClaimsPrincipal user,
AppDbContext db,
CalendarAccessService access,
ISettingsProvider settingsProvider,
CalendarNotifier notifier,
TimeProvider timeProvider,
CancellationToken ct)
{
http.Response.Headers["DAV"] = "1, calendar-access";
var userId = user.UserId() ?? Guid.Empty;
var role = CalendarAccessService.PrimaryRole(user);
var calendars = (await access.ForUserAsync(userId, role, ct)).Where(c => CalendarRules.CanRead(c.Access)).ToDictionary(c => c.Calendar.Id);
var settings = await settingsProvider.GetAsync(ct);
var ctx = new Ctx(http, userId, user.Identity?.Name ?? string.Empty, user.FindFirst(ApiConventions.DisplayNameClaim)?.Value ?? user.Identity?.Name ?? string.Empty,
role, calendars, db, settings.TimeZoneId, ct);
var path = DavPath.Parse(http.Features.Get<IHttpRequestFeature>()?.RawTarget ?? http.Request.Path.Value);
return http.Request.Method.ToUpperInvariant() switch
{
"OPTIONS" => Options(http),
"PROPFIND" => await PropFindAsync(ctx, path),
"PROPPATCH" => await PropPatchAsync(ctx, path),
"REPORT" => await ReportAsync(ctx, path, timeProvider.GetUtcNow()),
"GET" or "HEAD" => await GetAsync(ctx, path),
"PUT" => await PutAsync(ctx, path, notifier),
"DELETE" => await DeleteAsync(ctx, path, notifier),
"MKCOL" or "MKCALENDAR" or "MOVE" or "COPY" => Error(StatusCodes.Status403Forbidden, DAV("forbidden"), "Kalender werden im Familienplaner verwaltet, Termine nur per PUT/DELETE geändert."),
_ => new DavResult(StatusCodes.Status405MethodNotAllowed, headers: h => h.Allow = "OPTIONS, PROPFIND, PROPPATCH, REPORT, GET, HEAD, PUT, DELETE"),
};
}
private static XName DAV(string name) => D + name;
private static DavResult Error(int status, XName precondition, string? description = null) =>
new(status, CalDavXml.Error(precondition, description));
private static IResult Options(HttpContext http)
{
http.Response.Headers.Allow = "OPTIONS, PROPFIND, PROPPATCH, REPORT, GET, HEAD, PUT, DELETE";
return new DavResult(StatusCodes.Status200OK);
}
// ---------- PROPFIND ----------
private static async Task<IResult> PropFindAsync(Ctx c, DavPath path)
{
var depth = c.Http.Request.Headers["Depth"].ToString().Trim().ToLowerInvariant();
if (depth == "infinity")
{
return Error(StatusCodes.Status403Forbidden, DAV("propfind-finite-depth"));
}
var withChildren = depth == "1";
var request = DavPropRequest.FromPropfind(TryParse(await ReadBodyAsync(c.Http.Request, c.Ct)));
var nodes = new List<Node>();
switch (path.Kind)
{
case DavKind.Root:
nodes.Add(RootNode(c));
if (withChildren)
{
nodes.Add(PrincipalsNode());
nodes.Add(HomeNode(c));
}
break;
case DavKind.PrincipalsRoot:
nodes.Add(PrincipalsNode());
if (withChildren)
{
nodes.Add(PrincipalNode(c));
}
break;
case DavKind.Principal when path.PrincipalId == c.UserId:
nodes.Add(PrincipalNode(c));
break;
case DavKind.Home:
nodes.Add(HomeNode(c));
if (withChildren)
{
var stamps = await StampsAsync(c);
nodes.AddRange(c.Calendars.Values.OrderBy(x => x.Calendar.SortOrder).ThenBy(x => x.Calendar.Name).Select(x => CalendarNode(x, stamps[x.Calendar.Id])));
}
break;
case DavKind.Calendar when c.Calendars.TryGetValue(path.CalendarId!.Value, out var calendar):
var state = await LoadStateAsync(c, calendar.Calendar);
nodes.Add(CalendarNode(calendar, state.Stamp));
if (withChildren)
{
nodes.AddRange(state.Resources.Select(r => EventNode(c, calendar, r)));
}
break;
case DavKind.Event when c.Calendars.TryGetValue(path.CalendarId!.Value, out var eventCalendar):
var resource = await FindResourceAsync(c, eventCalendar.Calendar, path.Uid!);
if (resource is null)
{
return new DavResult(StatusCodes.Status404NotFound);
}
nodes.Add(EventNode(c, eventCalendar, resource));
break;
default:
return new DavResult(StatusCodes.Status404NotFound);
}
return Multistatus(nodes.Select(n => Render(n, request)));
}
private static DavResult Multistatus(IEnumerable<XElement> responses, string? syncToken = null) =>
new(207, CalDavXml.Multistatus(responses, syncToken));
private static XElement Render(Node node, DavPropRequest request)
{
if (request.PropNameOnly)
{
return CalDavXml.Response(node.Href, node.Props.Keys.Select(n => new XElement(n)), []);
}
if (request.AllProp)
{
// allprop liefert die „billigen“ Eigenschaften; calendar-data nur auf ausdrückliche Anfrage.
return CalDavXml.Response(node.Href, node.Props.Where(p => p.Key != C + "calendar-data").Select(p => p.Value()), []);
}
var found = request.Names.Where(node.Props.ContainsKey).Select(n => node.Props[n]()).ToList();
return CalDavXml.Response(node.Href, found, request.Names.Where(n => !node.Props.ContainsKey(n)));
}
private static Func<XElement> Prop(XName name, params object[] content) => () => new XElement(name, content);
private static XElement Href(string href) => new(D + "href", href);
private static XElement Privilege(params XName[] names) =>
new(D + "current-user-privilege-set", names.Select(n => new XElement(D + "privilege", new XElement(n))));
private static Dictionary<XName, Func<XElement>> CommonProps(string displayName, params XElement[] resourceType) => new()
{
[D + "displayname"] = Prop(D + "displayname", displayName),
[D + "resourcetype"] = Prop(D + "resourcetype", resourceType),
};
private static Node RootNode(Ctx c)
{
var props = CommonProps("Familienplaner", new XElement(D + "collection"));
props[D + "current-user-principal"] = Prop(D + "current-user-principal", Href(DavPath.Principal(c.UserId)));
props[D + "principal-URL"] = Prop(D + "principal-URL", Href(DavPath.Principal(c.UserId)));
props[D + "principal-collection-set"] = Prop(D + "principal-collection-set", Href(DavPath.PrincipalsPath));
props[C + "calendar-home-set"] = Prop(C + "calendar-home-set", Href(DavPath.HomePath));
props[D + "current-user-privilege-set"] = () => Privilege(D + "read", D + "read-current-user-privilege-set");
return new Node(DavPath.RootPath, props);
}
private static Node PrincipalsNode() =>
new(DavPath.PrincipalsPath, CommonProps("Personen", new XElement(D + "collection")));
private static Node PrincipalNode(Ctx c)
{
var href = DavPath.Principal(c.UserId);
var props = CommonProps(c.DisplayName, new XElement(D + "collection"), new XElement(D + "principal"));
props[D + "principal-URL"] = Prop(D + "principal-URL", Href(href));
props[D + "current-user-principal"] = Prop(D + "current-user-principal", Href(href));
props[C + "calendar-home-set"] = Prop(C + "calendar-home-set", Href(DavPath.HomePath));
props[C + "calendar-user-address-set"] = Prop(C + "calendar-user-address-set", Href(href));
props[D + "principal-collection-set"] = Prop(D + "principal-collection-set", Href(DavPath.PrincipalsPath));
props[D + "current-user-privilege-set"] = () => Privilege(D + "read", D + "read-current-user-privilege-set");
return new Node(href, props);
}
private static Node HomeNode(Ctx c)
{
var props = CommonProps("Kalender", new XElement(D + "collection"));
props[D + "current-user-principal"] = Prop(D + "current-user-principal", Href(DavPath.Principal(c.UserId)));
props[D + "current-user-privilege-set"] = () => Privilege(D + "read", D + "read-current-user-privilege-set");
return new Node(DavPath.HomePath, props);
}
private static Node CalendarNode(CalendarWithAccess calendar, DateTimeOffset stamp)
{
var token = CalDavSyncToken.Create(stamp);
var color = calendar.Calendar.Color.Length == 7 ? calendar.Calendar.Color + "FF" : calendar.Calendar.Color;
var props = CommonProps(calendar.Calendar.Name, new XElement(D + "collection"), new XElement(C + "calendar"));
props[C + "supported-calendar-component-set"] = Prop(C + "supported-calendar-component-set", new XElement(C + "comp", new XAttribute("name", "VEVENT")));
props[IC + "calendar-color"] = Prop(IC + "calendar-color", color);
props[IC + "calendar-order"] = Prop(IC + "calendar-order", calendar.Calendar.SortOrder.ToString(CultureInfo.InvariantCulture));
props[CS + "getctag"] = Prop(CS + "getctag", token);
props[D + "sync-token"] = Prop(D + "sync-token", token);
props[C + "max-resource-size"] = Prop(C + "max-resource-size", CalDavXml.MaxRequestBytes.ToString(CultureInfo.InvariantCulture));
props[D + "supported-report-set"] = Prop(
D + "supported-report-set",
new XElement(D + "supported-report", new XElement(D + "report", new XElement(C + "calendar-query"))),
new XElement(D + "supported-report", new XElement(D + "report", new XElement(C + "calendar-multiget"))),
new XElement(D + "supported-report", new XElement(D + "report", new XElement(D + "sync-collection"))));
props[D + "current-user-privilege-set"] = () => CalendarRules.CanCreate(calendar.Access)
? Privilege(D + "read", D + "read-current-user-privilege-set", D + "write", D + "write-content", D + "bind", D + "unbind")
: Privilege(D + "read", D + "read-current-user-privilege-set");
return new Node(DavPath.Calendar(calendar.Calendar.Id), props);
}
private static Node EventNode(Ctx c, CalendarWithAccess calendar, DavResource resource)
{
var canModify = CalendarRules.CanModify(calendar.Access, resource.Master.CreatedBy, c.UserId);
var props = new Dictionary<XName, Func<XElement>>
{
[D + "getetag"] = Prop(D + "getetag", resource.ETag),
[D + "getcontenttype"] = Prop(D + "getcontenttype", ContentTypeEvent),
[D + "getlastmodified"] = Prop(D + "getlastmodified", resource.LastModified.ToString("R", CultureInfo.InvariantCulture)),
[D + "resourcetype"] = Prop(D + "resourcetype"),
[D + "current-user-privilege-set"] = () => canModify
? Privilege(D + "read", D + "read-current-user-privilege-set", D + "write", D + "write-content", D + "unbind")
: Privilege(D + "read", D + "read-current-user-privilege-set"),
[C + "calendar-data"] = () => new XElement(C + "calendar-data", Serialize(c, resource)),
};
return new Node(DavPath.Event(resource.CalendarId, resource.Uid), props);
}
private static string Serialize(Ctx c, DavResource resource) =>
IcsBuilder.Build(string.Empty, c.TimeZoneId, resource.Events.ToList(), new Dictionary<Guid, string>(), new Dictionary<Guid, string>(), [], IcsTaskMode.None, resourceMode: true);
// ---------- PROPPATCH ----------
/// <summary>Eigenschaften (Name, Farbe …) werden im Familienplaner verwaltet; Clients bekommen je Eigenschaft 403, damit sie nicht hängen.</summary>
private static async Task<IResult> PropPatchAsync(Ctx c, DavPath path)
{
var body = TryParse(await ReadBodyAsync(c.Http.Request, c.Ct));
var names = body?.Root?.Elements().SelectMany(e => e.Elements(D + "prop")).SelectMany(p => p.Elements()).Select(e => e.Name).ToList() ?? [];
var href = path.Kind switch
{
DavKind.Calendar when path.CalendarId is { } id && c.Calendars.ContainsKey(id) => DavPath.Calendar(id),
DavKind.Principal when path.PrincipalId == c.UserId => DavPath.Principal(c.UserId),
_ => null,
};
return href is null
? new DavResult(StatusCodes.Status404NotFound)
: Multistatus([PropStatResponse(href, names, StatusCodes.Status403Forbidden)]);
}
// ---------- REPORT ----------
private static async Task<IResult> ReportAsync(Ctx c, DavPath path, DateTimeOffset now)
{
var root = TryParse(await ReadBodyAsync(c.Http.Request, c.Ct))?.Root;
if (root is null)
{
return new DavResult(StatusCodes.Status400BadRequest);
}
if (path.Kind != DavKind.Calendar || !c.Calendars.TryGetValue(path.CalendarId!.Value, out var calendar))
{
return path.Kind == DavKind.Calendar
? new DavResult(StatusCodes.Status404NotFound)
: Error(StatusCodes.Status403Forbidden, DAV("supported-report"), "Berichte sind nur für Kalender möglich.");
}
var request = DavPropRequest.FromReport(root);
var state = await LoadStateAsync(c, calendar.Calendar);
if (root.Name == C + "calendar-query")
{
var query = CalendarQuery.Parse(root);
return Multistatus(state.Resources.Where(r => query.Matches(r, now)).Select(r => Render(EventNode(c, calendar, r), request)));
}
if (root.Name == C + "calendar-multiget")
{
var responses = new List<XElement>();
foreach (var href in root.Elements(D + "href").Select(h => h.Value.Trim()))
{
var target = DavPath.Parse(href);
var resource = target is { Kind: DavKind.Event, CalendarId: { } cid } && cid == calendar.Calendar.Id
? state.Resources.FirstOrDefault(r => r.Uid == target.Uid)
: null;
responses.Add(resource is null ? StatusResponse(href, StatusCodes.Status404NotFound) : Render(EventNode(c, calendar, resource), request));
}
return Multistatus(responses);
}
if (root.Name == D + "sync-collection")
{
if (!CalDavSyncToken.TryParse(root.Element(D + "sync-token")?.Value, out var since) || since > state.Stamp)
{
return Error(StatusCodes.Status403Forbidden, DAV("valid-sync-token"), "Das Sync-Token ist ungültig. Bitte vollständig neu synchronisieren.");
}
var (changed, removed) = CalDavSyncToken.Delta(state, since);
var responses = changed.Select(r => Render(EventNode(c, calendar, r), request))
.Concat(removed.Select(t => StatusResponse(DavPath.Event(calendar.Calendar.Id, t.Uid), StatusCodes.Status404NotFound)));
return Multistatus(responses, CalDavSyncToken.Create(state.Stamp));
}
return Error(StatusCodes.Status403Forbidden, DAV("supported-report"));
}
// ---------- GET ----------
private static async Task<IResult> GetAsync(Ctx c, DavPath path)
{
if (path.Kind != DavKind.Event || !c.Calendars.TryGetValue(path.CalendarId!.Value, out var calendar)
|| await FindResourceAsync(c, calendar.Calendar, path.Uid!) is not { } resource)
{
return new DavResult(StatusCodes.Status404NotFound);
}
var response = c.Http.Response;
response.Headers.ETag = resource.ETag;
response.Headers.LastModified = resource.LastModified.ToString("R", CultureInfo.InvariantCulture);
if (CalDavResources.MatchesAny(c.Http.Request.Headers.IfNoneMatch.ToString(), resource.ETag))
{
return new DavResult(StatusCodes.Status304NotModified);
}
return new DavResult(StatusCodes.Status200OK, Serialize(c, resource), "text/calendar; charset=utf-8");
}
// ---------- PUT ----------
private static async Task<IResult> PutAsync(Ctx c, DavPath path, CalendarNotifier notifier)
{
if (path.Kind != DavKind.Event || !c.Calendars.TryGetValue(path.CalendarId!.Value, out var calendar))
{
return path.Kind == DavKind.Event ? new DavResult(StatusCodes.Status404NotFound) : new DavResult(StatusCodes.Status405MethodNotAllowed);
}
var cid = calendar.Calendar.Id;
var uid = path.Uid!;
var rows = await c.Db.CalendarEvents.Where(e => e.CalendarId == cid && e.Uid == uid).ToListAsync(c.Ct);
var existing = CalDavResources.Build(cid, DateTimeOffset.MinValue, rows).Resources.SingleOrDefault();
// Rechte zuerst: wer nicht schreiben darf, erfährt nichts über Inhalt oder ETag.
if (existing is null ? !CalendarRules.CanCreate(calendar.Access) : !CalendarRules.CanModify(calendar.Access, existing.Master.CreatedBy, c.UserId))
{
return Error(StatusCodes.Status403Forbidden, DAV("need-privileges"), "Keine Berechtigung zum Ändern dieses Termins.");
}
var headers = c.Http.Request.Headers;
var ifMatch = headers.IfMatch.ToString();
var ifNoneMatch = headers.IfNoneMatch.ToString();
if ((ifNoneMatch.Trim() == "*" && existing is not null)
|| (!string.IsNullOrWhiteSpace(ifMatch) && (existing is null || !CalDavResources.MatchesAny(ifMatch, existing.ETag)))
|| (existing is not null && !string.IsNullOrWhiteSpace(ifNoneMatch) && CalDavResources.MatchesAny(ifNoneMatch, existing.ETag)))
{
return new DavResult(StatusCodes.Status412PreconditionFailed);
}
var text = await ReadBodyAsync(c.Http.Request, c.Ct);
if (text is null)
{
return Error(StatusCodes.Status403Forbidden, C + "max-resource-size", "Die Datei ist zu groß (höchstens 1 MB).");
}
var rejection = Validate(text, uid, CalendarTime.Zone(c.TimeZoneId), out var master, out var overrides);
if (rejection is not null)
{
return rejection;
}
if (existing is null)
{
master!.CalendarId = cid;
master.PersonIds = [c.UserId];
c.Db.CalendarEvents.Add(master);
foreach (var o in overrides)
{
Attach(o, master);
c.Db.CalendarEvents.Add(o);
}
}
else
{
ApplyUpdate(c.Db, existing, master!, overrides);
}
c.Db.ChangeTracker.DetectChanges();
foreach (var entry in c.Db.ChangeTracker.Entries<CalendarEvent>().Where(e => e.State == EntityState.Modified))
{
entry.Entity.Sequence++;
}
try
{
await c.Db.SaveChangesAsync(c.Ct);
}
catch (DbUpdateConcurrencyException)
{
return new DavResult(StatusCodes.Status412PreconditionFailed);
}
await notifier.ChangedAsync(cid, c.Ct);
// Bewusst ohne ETag-Header: Die gespeicherte Fassung ist normalisiert und nicht byte-gleich zum Gesendeten (RFC 4791 §5.3.4);
// Clients laden sie dann per GET.
return new DavResult(existing is null ? StatusCodes.Status201Created : StatusCodes.Status204NoContent);
}
/// <summary>Prüft den Körper eines PUT; liefert die Fehlerantwort oder (bei Erfolg) <c>null</c> mit Serie und Überschreibungen.</summary>
internal static IResult? Validate(string text, string uid, TimeZoneInfo zone, out CalendarEvent? master, out List<CalendarEvent> overrides)
{
master = null;
overrides = [];
if (UnsupportedComponent.IsMatch(text))
{
return Error(StatusCodes.Status403Forbidden, C + "supported-calendar-component", "Der Familienplaner speichert nur Termine (VEVENT), keine Aufgaben oder Journale.");
}
var parsed = IcsImporter.Parse(NormalizeWeekStart(text), zone);
if (parsed.IsInvalid)
{
return Error(StatusCodes.Status403Forbidden, C + "valid-calendar-data", "Keine gültige iCalendar-Datei.");
}
if (parsed.Warnings.Count > 0 || parsed.Events.Count == 0)
{
// Nicht unterstützte Wiederholungen würden sonst stillschweigend zu einem Einzeltermin – das wäre Datenverlust für den Client.
return Error(StatusCodes.Status403Forbidden, C + "valid-calendar-object-resource", string.Join(" ", parsed.Warnings.DefaultIfEmpty("Die Datei enthält keinen Termin.")));
}
if (parsed.Events.Any(e => e.Event.Uid != uid))
{
return Error(StatusCodes.Status403Forbidden, C + "valid-calendar-object-resource", "Alle Termine müssen die UID des Dateinamens tragen (<UID>.ics).");
}
var masters = parsed.Events.Where(e => e.RecurrenceId is null).ToList();
var changes = parsed.Events.Where(e => e.RecurrenceId is not null).ToList();
if (masters.Count != 1 || changes.Select(e => e.RecurrenceId).Distinct().Count() != changes.Count)
{
return Error(StatusCodes.Status403Forbidden, C + "valid-calendar-object-resource", "Erwartet wird genau ein Haupttermin je UID (plus Änderungen einzelner Vorkommen).");
}
if (changes.Count > 0 && masters[0].Event.RecurrenceRule is null)
{
return Error(StatusCodes.Status403Forbidden, C + "valid-calendar-object-resource", "Änderungen einzelner Vorkommen setzen eine Wiederholung voraus.");
}
master = masters[0].Event;
overrides = changes.Select(e => { e.Event.RecurrenceId = e.RecurrenceId; return e.Event; }).ToList();
return null;
}
private static readonly Regex RuleLine = new(@"^RRULE:(?<rule>.*)$", RegexOptions.Multiline | RegexOptions.CultureInvariant);
/// <summary>
/// Android/Google senden oft <c>WKST=SU</c>. Der Wochenbeginn ändert die Termine nur bei „alle n Wochen“ (INTERVAL &gt; 1) mit mehreren Wochentagen;
/// in allen anderen Fällen ist er unerheblich und wird entfernt, damit die Regel in die unterstützte Teilmenge (WKST=MO) fällt. Sonst bleibt er stehen
/// und der Termin wird abgelehnt (ehrlicher als eine still abweichende Serie).
/// </summary>
internal static string NormalizeWeekStart(string ics) =>
RuleLine.Replace(ics, match =>
{
var rule = match.Groups["rule"].Value.TrimEnd('\r');
var parts = rule.Split(';').ToList();
var weekStart = parts.FirstOrDefault(p => p.StartsWith("WKST=", StringComparison.OrdinalIgnoreCase));
if (weekStart is null || weekStart.Equals("WKST=MO", StringComparison.OrdinalIgnoreCase))
{
return match.Value;
}
var weekly = parts.Contains("FREQ=WEEKLY", StringComparer.OrdinalIgnoreCase);
var interval = parts.FirstOrDefault(p => p.StartsWith("INTERVAL=", StringComparison.OrdinalIgnoreCase));
var intervalValue = interval is not null && int.TryParse(interval["INTERVAL=".Length..], out var n) ? n : 1;
var multipleDays = parts.FirstOrDefault(p => p.StartsWith("BYDAY=", StringComparison.OrdinalIgnoreCase)) is { } byDay && byDay.Contains(',', StringComparison.Ordinal);
if (weekly && intervalValue > 1 && multipleDays)
{
return match.Value;
}
parts.Remove(weekStart);
return "RRULE:" + string.Join(';', parts) + (match.Value.EndsWith('\r') ? "\r" : string.Empty);
});
private static void Attach(CalendarEvent o, CalendarEvent master)
{
o.CalendarId = master.CalendarId;
o.SeriesMasterId = master.Id;
o.Uid = master.Uid;
o.PersonIds = [.. master.PersonIds];
o.ForWholeFamily = master.ForWholeFamily;
o.RecurrenceRule = null;
o.ExceptionDates = [];
}
/// <summary>
/// Überträgt den Inhalt auf die vorhandenen Zeilen (Ids, Ersteller, Personen-Tags und „ganze Familie“ bleiben erhalten – Clients kennen sie nicht)
/// und gleicht die Überschreibungen über RECURRENCE-ID ab.
/// </summary>
private static void ApplyUpdate(AppDbContext db, DavResource existing, CalendarEvent incoming, List<CalendarEvent> incomingOverrides)
{
var master = existing.Master;
CopyContent(master, incoming);
master.RecurrenceRule = incoming.RecurrenceRule;
master.ExceptionDates = incoming.RecurrenceRule is null ? [] : incoming.ExceptionDates;
var current = existing.Overrides.ToDictionary(o => o.RecurrenceId!.Value);
var keep = new HashSet<DateTimeOffset>();
foreach (var change in master.RecurrenceRule is null ? [] : incomingOverrides)
{
var key = change.RecurrenceId!.Value;
keep.Add(key);
if (current.TryGetValue(key, out var row))
{
CopyContent(row, change);
}
else
{
change.CalendarId = master.CalendarId;
Attach(change, master);
db.CalendarEvents.Add(change);
}
}
foreach (var (key, row) in current.Where(kv => !keep.Contains(kv.Key)))
{
db.CalendarEvents.Remove(row);
}
}
private static void CopyContent(CalendarEvent target, CalendarEvent source)
{
target.Title = source.Title;
target.Location = source.Location;
target.Notes = source.Notes;
target.IsAllDay = source.IsAllDay;
target.StartUtc = source.StartUtc;
target.EndUtc = source.EndUtc;
target.TimeZoneId = source.TimeZoneId;
}
// ---------- DELETE ----------
private static async Task<IResult> DeleteAsync(Ctx c, DavPath path, CalendarNotifier notifier)
{
if (path.Kind != DavKind.Event)
{
return path.Kind == DavKind.Calendar && c.Calendars.ContainsKey(path.CalendarId!.Value)
? Error(StatusCodes.Status403Forbidden, DAV("forbidden"), "Kalender können nur in der Verwaltung des Familienplaners gelöscht werden.")
: new DavResult(StatusCodes.Status404NotFound);
}
if (!c.Calendars.TryGetValue(path.CalendarId!.Value, out var calendar))
{
return new DavResult(StatusCodes.Status404NotFound);
}
var cid = calendar.Calendar.Id;
var uid = path.Uid!;
var rows = await c.Db.CalendarEvents.Where(e => e.CalendarId == cid && e.Uid == uid).ToListAsync(c.Ct);
if (CalDavResources.Build(cid, DateTimeOffset.MinValue, rows).Resources.SingleOrDefault() is not { } resource)
{
return new DavResult(StatusCodes.Status404NotFound);
}
if (!CalendarRules.CanModify(calendar.Access, resource.Master.CreatedBy, c.UserId))
{
return Error(StatusCodes.Status403Forbidden, DAV("need-privileges"), "Keine Berechtigung zum Löschen dieses Termins.");
}
var ifMatch = c.Http.Request.Headers.IfMatch.ToString();
if (!string.IsNullOrWhiteSpace(ifMatch) && !CalDavResources.MatchesAny(ifMatch, resource.ETag))
{
return new DavResult(StatusCodes.Status412PreconditionFailed);
}
c.Db.CalendarEvents.RemoveRange(rows);
try
{
await c.Db.SaveChangesAsync(c.Ct);
}
catch (DbUpdateConcurrencyException)
{
return new DavResult(StatusCodes.Status412PreconditionFailed);
}
await notifier.ChangedAsync(cid, c.Ct);
return new DavResult(StatusCodes.Status204NoContent);
}
// ---------- Laden ----------
private static async Task<DavCollectionState> LoadStateAsync(Ctx c, FamilyCalendar calendar)
{
var rows = await c.Db.CalendarEvents.IgnoreQueryFilters().AsNoTracking().Where(e => e.CalendarId == calendar.Id).ToListAsync(c.Ct);
return CalDavResources.Build(calendar.Id, calendar.UpdatedAt, rows);
}
private static async Task<DavResource?> FindResourceAsync(Ctx c, FamilyCalendar calendar, string uid)
{
var rows = await c.Db.CalendarEvents.AsNoTracking().Where(e => e.CalendarId == calendar.Id && e.Uid == uid).ToListAsync(c.Ct);
return CalDavResources.Build(calendar.Id, DateTimeOffset.MinValue, rows).Resources.SingleOrDefault();
}
/// <summary>Änderungsstand (CTag) aller sichtbaren Kalender in einer Abfrage.</summary>
private static async Task<Dictionary<Guid, DateTimeOffset>> StampsAsync(Ctx c)
{
var ids = c.Calendars.Keys.ToList();
var latest = (await c.Db.CalendarEvents.IgnoreQueryFilters().Where(e => ids.Contains(e.CalendarId))
.GroupBy(e => e.CalendarId).Select(g => new { Id = g.Key, Max = g.Max(e => e.UpdatedAt) }).ToListAsync(c.Ct))
.ToDictionary(x => x.Id, x => x.Max);
return ids.ToDictionary(id => id, id => latest.TryGetValue(id, out var max) && max > c.Calendars[id].Calendar.UpdatedAt ? max : c.Calendars[id].Calendar.UpdatedAt);
}
/// <summary>Liest den Körper als Text; <c>null</c>, wenn er größer als 1 MB ist. Leer = leerer Text.</summary>
private static async Task<string?> ReadBodyAsync(HttpRequest request, CancellationToken ct)
{
if (request.ContentLength > CalDavXml.MaxRequestBytes)
{
return null;
}
using var buffer = new MemoryStream();
var chunk = new byte[16 * 1024];
int read;
while ((read = await request.Body.ReadAsync(chunk, ct)) > 0)
{
if (buffer.Length + read > CalDavXml.MaxRequestBytes)
{
return null;
}
buffer.Write(chunk, 0, read);
}
return Encoding.UTF8.GetString(buffer.ToArray());
}
}
@@ -0,0 +1,249 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using FamilyPlanner.Domain.Calendar;
namespace FamilyPlanner.Api.Features.CalDav;
/// <summary>Art der Adresse unter <c>/caldav</c>.</summary>
public enum DavKind
{
Unknown,
Root,
PrincipalsRoot,
Principal,
Home,
Calendar,
Event,
}
/// <summary>
/// Adressraum (ADR-0017): <c>/caldav/</c> (Wurzel), <c>/caldav/principals/{userId}/</c>, <c>/caldav/calendars/</c> (calendar-home-set),
/// <c>/caldav/calendars/{calendarId}/</c> (ein Kalender) und <c>/caldav/calendars/{calendarId}/{uid}.ics</c> (ein Termin samt Ausnahmen).
/// </summary>
public sealed record DavPath(DavKind Kind, Guid? PrincipalId = null, Guid? CalendarId = null, string? Uid = null)
{
public const string RootPath = "/caldav/";
public const string PrincipalsPath = "/caldav/principals/";
public const string HomePath = "/caldav/calendars/";
public const string ResourceExtension = ".ics";
public static string Principal(Guid userId) => $"{PrincipalsPath}{userId:D}/";
public static string Calendar(Guid calendarId) => $"{HomePath}{calendarId:D}/";
public static string Event(Guid calendarId, string uid) => Calendar(calendarId) + EncodeName(uid) + ResourceExtension;
/// <summary>
/// Wandelt einen unverarbeiteten Pfad (noch prozentkodiert, ggf. mit Host oder Query) in einen <see cref="DavPath"/>.
/// Jedes Segment wird genau einmal dekodiert.
/// </summary>
public static DavPath Parse(string? rawPath)
{
if (string.IsNullOrEmpty(rawPath))
{
return new DavPath(DavKind.Unknown);
}
var path = rawPath;
if (path.Contains("://", StringComparison.Ordinal) && Uri.TryCreate(path, UriKind.Absolute, out var absolute))
{
path = absolute.AbsolutePath;
}
var queryStart = path.IndexOfAny(['?', '#']);
if (queryStart >= 0)
{
path = path[..queryStart];
}
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length == 0 || !string.Equals(segments[0], "caldav", StringComparison.OrdinalIgnoreCase))
{
return new DavPath(DavKind.Unknown);
}
// YARP kodiert ein bereits kodiertes %2F beim Weiterleiten erneut (%252F). Ein Schrägstrich ist in UIDs möglich (Importe), ein
// wörtliches „%2F“ praktisch nie – daher gilt nach dem Dekodieren verbliebenes %2F als Schrägstrich.
static string Decode(string segment) =>
Uri.UnescapeDataString(segment).Replace("%2F", "/", StringComparison.OrdinalIgnoreCase);
switch (segments.Length)
{
case 1:
return new DavPath(DavKind.Root);
case 2 when segments[1] == "principals":
return new DavPath(DavKind.PrincipalsRoot);
case 3 when segments[1] == "principals":
return Guid.TryParse(Decode(segments[2]), out var userId) ? new DavPath(DavKind.Principal, PrincipalId: userId) : new DavPath(DavKind.Unknown);
case 2 when segments[1] == "calendars":
return new DavPath(DavKind.Home);
case 3 when segments[1] == "calendars":
return Guid.TryParse(Decode(segments[2]), out var calendarId) ? new DavPath(DavKind.Calendar, CalendarId: calendarId) : new DavPath(DavKind.Unknown);
case 4 when segments[1] == "calendars" && Guid.TryParse(Decode(segments[2]), out var cid):
var name = Decode(segments[3]);
return name.EndsWith(ResourceExtension, StringComparison.OrdinalIgnoreCase) && name.Length > ResourceExtension.Length
? new DavPath(DavKind.Event, CalendarId: cid, Uid: name[..^ResourceExtension.Length])
: new DavPath(DavKind.Unknown);
default:
return new DavPath(DavKind.Unknown);
}
}
/// <summary>
/// Ressourcenname aus der UID: unreservierte Zeichen und <c>@</c> bleiben, alles andere wird UTF-8-prozentkodiert
/// (auch <c>/</c> und <c>%</c>, damit die UID ein einzelnes Segment bleibt).
/// </summary>
public static string EncodeName(string uid)
{
var builder = new StringBuilder(uid.Length + 8);
foreach (var b in Encoding.UTF8.GetBytes(uid))
{
var c = (char)b;
if (b < 0x80 && (char.IsAsciiLetterOrDigit(c) || c is '-' or '.' or '_' or '~' or '@'))
{
builder.Append(c);
}
else
{
builder.Append('%').Append(b.ToString("X2", CultureInfo.InvariantCulture));
}
}
return builder.ToString();
}
}
/// <summary>Ein Termin im Sinne von CalDAV: Serie (oder Einzeltermin) mit allen Überschreibungen unter einer UID.</summary>
public sealed record DavResource(
Guid CalendarId,
string Uid,
CalendarEvent Master,
IReadOnlyList<CalendarEvent> Overrides,
DateTimeOffset LastModified,
string ETag)
{
public IEnumerable<CalendarEvent> Events => Overrides.Prepend(Master);
}
/// <summary>Ein gelöschter Termin (für <c>sync-collection</c>: Antwort 404).</summary>
public sealed record DavTombstone(string Uid, DateTimeOffset DeletedAt);
/// <summary>Zustand eines Kalenders: lebende Ressourcen, Löschungen und der Änderungsstand (CTag / Sync-Token).</summary>
public sealed record DavCollectionState(IReadOnlyList<DavResource> Resources, IReadOnlyList<DavTombstone> Tombstones, DateTimeOffset Stamp);
public static class CalDavResources
{
/// <summary>
/// Gruppiert die Zeilen (auch gelöschte!) eines Kalenders nach UID. Der Änderungsstand eines Termins ist die jüngste Änderung
/// aller seiner Zeilen inklusive gelöschter Überschreibungen; der des Kalenders die jüngste Änderung überhaupt.
/// </summary>
public static DavCollectionState Build(Guid calendarId, DateTimeOffset calendarUpdatedAt, IEnumerable<CalendarEvent> rows)
{
var all = rows.ToList();
var stamp = all.Select(e => e.UpdatedAt).Append(calendarUpdatedAt).Max();
var resources = new List<DavResource>();
var tombstones = new List<DavTombstone>();
foreach (var group in all.GroupBy(e => e.Uid, StringComparer.Ordinal))
{
var masters = group.Where(e => e.SeriesMasterId is null).ToList();
var master = masters.FirstOrDefault(e => !e.IsDeleted);
var lastModified = group.Max(e => e.UpdatedAt);
if (master is null)
{
if (masters.Count > 0)
{
tombstones.Add(new DavTombstone(group.Key, lastModified));
}
continue;
}
var overrides = group.Where(e => !e.IsDeleted && e.SeriesMasterId == master.Id).OrderBy(e => e.RecurrenceId).ToList();
resources.Add(new DavResource(calendarId, group.Key, master, overrides, lastModified, ETag(overrides.Prepend(master))));
}
return new DavCollectionState(resources.OrderBy(r => r.Uid, StringComparer.Ordinal).ToList(), tombstones, stamp);
}
/// <summary>Starker ETag (in Anführungszeichen) über Id, RowVersion und Änderungszeit aller Zeilen des Termins.</summary>
public static string ETag(IEnumerable<CalendarEvent> rows)
{
var builder = new StringBuilder();
foreach (var row in rows.OrderBy(r => r.Id))
{
builder.Append(row.Id.ToString("N")).Append(':')
.Append(Convert.ToHexString(row.RowVersion)).Append(':')
.Append(row.UpdatedAt.UtcTicks.ToString(CultureInfo.InvariantCulture)).Append(';');
}
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()));
return "\"" + Convert.ToBase64String(hash)[..27].Replace('+', '-').Replace('/', '_') + "\"";
}
/// <summary>Entspricht ein If-Match/If-None-Match-Wert (Liste, <c>*</c>, schwache Tags) dem ETag?</summary>
public static bool MatchesAny(string? headerValue, string etag)
{
if (string.IsNullOrWhiteSpace(headerValue))
{
return false;
}
foreach (var part in headerValue.Split(','))
{
var tag = part.Trim();
if (tag == "*")
{
return true;
}
if (tag.StartsWith("W/", StringComparison.Ordinal))
{
tag = tag[2..];
}
if (string.Equals(tag, etag, StringComparison.Ordinal))
{
return true;
}
}
return false;
}
}
/// <summary>Sync-Token / CTag (RFC 6578): die jüngste Änderung des Kalenders in Ticks. Zustandslos, daher ohne Tabelle für Tokens.</summary>
public static class CalDavSyncToken
{
private const string Prefix = "urn:familyplanner:sync:";
public static string Create(DateTimeOffset stamp) => Prefix + stamp.UtcTicks.ToString(CultureInfo.InvariantCulture);
/// <summary>Leeres Token = Erstsynchronisation (<c>since = null</c>). Ungültig: <c>false</c>.</summary>
public static bool TryParse(string? token, out DateTimeOffset? since)
{
since = null;
if (string.IsNullOrWhiteSpace(token))
{
return true;
}
token = token.Trim();
if (!token.StartsWith(Prefix, StringComparison.Ordinal)
|| !long.TryParse(token[Prefix.Length..], NumberStyles.None, CultureInfo.InvariantCulture, out var ticks)
|| ticks > DateTimeOffset.MaxValue.UtcTicks)
{
return false;
}
since = new DateTimeOffset(ticks, TimeSpan.Zero);
return true;
}
/// <summary>Seit dem Token geänderte Ressourcen und Löschungen. Strikt „größer“: ein Gleichstand gilt als bereits gesehen.</summary>
public static (IReadOnlyList<DavResource> Changed, IReadOnlyList<DavTombstone> Removed) Delta(DavCollectionState state, DateTimeOffset? since) =>
since is null
? (state.Resources, [])
: (state.Resources.Where(r => r.LastModified > since).ToList(), state.Tombstones.Where(t => t.DeletedAt > since).ToList());
}
@@ -0,0 +1,267 @@
using System.Globalization;
using System.Text;
using System.Xml;
using System.Xml.Linq;
using FamilyPlanner.Domain.Calendar;
namespace FamilyPlanner.Api.Features.CalDav;
/// <summary>XML-Namen und Hilfen für WebDAV/CalDAV (RFC 4918, 4791, 6578). Nur System.Xml.Linq, ohne zusätzliche Pakete.</summary>
public static class CalDavXml
{
public static readonly XNamespace D = "DAV:";
public static readonly XNamespace C = "urn:ietf:params:xml:ns:caldav";
public static readonly XNamespace CS = "http://calendarserver.org/ns/";
public static readonly XNamespace IC = "http://apple.com/ns/ical/";
public const int MaxRequestBytes = 1024 * 1024;
/// <summary>Sicheres Einlesen: keine DTDs, keine externen Entitäten, begrenzte Größe. <c>null</c> bei leerem oder ungültigem Text.</summary>
public static XDocument? TryParse(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return null;
}
try
{
var settings = new XmlReaderSettings
{
DtdProcessing = DtdProcessing.Prohibit,
XmlResolver = null,
MaxCharactersInDocument = MaxRequestBytes,
};
using var reader = XmlReader.Create(new StringReader(text), settings);
return XDocument.Load(reader);
}
catch (XmlException)
{
return null;
}
}
public static string Serialize(XDocument document)
{
var settings = new XmlWriterSettings { Encoding = new UTF8Encoding(false), Indent = false, OmitXmlDeclaration = false };
using var stream = new MemoryStream();
using (var writer = XmlWriter.Create(stream, settings))
{
document.Save(writer);
}
return Encoding.UTF8.GetString(stream.ToArray());
}
private static XElement Root(XName name, params object?[] content) => new(
name,
new XAttribute(XNamespace.Xmlns + "D", D),
new XAttribute(XNamespace.Xmlns + "C", C),
new XAttribute(XNamespace.Xmlns + "CS", CS),
new XAttribute(XNamespace.Xmlns + "IC", IC),
content);
public static string Multistatus(IEnumerable<XElement> responses, string? syncToken = null) =>
Serialize(new XDocument(new XDeclaration("1.0", "utf-8", null), Root(D + "multistatus", responses, syncToken is null ? null : new XElement(D + "sync-token", syncToken))));
/// <summary>Fehlerantwort mit Vorbedingung, z. B. <c>CALDAV:valid-calendar-data</c> oder <c>DAV:valid-sync-token</c>.</summary>
public static string Error(XName precondition, string? description = null) =>
Serialize(new XDocument(new XDeclaration("1.0", "utf-8", null),
Root(D + "error", new XElement(precondition), description is null ? null : new XElement(D + "responsedescription", description))));
private static string StatusLine(int code) => code switch
{
200 => "HTTP/1.1 200 OK",
403 => "HTTP/1.1 403 Forbidden",
404 => "HTTP/1.1 404 Not Found",
_ => $"HTTP/1.1 {code.ToString(CultureInfo.InvariantCulture)}",
};
/// <summary>Eine <c>response</c> mit den gefundenen Eigenschaften (200) und den unbekannten (404).</summary>
public static XElement Response(string href, IEnumerable<XElement> found, IEnumerable<XName> notFound)
{
var response = new XElement(D + "response", new XElement(D + "href", href));
var foundList = found.ToList();
if (foundList.Count > 0)
{
response.Add(PropStat(foundList, 200));
}
var missing = notFound.Select(n => new XElement(n)).ToList();
if (missing.Count > 0)
{
response.Add(PropStat(missing, 404));
}
if (foundList.Count == 0 && missing.Count == 0)
{
response.Add(PropStat([], 200));
}
return response;
}
/// <summary>Eine <c>response</c> nur mit Status (gelöschte Ressource bei sync-collection, nicht gefundener href bei multiget).</summary>
public static XElement StatusResponse(string href, int status) =>
new(D + "response", new XElement(D + "href", href), new XElement(D + "status", StatusLine(status)));
public static XElement PropStatResponse(string href, IEnumerable<XName> names, int status) =>
new(D + "response", new XElement(D + "href", href), PropStat(names.Select(n => new XElement(n)).ToList(), status));
private static XElement PropStat(List<XElement> props, int status) =>
new(D + "propstat", new XElement(D + "prop", props), new XElement(D + "status", StatusLine(status)));
public static string DateTimeUtc(DateTimeOffset value) =>
value.UtcDateTime.ToString("yyyyMMdd'T'HHmmss'Z'", CultureInfo.InvariantCulture);
public static bool TryParseDateTimeUtc(string? text, out DateTimeOffset value)
{
value = default;
if (!DateTime.TryParseExact(text, "yyyyMMdd'T'HHmmss'Z'", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed))
{
return false;
}
value = new DateTimeOffset(DateTime.SpecifyKind(parsed, DateTimeKind.Utc));
return true;
}
}
/// <summary>Welche Eigenschaften will der Client (PROPFIND, REPORT)?</summary>
public sealed record DavPropRequest(bool AllProp, bool PropNameOnly, IReadOnlyList<XName> Names)
{
public static readonly DavPropRequest All = new(true, false, []);
public bool Wants(XName name) => AllProp || PropNameOnly || Names.Contains(name);
/// <summary>PROPFIND-Körper: leer oder <c>allprop</c> = alles; <c>propname</c>; sonst die Liste unter <c>prop</c>.</summary>
public static DavPropRequest FromPropfind(XDocument? body)
{
if (body?.Root is not { } root)
{
return All;
}
if (root.Element(CalDavXml.D + "propname") is not null)
{
return new DavPropRequest(false, true, []);
}
return root.Element(CalDavXml.D + "prop") is { } prop ? new DavPropRequest(false, false, prop.Elements().Select(e => e.Name).ToList()) : All;
}
/// <summary>Eigenschaften unter <c>DAV:prop</c> eines REPORT-Körpers.</summary>
public static DavPropRequest FromReport(XElement report) =>
report.Element(CalDavXml.D + "prop") is { } prop ? new DavPropRequest(false, false, prop.Elements().Select(e => e.Name).ToList()) : All;
}
/// <summary>Filter eines <c>calendar-query</c> (RFC 4791 §9.7), soweit unterstützt: VEVENT, time-range, prop-filter auf UID/SUMMARY/LOCATION/DESCRIPTION.</summary>
public sealed record CalendarQuery(bool MatchesEvents, DateTimeOffset? RangeStart, DateTimeOffset? RangeEnd, IReadOnlyList<PropFilter> PropFilters)
{
public static readonly CalendarQuery MatchAll = new(true, null, null, []);
public static CalendarQuery Parse(XElement query)
{
var filter = query.Element(CalDavXml.C + "filter");
var calendarFilter = filter?.Elements(CalDavXml.C + "comp-filter").FirstOrDefault(f => NameIs(f, "VCALENDAR"));
if (calendarFilter is null)
{
return MatchAll;
}
var children = calendarFilter.Elements(CalDavXml.C + "comp-filter").ToList();
if (children.Count == 0)
{
return MatchAll;
}
var eventFilter = children.FirstOrDefault(f => NameIs(f, "VEVENT"));
if (eventFilter is null)
{
return new CalendarQuery(false, null, null, []); // VTODO, VJOURNAL, VFREEBUSY: gibt es hier nicht
}
DateTimeOffset? start = null;
DateTimeOffset? end = null;
if (eventFilter.Element(CalDavXml.C + "time-range") is { } range)
{
start = CalDavXml.TryParseDateTimeUtc((string?)range.Attribute("start"), out var s) ? s : null;
end = CalDavXml.TryParseDateTimeUtc((string?)range.Attribute("end"), out var e) ? e : null;
}
var props = eventFilter.Elements(CalDavXml.C + "prop-filter").Select(PropFilter.Parse).ToList();
return new CalendarQuery(true, start, end, props);
}
/// <summary>Trifft der Termin den Filter? Bei Zeitbereich werden Serien expandiert (Überschreibungen und EXDATE beachtet).</summary>
public bool Matches(DavResource resource, DateTimeOffset now)
{
if (!MatchesEvents)
{
return false;
}
if (PropFilters.Any(f => !f.Matches(resource.Master)))
{
return false;
}
if (RangeStart is null && RangeEnd is null)
{
return true;
}
var from = RangeStart ?? DateTimeOffset.UnixEpoch.AddYears(-100);
var to = RangeEnd ?? now.AddYears(10);
return CalendarTime.Expand(resource.Events, from, to).Count > 0;
}
private static bool NameIs(XElement element, string name) =>
string.Equals((string?)element.Attribute("name"), name, StringComparison.OrdinalIgnoreCase);
}
/// <summary><c>prop-filter</c>: Eigenschaft ist (nicht) definiert oder enthält/gleicht einem Text (ohne Beachtung der Groß-/Kleinschreibung).</summary>
public sealed record PropFilter(string Name, bool IsNotDefined, string? Text, string MatchType, bool Negate)
{
public static PropFilter Parse(XElement element)
{
var textMatch = element.Element(CalDavXml.C + "text-match");
return new PropFilter(
((string?)element.Attribute("name") ?? string.Empty).ToUpperInvariant(),
element.Element(CalDavXml.C + "is-not-defined") is not null,
textMatch?.Value,
((string?)textMatch?.Attribute("match-type") ?? "contains").ToLowerInvariant(),
string.Equals((string?)textMatch?.Attribute("negate-condition"), "yes", StringComparison.OrdinalIgnoreCase));
}
public bool Matches(CalendarEvent master)
{
var value = Name switch
{
"UID" => master.Uid,
"SUMMARY" => master.Title,
"LOCATION" => master.Location,
"DESCRIPTION" => master.Notes,
_ => null, // nicht gespeicherte Eigenschaft: nie „definiert“
};
if (IsNotDefined)
{
return string.IsNullOrEmpty(value);
}
if (Text is null)
{
return !string.IsNullOrEmpty(value); // nur „Eigenschaft vorhanden“
}
var hit = value is not null && MatchType switch
{
"equals" => string.Equals(value, Text, StringComparison.OrdinalIgnoreCase),
"starts-with" => value.StartsWith(Text, StringComparison.OrdinalIgnoreCase),
"ends-with" => value.EndsWith(Text, StringComparison.OrdinalIgnoreCase),
_ => value.Contains(Text, StringComparison.OrdinalIgnoreCase),
};
return hit != Negate;
}
}
@@ -35,19 +35,23 @@ public static class IcsBuilder
IReadOnlyDictionary<Guid, string> calendarNames,
IReadOnlyDictionary<Guid, string> personNames,
IReadOnlyList<IcsTask> tasks,
IcsTaskMode taskMode)
IcsTaskMode taskMode,
bool resourceMode = false)
{
var zone = CalendarTime.Zone(timeZoneId);
var calendar = new IcalCalendar { ProductId = ProductId };
calendar.AddProperty("X-WR-CALNAME", feedName);
calendar.AddProperty("X-WR-TIMEZONE", timeZoneId);
calendar.AddProperty("REFRESH-INTERVAL;VALUE=DURATION", "PT15M");
calendar.AddProperty("X-PUBLISHED-TTL", "PT15M");
if (!resourceMode)
{
calendar.AddProperty("X-WR-CALNAME", feedName);
calendar.AddProperty("X-WR-TIMEZONE", timeZoneId);
calendar.AddProperty("REFRESH-INTERVAL;VALUE=DURATION", "PT15M");
calendar.AddProperty("X-PUBLISHED-TTL", "PT15M");
}
// Serien vor ihren Überschreibungen (manche Clients erwarten diese Reihenfolge).
foreach (var e in events.OrderBy(e => e.Uid, StringComparer.Ordinal).ThenBy(e => e.IsOverride).ThenBy(e => e.RecurrenceId))
{
calendar.Events.Add(ToIcal(e, timeZoneId, zone, calendarNames, personNames));
calendar.Events.Add(ToIcal(e, timeZoneId, zone, calendarNames, personNames, resourceMode));
}
foreach (var task in tasks.OrderBy(t => t.DueDate).ThenBy(t => t.Id))
@@ -74,14 +78,17 @@ public static class IcsBuilder
public static string TaskUid(Guid taskId) => $"task-{taskId:N}@familyplanner";
private static IcalEvent ToIcal(
DomainEvent e, string tzId, TimeZoneInfo zone, IReadOnlyDictionary<Guid, string> calendarNames, IReadOnlyDictionary<Guid, string> personNames)
DomainEvent e, string tzId, TimeZoneInfo zone, IReadOnlyDictionary<Guid, string> calendarNames, IReadOnlyDictionary<Guid, string> personNames,
bool resourceMode)
{
var ical = new IcalEvent
{
Uid = e.Uid,
Summary = e.Title,
Location = e.Location,
Description = Description(e, personNames),
// CalDAV-Ressource: nur die Notiz. Die „Für: …“-Zeile würde sonst bei jedem Speichern durch einen Client in die Notiz wandern.
Description = resourceMode ? e.Notes : Description(e, personNames),
Sequence = e.Sequence,
DtStamp = Utc(e.UpdatedAt),
LastModified = Utc(e.UpdatedAt),
@@ -90,7 +97,7 @@ public static class IcsBuilder
DtEnd = Local(e.EndUtc, e.IsAllDay, tzId, zone),
};
if (calendarNames.TryGetValue(e.CalendarId, out var calendarName))
if (!resourceMode && calendarNames.TryGetValue(e.CalendarId, out var calendarName))
{
ical.Categories = [calendarName];
}
@@ -1,10 +1,12 @@
using System.Globalization;
using System.Security.Claims;
using FamilyPlanner.Api.Features.CalDav;
using FamilyPlanner.Domain.Authorization;
using FamilyPlanner.Infrastructure.Identity;
using FamilyPlanner.Infrastructure.Persistence;
using FamilyPlanner.Infrastructure.Settings;
using FamilyPlanner.Shared;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.DataProtection;
@@ -29,6 +31,12 @@ internal static class AuthSetup
services.AddAuthentication(IdentityConstants.ApplicationScheme)
.AddIdentityCookies();
// CalDAV (ADR-0017): eigenes Basic-Schema mit App-Passwörtern, nur über die Policy "CalDav" aktiv – nie Standard, nie für /api.
services.AddAuthentication()
.AddScheme<AuthenticationSchemeOptions, CalDavBasicHandler>(CalDavBasicHandler.SchemeName, null);
services.AddOptions<CalDavOptions>().BindConfiguration(CalDavOptions.SectionName);
services.AddSingleton<CalDavFailureThrottle>();
services.AddFamilyPlannerIdentityCore()
.AddSignInManager<AppSignInManager>()
.AddClaimsPrincipalFactory<AppClaimsPrincipalFactory>()
@@ -90,6 +98,13 @@ internal static class AuthSetup
builder.AddPolicy(policy, p => p.Combine(PasswordChangedPolicy()).RequireRole(roles));
}
// CalDAV: nur das App-Passwort-Schema; Kalenderrechte je Kalender prüft der Slice bei jeder Anfrage.
builder.AddPolicy(CalDavEndpoints.PolicyName, new AuthorizationPolicyBuilder(CalDavBasicHandler.SchemeName)
.RequireAuthenticatedUser()
.AddRequirements(ActiveAccountRequirement.Instance)
.AddRequirements(new PermissionRequirement(Permission.CalendarRead))
.Build());
foreach (var permission in PermissionMatrix.All)
{
builder.AddPolicy(Policies.For(permission), p => p
@@ -6,6 +6,7 @@ namespace FamilyPlanner.Api.Infrastructure;
internal static class HostingSetup
{
public const string LoginRateLimitPolicy = "login";
public const string CalDavRateLimitPolicy = "caldav";
public const string GifSearchRateLimitPolicy = "gif-search";
public const string GifPreviewRateLimitPolicy = "gif-preview";
@@ -28,6 +29,17 @@ internal static class HostingSetup
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
}));
// CalDAV (ADR-0017): großzügig je IP (iOS stellt bei der Erstsynchronisation viele Anfragen); Fehlversuche begrenzt CalDavFailureThrottle.
options.AddPolicy(CalDavRateLimitPolicy, context =>
RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = builder.Configuration.GetValue("RateLimiting:CalDavPermitLimit", 600),
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
}));
// GIF-Suche und -Vorschauen (ADR-0022): je angemeldeter Person begrenzt, damit niemand den Giphy-Schlüssel der Familie leerfragt.
options.AddPolicy(GifSearchRateLimitPolicy, context => PerUser(context, 20));
options.AddPolicy(GifPreviewRateLimitPolicy, context => PerUser(context, 200));
+3
View File
@@ -2,6 +2,7 @@ using FamilyPlanner.Api.Features.Admin;
using FamilyPlanner.Api.Features.Ai;
using FamilyPlanner.Api.Features.Auth;
using FamilyPlanner.Api.Features.Calendar;
using FamilyPlanner.Api.Features.CalDav;
using FamilyPlanner.Api.Features.Chat;
using FamilyPlanner.Api.Features.Dashboard;
using FamilyPlanner.Api.Features.Learning;
@@ -101,6 +102,8 @@ app.MapPushEndpoints();
app.MapCalendarEndpoints();
app.MapCalendarAdminEndpoints();
app.MapFeedEndpoints();
app.MapCalDavEndpoints();
app.MapCalDavCredentialEndpoints();
app.MapDashboardEndpoints();
app.MapChatEndpoints();
app.MapAiEndpoints();
@@ -4,5 +4,8 @@
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"CalDav": {
"RequireHttps": false
}
}
@@ -36,6 +36,8 @@ public static class AuditActions
public const string CalendarDeleted = "calendar.deleted";
public const string CalendarImported = "calendar.imported";
public const string FeedRevoked = "calendar.feed_revoked";
public const string CalDavCredentialCreated = "calendar.caldav_credential_created";
public const string CalDavCredentialRevoked = "calendar.caldav_credential_revoked";
public const string AiProviderCreated = "ai.provider_created";
public const string AiProviderUpdated = "ai.provider_updated";
public const string AiProviderDeleted = "ai.provider_deleted";
@@ -56,6 +58,8 @@ public static class AuditActions
CalendarDeleted => "Kalender gelöscht",
CalendarImported => "Kalender importiert",
FeedRevoked => "Kalender-Abo widerrufen",
CalDavCredentialCreated => "App-Passwort (Kalender-Sync) erstellt",
CalDavCredentialRevoked => "App-Passwort (Kalender-Sync) widerrufen",
AiProviderCreated => "KI-Anbieter angelegt",
AiProviderUpdated => "KI-Anbieter geändert",
AiProviderDeleted => "KI-Anbieter gelöscht",
@@ -0,0 +1,70 @@
using System.Security.Cryptography;
using System.Text;
using FamilyPlanner.Domain.Common;
namespace FamilyPlanner.Domain.Calendar;
/// <summary>
/// App-Passwort eines Benutzers für CalDAV-Clients (iOS, Thunderbird, DAVx5; ADR-0017). Nie das normale Passwort: Das Geheimnis wird
/// einmalig angezeigt, nur als SHA-256 gespeichert und kann jederzeit widerrufen werden. Es gilt ausschließlich für <c>/caldav</c>.
/// </summary>
public sealed class CalDavCredential : AuditableEntity
{
public const int LabelMaxLength = 60;
public const int MaxActivePerUser = 10;
public Guid UserId { get; set; }
/// <summary>Bezeichnung, z. B. „iPhone von Anna“.</summary>
public string Label { get; set; } = string.Empty;
/// <summary>SHA-256 des normalisierten App-Passworts (hex, kleingeschrieben).</summary>
public string SecretHash { get; set; } = string.Empty;
/// <summary>Erste Zeichen zur Wiedererkennung in der Verwaltung.</summary>
public string SecretPrefix { get; set; } = string.Empty;
public DateTimeOffset? LastUsedAt { get; set; }
/// <summary>User-Agent des letzten Zugriffs (gekürzt) – hilft beim Erkennen des Geräts.</summary>
public string? LastUsedClient { get; set; }
public DateTimeOffset? RevokedAt { get; set; }
public Guid? RevokedBy { get; set; }
public bool IsActive => RevokedAt is null;
}
/// <summary>
/// App-Passwörter: 20 Zeichen aus einem 31er-Alphabet (rund 99 Bit Zufall), gruppiert zu <c>xxxx-xxxx-xxxx-xxxx-xxxx</c>.
/// Wegen der hohen Entropie genügt ein schneller Hash (Basic-Auth sendet das Geheimnis bei jeder Anfrage).
/// </summary>
public static class CalDavSecret
{
public const int PrefixLength = 4;
private const string Alphabet = "abcdefghjkmnpqrstuvwxyz23456789"; // ohne l, i, o, 0, 1 (Verwechslungsgefahr)
private const int Length = 20;
public static string Generate()
{
var chars = new char[Length];
for (var i = 0; i < Length; i++)
{
chars[i] = Alphabet[RandomNumberGenerator.GetInt32(Alphabet.Length)];
}
return string.Join('-', Enumerable.Range(0, Length / 4).Select(g => new string(chars, g * 4, 4)));
}
/// <summary>Entfernt Trenner/Leerzeichen und vereinheitlicht die Schreibweise (iOS fügt gern Leerzeichen ein).</summary>
public static string Normalize(string? secret) =>
new((secret ?? string.Empty).Where(char.IsAsciiLetterOrDigit).Select(char.ToLowerInvariant).ToArray());
public static bool IsWellFormed(string? secret) => Normalize(secret).Length == Length;
public static string Hash(string secret) =>
Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(Normalize(secret))));
public static string Prefix(string secret) => Normalize(secret)[..PrefixLength];
}
@@ -60,6 +60,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<IcsFeed> IcsFeeds => Set<IcsFeed>();
public DbSet<CalDavCredential> CalDavCredentials => Set<CalDavCredential>();
public DbSet<ChatChannel> ChatChannels => Set<ChatChannel>();
public DbSet<ChatMember> ChatMembers => Set<ChatMember>();
@@ -49,6 +49,21 @@ internal sealed class CalendarEventConfiguration : IEntityTypeConfiguration<Cale
}
}
internal sealed class CalDavCredentialConfiguration : IEntityTypeConfiguration<CalDavCredential>
{
public void Configure(EntityTypeBuilder<CalDavCredential> builder)
{
builder.ToTable("CalDavCredentials");
builder.Property(c => c.Label).HasMaxLength(CalDavCredential.LabelMaxLength).IsRequired();
builder.Property(c => c.SecretHash).HasMaxLength(64).IsRequired();
builder.Property(c => c.SecretPrefix).HasMaxLength(CalDavSecret.PrefixLength).IsRequired();
builder.Property(c => c.LastUsedClient).HasMaxLength(100);
builder.Ignore(c => c.IsActive);
builder.HasIndex(c => c.SecretHash).IsUnique();
builder.HasIndex(c => c.UserId);
}
}
internal sealed class IcsFeedConfiguration : IEntityTypeConfiguration<IcsFeed>
{
public void Configure(EntityTypeBuilder<IcsFeed> builder)
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,56 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FamilyPlanner.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class Phase12cCalDav : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "CalDavCredentials",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Label = table.Column<string>(type: "nvarchar(60)", maxLength: 60, nullable: false),
SecretHash = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
SecretPrefix = table.Column<string>(type: "nvarchar(4)", maxLength: 4, nullable: false),
LastUsedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
LastUsedClient = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
RevokedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
RevokedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_CalDavCredentials", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_CalDavCredentials_SecretHash",
table: "CalDavCredentials",
column: "SecretHash",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_CalDavCredentials_UserId",
table: "CalDavCredentials",
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CalDavCredentials");
}
}
}
@@ -298,6 +298,65 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
b.ToTable("AuditLog", (string)null);
});
modelBuilder.Entity("FamilyPlanner.Domain.Calendar.CalDavCredential", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("nvarchar(60)");
b.Property<DateTimeOffset?>("LastUsedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("LastUsedClient")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("RevokedBy")
.HasColumnType("uniqueidentifier");
b.Property<string>("SecretHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("SecretPrefix")
.IsRequired()
.HasMaxLength(4)
.HasColumnType("nvarchar(4)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("SecretHash")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("CalDavCredentials", (string)null);
});
modelBuilder.Entity("FamilyPlanner.Domain.Calendar.CalendarEvent", b =>
{
b.Property<Guid>("Id")
@@ -0,0 +1,32 @@
using FamilyPlanner.Domain.Calendar;
using FluentValidation;
namespace FamilyPlanner.Shared.Calendar;
/// <summary>Ein App-Passwort für Kalender-Apps (ohne das Geheimnis).</summary>
public sealed record CalDavCredentialDto(
Guid Id,
string Label,
string SecretPrefix,
DateTimeOffset CreatedAt,
DateTimeOffset? LastUsedAt,
string? LastUsedClient,
DateTimeOffset? RevokedAt);
public sealed record CreateCalDavCredentialRequest(string Label);
/// <summary>Neu erzeugtes App-Passwort – das Geheimnis wird nur in dieser Antwort geliefert.</summary>
public sealed record CalDavCredentialSecretDto(CalDavCredentialDto Credential, string Secret);
/// <summary>Verbindungsdaten für Kalender-Apps: Server-Adresse und Benutzername des Angemeldeten.</summary>
public sealed record CalDavInfoDto(string ServerUrl, string UserName, bool IsSecure);
public sealed class CreateCalDavCredentialRequestValidator : AbstractValidator<CreateCalDavCredentialRequest>
{
public CreateCalDavCredentialRequestValidator()
{
RuleFor(r => r.Label)
.NotEmpty().WithMessage("Bitte eine Bezeichnung eingeben, z. B. „iPhone von Anna“.")
.MaximumLength(CalDavCredential.LabelMaxLength).WithMessage($"Die Bezeichnung darf höchstens {CalDavCredential.LabelMaxLength} Zeichen haben.");
}
}
@@ -19,6 +19,15 @@
<MudText Typo="Typo.body2" Class="mb-2">Erinnerungen auf diesem Gerät ein- oder ausschalten.</MudText>
<MudButton Href="konto/benachrichtigungen" Variant="Variant.Outlined" Color="Color.Primary" Class="fp-settings-notifications"
StartIcon="@Icons.Material.Filled.Notifications">Benachrichtigungen einstellen</MudButton>
<MudDivider Class="my-4" />
<MudText Typo="Typo.h6" HtmlTag="h2" Class="mb-2">Kalender freigeben / Mit Handy verbinden</MudText>
<MudText Typo="Typo.body2" Class="mb-2">
Familienkalender in iPhone, Mac, Android oder Thunderbird einbinden – mit Zwei-Wege-Abgleich über App-Passwörter.
</MudText>
<MudButton Href="konto/kalender-verbinden" Variant="Variant.Outlined" Color="Color.Primary" Class="fp-settings-caldav"
StartIcon="@Icons.Material.Filled.Sync">Kalender verbinden</MudButton>
</MudPaper>
@code {
@@ -0,0 +1,249 @@
@page "/konto/kalender-verbinden"
@using FamilyPlanner.Domain.Calendar
@using FamilyPlanner.Shared.Calendar
@inject ICalDavApi Api
@inject IJSRuntime JS
@inject ISnackbar Snackbar
@inject IDialogService DialogService
<PageTitle>Kalender mit dem Handy verbinden – Familienplaner</PageTitle>
<div class="d-flex align-center gap-2 mb-1">
<MudIconButton Icon="@Icons.Material.Filled.ArrowBack" Href="konto/einstellungen" aria-label="Zurück zu den Einstellungen" />
<MudText Typo="Typo.h4" HtmlTag="h1">Kalender mit dem Handy verbinden</MudText>
</div>
<MudText Typo="Typo.body1" Class="mb-4">
Mit der Kalender-Synchronisation (CalDAV) erscheinen die Familienkalender in der Kalender-App deines iPhones, Macs, Androids oder in Thunderbird –
und Termine, die du dort anlegst oder änderst, landen automatisch im Familienplaner (Zwei-Wege-Abgleich). Du siehst und bearbeitest dort genau das,
was du auch hier darfst.
</MudText>
@if (_info is { IsSecure: false })
{
<MudAlert Severity="Severity.Warning" Class="mb-4 fp-caldav-insecure" role="alert">
Diese Seite wurde ohne HTTPS geöffnet. Kalender-Apps senden das App-Passwort bei jeder Abfrage mit – verbinde sie nur über eine
https-Adresse, sonst kann es im Netzwerk mitgelesen werden.
</MudAlert>
}
@if (_secret is { } secret)
{
<MudPaper Outlined="true" Class="pa-4 mb-4 fp-caldav-secret">
<MudText Typo="Typo.h6" HtmlTag="h2">Dein neues App-Passwort: @secret.Credential.Label</MudText>
<MudAlert Severity="Severity.Warning" Dense="true" Class="my-2">
Das Passwort ist nur jetzt sichtbar. Trage es gleich in der Kalender-App ein. Es ersetzt dein normales Passwort <b>nicht</b> – dafür
kannst du es jederzeit widerrufen.
</MudAlert>
<MudTextField T="string" Value="@secret.Secret" Label="App-Passwort" ReadOnly="true" Variant="Variant.Outlined" InputId="caldav-secret"
Adornment="Adornment.End" AdornmentIcon="@Icons.Material.Filled.ContentCopy" AdornmentAriaLabel="App-Passwort kopieren"
OnAdornmentClick="() => CopyAsync(secret.Secret)" />
</MudPaper>
}
<MudGrid Spacing="3">
<MudItem xs="12" md="6">
<MudPaper Outlined="true" Class="pa-4 fp-caldav-info">
<MudText Typo="Typo.h6" HtmlTag="h2" Class="mb-2">Verbindungsdaten</MudText>
@if (_info is null)
{
<LoadingIndicator />
}
else
{
<MudStack Spacing="2">
<MudTextField T="string" Value="@_info.ServerUrl" Label="Server-Adresse" ReadOnly="true" Variant="Variant.Outlined" InputId="caldav-url"
Adornment="Adornment.End" AdornmentIcon="@Icons.Material.Filled.ContentCopy" AdornmentAriaLabel="Server-Adresse kopieren"
OnAdornmentClick="() => CopyAsync(_info.ServerUrl)" />
<MudTextField T="string" Value="@_info.UserName" Label="Benutzername" ReadOnly="true" Variant="Variant.Outlined" InputId="caldav-user"
Adornment="Adornment.End" AdornmentIcon="@Icons.Material.Filled.ContentCopy" AdornmentAriaLabel="Benutzername kopieren"
OnAdornmentClick="() => CopyAsync(_info.UserName)" />
<MudText Typo="Typo.body2">Passwort: ein App-Passwort (siehe unten) – nicht dein normales Passwort.</MudText>
</MudStack>
}
</MudPaper>
</MudItem>
<MudItem xs="12" md="6">
<MudPaper Outlined="true" Class="pa-4">
<MudText Typo="Typo.h6" HtmlTag="h2" Class="mb-2">Neues App-Passwort</MudText>
<EditForm Model="this" OnSubmit="CreateAsync" FormName="caldav-create" id="caldav-form">
<MudStack Spacing="2">
@if (_error is not null)
{
<MudAlert Severity="Severity.Error" role="alert">@_error</MudAlert>
}
<MudTextField @bind-Value="_label" Label="Für welches Gerät?" Placeholder="z. B. iPhone von Anna" Variant="Variant.Outlined"
MaxLength="CalDavCredential.LabelMaxLength" InputId="caldav-label" Immediate="true" />
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary" Disabled="_busy"
StartIcon="@Icons.Material.Filled.Key" Class="fp-caldav-create">App-Passwort erstellen</MudButton>
</MudStack>
</EditForm>
</MudPaper>
</MudItem>
</MudGrid>
<MudText Typo="Typo.h6" HtmlTag="h2" Class="mt-6 mb-2">Meine App-Passwörter</MudText>
@if (_credentials is null)
{
<LoadingIndicator />
}
else if (_credentials.Count == 0)
{
<MudText Typo="Typo.body2" Class="fp-caldav-empty">Noch keine App-Passwörter.</MudText>
}
else
{
<MudTable T="CalDavCredentialDto" Items="_credentials" Breakpoint="Breakpoint.Sm" Elevation="0" Outlined="true" Dense="true" Class="fp-caldav-table">
<HeaderContent>
<MudTh>Gerät</MudTh>
<MudTh>Erstellt</MudTh>
<MudTh>Zuletzt benutzt</MudTh>
<MudTh>Status</MudTh>
<MudTh><span class="sr-only">Aktionen</span></MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Gerät">@context.Label <span class="fp-token-prefix">(@context.SecretPrefix…)</span></MudTd>
<MudTd DataLabel="Erstellt">@UserManagement.FormatDate(context.CreatedAt)</MudTd>
<MudTd DataLabel="Zuletzt benutzt">@(context.LastUsedAt is { } at ? UserManagement.FormatDate(at) + (string.IsNullOrWhiteSpace(context.LastUsedClient) ? string.Empty : $" – {context.LastUsedClient}") : "noch nie")</MudTd>
<MudTd DataLabel="Status">
@if (context.RevokedAt is null)
{
<MudChip T="string" Size="Size.Small" Color="Color.Success" Variant="Variant.Outlined">aktiv</MudChip>
}
else
{
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined">widerrufen</MudChip>
}
</MudTd>
<MudTd Style="text-align:right">
@if (context.RevokedAt is null)
{
<MudButton Size="Size.Small" Color="Color.Error" OnClick="() => RevokeAsync(context)" StartIcon="@Icons.Material.Filled.LinkOff"
Class="fp-caldav-revoke">Widerrufen</MudButton>
}
</MudTd>
</RowTemplate>
</MudTable>
}
<MudText Typo="Typo.h6" HtmlTag="h2" Class="mt-6 mb-2">So geht's</MudText>
<MudPaper Outlined="true" Class="pa-4">
<MudExpansionPanels Elevation="0" MultiExpansion="true">
<MudExpansionPanel Text="iPhone / iPad" Expanded="true">
<ol class="fp-steps">
<li><b>Einstellungen › Apps › Kalender › Kalender-Accounts › Account hinzufügen › Andere › CalDAV-Account hinzufügen</b>
(ältere iOS-Versionen: Einstellungen › Kalender › Accounts).</li>
<li><b>Server:</b> die Server-Adresse von oben, <b>Benutzername:</b> dein Benutzername, <b>Passwort:</b> das App-Passwort.</li>
<li>„Weiter“ tippen – die Familienkalender erscheinen in der Kalender-App. Neue Termine dort einem dieser Kalender zuordnen.</li>
</ol>
</MudExpansionPanel>
<MudExpansionPanel Text="Mac">
<ol class="fp-steps">
<li><b>Systemeinstellungen › Internetaccounts › Account hinzufügen › Anderer Account › CalDAV-Account</b>.</li>
<li>Kontotyp „Manuell“, Benutzername, App-Passwort und die Server-Adresse eintragen.</li>
</ol>
</MudExpansionPanel>
<MudExpansionPanel Text="Android (DAVx5)">
<ol class="fp-steps">
<li>Die App <b>DAVx5</b> installieren, „Konto hinzufügen“ › <b>Mit URL und Benutzername anmelden</b>.</li>
<li>Server-Adresse, Benutzername und App-Passwort eintragen, Kalender zum Abgleich auswählen.</li>
<li>Die Kalender erscheinen dann in der Google-Kalender-App oder jeder anderen Kalender-App des Geräts.</li>
</ol>
</MudExpansionPanel>
<MudExpansionPanel Text="Thunderbird">
<ol class="fp-steps">
<li>Kalender › <b>Neuer Kalender › Im Netzwerk</b> › Format <b>CalDAV</b>.</li>
<li>Als Ort die Server-Adresse eintragen, mit dem Benutzernamen fortfahren und das App-Passwort eingeben.</li>
<li>Thunderbird listet die Familienkalender – die gewünschten auswählen und abonnieren.</li>
</ol>
</MudExpansionPanel>
<MudExpansionPanel Text="Outlook">
<MudText Typo="Typo.body2">
Outlook kann kein CalDAV – eine Zwei-Wege-Synchronisation ist dort nicht möglich. Du kannst die Familienkalender aber
<b>nur lesend</b> abonnieren: <MudLink Href="kalender/abonnieren">Kalender abonnieren</MudLink> (Outlook aktualisiert Abos nur alle paar Stunden).
</MudText>
</MudExpansionPanel>
</MudExpansionPanels>
<MudText Typo="Typo.body2" Class="mt-3">
Gut zu wissen: Es werden Termine (keine Aufgaben) abgeglichen. Erinnerungen, Teilnehmer und Anhänge aus der Kalender-App werden nicht
gespeichert. Wiederholungen, die der Familienplaner nicht kennt (z. B. stündlich), werden beim Speichern abgelehnt.
</MudText>
</MudPaper>
@code {
private CalDavInfoDto? _info;
private List<CalDavCredentialDto>? _credentials;
private CalDavCredentialSecretDto? _secret;
private string? _label;
private string? _error;
private bool _busy;
protected override async Task OnInitializedAsync()
{
_info = (await Api.GetInfoAsync()).Value;
await LoadAsync();
}
private async Task LoadAsync() => _credentials = (await Api.GetCredentialsAsync()).Value ?? [];
private async Task CreateAsync()
{
_error = null;
var request = new CreateCalDavCredentialRequest(_label ?? string.Empty);
var validation = new CreateCalDavCredentialRequestValidator().Validate(request);
if (!validation.IsValid)
{
_error = string.Join(" ", validation.Errors.Select(e => e.ErrorMessage));
return;
}
_busy = true;
var result = await Api.CreateCredentialAsync(request);
_busy = false;
if (result.Value is null)
{
_error = result.Problem?.Message ?? "Das App-Passwort konnte nicht erstellt werden.";
return;
}
_secret = result.Value;
_label = null;
await LoadAsync();
}
private async Task RevokeAsync(CalDavCredentialDto credential)
{
if (await DialogService.ShowMessageBoxAsync("App-Passwort widerrufen?",
$"„{credential.Label}“ kann sich danach nicht mehr verbinden. Die Kalender-App auf diesem Gerät muss neu eingerichtet werden.",
yesText: "Widerrufen", cancelText: "Abbrechen") != true)
{
return;
}
var result = await Api.RevokeCredentialAsync(credential.Id);
if (!result.Succeeded)
{
Snackbar.Add(result.Problem?.Message ?? "Fehler", Severity.Error);
}
if (_secret?.Credential.Id == credential.Id)
{
_secret = null;
}
await LoadAsync();
}
private async Task CopyAsync(string text)
{
var copied = false;
try
{
copied = await JS.InvokeAsync<bool>("familyPlanner.clipboard.copy", text);
}
catch (JSException)
{
}
Snackbar.Add(copied ? "Kopiert." : "Kopieren nicht möglich – bitte markieren und kopieren.", copied ? Severity.Success : Severity.Warning);
}
}
@@ -41,6 +41,7 @@
</div>
<MudDivider />
<MudMenuItem Icon="@Icons.Material.Filled.Link" OnClick="@(() => Navigation.NavigateTo("kalender/abonnieren"))">Kalender abonnieren</MudMenuItem>
<MudMenuItem Icon="@Icons.Material.Filled.Sync" OnClick="@(() => Navigation.NavigateTo("konto/kalender-verbinden"))">Mit Handy verbinden</MudMenuItem>
@if (CanCreate)
{
<MudMenuItem Icon="@Icons.Material.Filled.UploadFile" OnClick="ImportAsync">.ics importieren</MudMenuItem>
+1
View File
@@ -36,6 +36,7 @@ builder.Services.AddScoped<IShoppingApi, ShoppingApi>();
builder.Services.AddScoped<IMealsApi, MealsApi>();
builder.Services.AddScoped<ITasksApi, TasksApi>();
builder.Services.AddScoped<ICalendarApi, CalendarApi>();
builder.Services.AddScoped<ICalDavApi, CalDavApi>();
builder.Services.AddScoped<IDashboardApi, DashboardApi>();
builder.Services.AddScoped<IChatApi, ChatApi>();
builder.Services.AddScoped<IAiApi, AiApi>();
@@ -0,0 +1,54 @@
using System.Net.Http.Json;
using FamilyPlanner.Shared.Calendar;
namespace FamilyPlanner.Web.Client.Services;
/// <summary>Verbindungsdaten und App-Passwörter für die Kalender-Synchronisation (CalDAV, ADR-0017).</summary>
public interface ICalDavApi
{
Task<ApiResult<CalDavInfoDto>> GetInfoAsync(CancellationToken ct = default);
Task<ApiResult<List<CalDavCredentialDto>>> GetCredentialsAsync(CancellationToken ct = default);
Task<ApiResult<CalDavCredentialSecretDto>> CreateCredentialAsync(CreateCalDavCredentialRequest request, CancellationToken ct = default);
Task<ApiResult<bool>> RevokeCredentialAsync(Guid id, CancellationToken ct = default);
}
public sealed class CalDavApi(HttpClient http) : ICalDavApi
{
public Task<ApiResult<CalDavInfoDto>> GetInfoAsync(CancellationToken ct = default) =>
SendAsync<CalDavInfoDto>(() => http.GetAsync("api/caldav/info", ct), ct);
public Task<ApiResult<List<CalDavCredentialDto>>> GetCredentialsAsync(CancellationToken ct = default) =>
SendAsync<List<CalDavCredentialDto>>(() => http.GetAsync("api/caldav/credentials", ct), ct);
public Task<ApiResult<CalDavCredentialSecretDto>> CreateCredentialAsync(CreateCalDavCredentialRequest request, CancellationToken ct = default) =>
SendAsync<CalDavCredentialSecretDto>(() => http.PostAsJsonAsync("api/caldav/credentials", request, ct), ct);
public async Task<ApiResult<bool>> RevokeCredentialAsync(Guid id, CancellationToken ct = default)
{
var result = await SendAsync<object>(() => http.DeleteAsync($"api/caldav/credentials/{id}", ct), ct);
return result.Problem is null ? ApiResult<bool>.Ok(true) : ApiResult<bool>.Fail(result.Problem);
}
private static async Task<ApiResult<T>> SendAsync<T>(Func<Task<HttpResponseMessage>> send, CancellationToken ct)
{
try
{
using var response = await send();
if (!response.IsSuccessStatusCode)
{
return ApiResult<T>.Fail(await ApiClient.ReadProblemAsync(response, ct));
}
return response.StatusCode == System.Net.HttpStatusCode.NoContent
? ApiResult<T>.Ok(default)
: ApiResult<T>.Ok(await response.Content.ReadFromJsonAsync<T>(ct));
}
catch (HttpRequestException)
{
return ApiResult<T>.Fail(ApiProblem.Offline);
}
}
}
+4 -1
View File
@@ -5,7 +5,7 @@ using Yarp.ReverseProxy.Forwarder;
namespace FamilyPlanner.Web;
/// <summary>
/// Backend-for-Frontend: leitet <c>/api</c>, <c>/hubs</c> und <c>/ics</c> same-origin an die Api weiter (ADR-0002).
/// Backend-for-Frontend: leitet <c>/api</c>, <c>/hubs</c>, <c>/ics</c> und <c>/caldav</c> (inkl. <c>/.well-known/caldav</c>) same-origin an die Api weiter (ADR-0002).
/// Die Adresse der Api kommt über Aspire Service Discovery (<c>services__api__…</c>).
/// </summary>
internal static class BffSetup
@@ -19,6 +19,9 @@ internal static class BffSetup
Route("api", "/api/{**catch-all}"),
Route("hubs", "/hubs/{**catch-all}"),
Route("ics", "/ics/{**catch-all}"),
Route("caldav", "/caldav/{**catch-all}"),
Route("caldav-root", "/caldav"),
Route("caldav-wellknown", "/.well-known/caldav"),
];
ClusterConfig[] clusters =
@@ -0,0 +1,582 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Xml.Linq;
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Calendar;
using FamilyPlanner.Shared.Calendar;
namespace FamilyPlanner.IntegrationTests;
/// <summary>CalDAV-Server (ADR-0017) über den BFF: Entdeckung, Round-Trip, ETag-Konflikte, Sync, Rechte und App-Passwörter.</summary>
public sealed class CalDavTests(AppFixture fixture)
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private static readonly XNamespace D = "DAV:";
private static readonly XNamespace C = "urn:ietf:params:xml:ns:caldav";
private static readonly XNamespace CS = "http://calendarserver.org/ns/";
private static readonly HttpMethod PropFind = new("PROPFIND");
private static readonly HttpMethod Report = new("REPORT");
/// <summary>Jeder Test arbeitet in einer eigenen Woche weit in der Zukunft (unabhängig von CalendarTests).</summary>
private static DateTime TestDay() => new DateTime(2041, 1, 7).AddDays(7 * Random.Shared.Next(0, 300));
private static string Ics(string uid, string summary, DateTime start, string extra = "", int hours = 1) =>
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//EN\r\n" +
$"BEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:20400101T000000Z\r\nDTSTART:{start:yyyyMMdd'T'HHmmss}Z\r\nDTEND:{start.AddHours(hours):yyyyMMdd'T'HHmmss}Z\r\nSUMMARY:{summary}\r\n{extra}END:VEVENT\r\n" +
"END:VCALENDAR\r\n";
private static string Override(string uid, string summary, DateTime recurrenceId, DateTime start) =>
$"BEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:20400101T000000Z\r\nRECURRENCE-ID:{recurrenceId:yyyyMMdd'T'HHmmss}Z\r\nDTSTART:{start:yyyyMMdd'T'HHmmss}Z\r\nDTEND:{start.AddHours(1):yyyyMMdd'T'HHmmss}Z\r\nSUMMARY:{summary}\r\nEND:VEVENT\r\n";
private static string Wrap(params string[] events) => "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//EN\r\n" + string.Concat(events) + "END:VCALENDAR\r\n";
private static string Vevent(string uid, string summary, DateTime start, string extra = "") =>
$"BEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:20400101T000000Z\r\nDTSTART:{start:yyyyMMdd'T'HHmmss}Z\r\nDTEND:{start.AddHours(1):yyyyMMdd'T'HHmmss}Z\r\nSUMMARY:{summary}\r\n{extra}END:VEVENT\r\n";
private async Task<string> NewSecretAsync(HttpClient cookieClient, string label = "Testgerät")
{
var response = await cookieClient.PostAsJsonAsync("/api/caldav/credentials", new CreateCalDavCredentialRequest(label), Ct);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
return (await response.Content.ReadFromJsonAsync<CalDavCredentialSecretDto>(Ct))!.Secret;
}
private HttpClient DavClient(string userName, string secret)
{
var client = fixture.CreateBrowserClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"{userName}:{secret}")));
return client;
}
private static async Task<(HttpResponseMessage Response, string Body)> SendAsync(
HttpClient client, HttpMethod method, string path, string? body = null, string contentType = "application/xml", Action<HttpRequestMessage>? tweak = null)
{
using var request = new HttpRequestMessage(method, path);
if (body is not null)
{
request.Content = new StringContent(body, Encoding.UTF8);
request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType);
}
tweak?.Invoke(request);
var response = await client.SendAsync(request, Ct);
return (response, await response.Content.ReadAsStringAsync(Ct));
}
private static Task<(HttpResponseMessage Response, string Body)> PropFindAsync(HttpClient client, string path, int depth, string props) =>
SendAsync(client, PropFind, path,
$"<D:propfind xmlns:D='DAV:' xmlns:C='urn:ietf:params:xml:ns:caldav' xmlns:CS='http://calendarserver.org/ns/'><D:prop>{props}</D:prop></D:propfind>",
tweak: r => r.Headers.Add("Depth", depth.ToString()));
private static Task<(HttpResponseMessage Response, string Body)> PutAsync(HttpClient client, string path, string ics, Action<HttpRequestMessage>? tweak = null) =>
SendAsync(client, HttpMethod.Put, path, ics, "text/calendar; charset=utf-8", tweak);
private static Task<(HttpResponseMessage Response, string Body)> DeleteAsync(HttpClient client, string path, string? ifMatch = null) =>
SendAsync(client, HttpMethod.Delete, path, tweak: r => { if (ifMatch is not null) { r.Headers.TryAddWithoutValidation("If-Match", ifMatch); } });
private static async Task<Guid> FamilyCalendarIdAsync(HttpClient client) =>
(await client.GetFromJsonAsync<List<CalendarDto>>("/api/calendars", Ct))!.Single(c => c.Name == "Familie").Id;
private static string CalendarPath(Guid id) => $"/caldav/calendars/{id:D}/";
private static string ResourcePath(Guid calendarId, string uid) => $"{CalendarPath(calendarId)}{uid}.ics";
private static XDocument Xml(string body) => XDocument.Parse(body);
private static async Task<string?> ETagOfAsync(HttpClient client, string path)
{
var (response, body) = await PropFindAsync(client, path, 0, "<D:getetag/>");
Assert.Equal((HttpStatusCode)207, response.StatusCode);
return (string?)Xml(body).Descendants(D + "getetag").SingleOrDefault();
}
private static async Task<CalendarRangeDto> RangeAsync(HttpClient client, DateTime from, DateTime to) =>
(await client.GetFromJsonAsync<CalendarRangeDto>($"/api/calendar/events?from={from:yyyy-MM-dd}&to={to:yyyy-MM-dd}&includeTasks=false", Ct))!;
// ---------- Entdeckung ----------
[Fact]
public async Task Discovery_redirects_well_known_and_walks_root_principal_home_calendars_through_the_bff()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var secret = await NewSecretAsync(admin);
using var dav = DavClient(AppFixture.AdminUserName, secret);
using var anonymous = fixture.CreateBrowserClient();
var wellKnown = await anonymous.GetAsync("/.well-known/caldav", Ct);
var wellKnownPropfind = await SendAsync(anonymous, PropFind, "/.well-known/caldav");
Assert.Equal(HttpStatusCode.MovedPermanently, wellKnown.StatusCode);
Assert.Equal("/caldav/", wellKnown.Headers.Location?.OriginalString);
Assert.Equal(HttpStatusCode.MovedPermanently, wellKnownPropfind.Response.StatusCode);
var options = await SendAsync(dav, HttpMethod.Options, "/caldav/");
Assert.Equal(HttpStatusCode.OK, options.Response.StatusCode);
Assert.Contains("calendar-access", string.Join(",", options.Response.Headers.GetValues("DAV")));
Assert.Contains("PROPFIND", string.Join(",", options.Response.Content.Headers.Allow));
var root = await PropFindAsync(dav, "/caldav/", 0, "<D:current-user-principal/><D:resourcetype/>");
Assert.Equal((HttpStatusCode)207, root.Response.StatusCode);
var principalHref = (string)Xml(root.Body).Descendants(D + "current-user-principal").Single().Element(D + "href")!;
Assert.StartsWith("/caldav/principals/", principalHref);
var principal = await PropFindAsync(dav, principalHref, 0, "<C:calendar-home-set/><D:displayname/><D:resourcetype/>");
var homeHref = (string)Xml(principal.Body).Descendants(C + "calendar-home-set").Single().Element(D + "href")!;
Assert.Equal("/caldav/calendars/", homeHref);
Assert.NotNull(Xml(principal.Body).Descendants(D + "principal").FirstOrDefault());
var home = await PropFindAsync(dav, homeHref, 1,
"<D:resourcetype/><D:displayname/><CS:getctag/><D:sync-token/><C:supported-calendar-component-set/><D:current-user-privilege-set/><x:nope xmlns:x='urn:x'/>");
var responses = Xml(home.Body).Root!.Elements(D + "response").ToList();
var family = responses.Single(r => (string?)r.Descendants(D + "displayname").FirstOrDefault() == "Familie");
Assert.NotNull(family.Descendants(C + "calendar").FirstOrDefault());
Assert.Equal("VEVENT", (string?)family.Descendants(C + "comp").Single().Attribute("name"));
Assert.StartsWith("urn:familyplanner:sync:", (string?)family.Descendants(CS + "getctag").Single());
Assert.NotNull(family.Descendants(D + "write-content").FirstOrDefault());
Assert.Contains("HTTP/1.1 404 Not Found", family.Descendants(D + "status").Select(s => (string)s)); // unbekannte Eigenschaft
}
// ---------- Round-Trip, ETag ----------
[Fact]
public async Task Put_get_propfind_delete_roundtrip_with_etag_conflicts_and_web_visibility()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var calendarId = await FamilyCalendarIdAsync(admin);
using var dav = DavClient(AppFixture.AdminUserName, await NewSecretAsync(admin));
var uid = Guid.NewGuid().ToString("D").ToUpperInvariant();
var path = ResourcePath(calendarId, uid);
var day = TestDay().AddHours(9);
var created = await PutAsync(dav, path, Ics(uid, "Zahnarzt", day, "LOCATION:Praxis\r\nDESCRIPTION:Karte mitnehmen\r\n"), r => r.Headers.TryAddWithoutValidation("If-None-Match", "*"));
Assert.Equal(HttpStatusCode.Created, created.Response.StatusCode);
Assert.Null(created.Response.Headers.ETag);
Assert.Equal(HttpStatusCode.PreconditionFailed, (await PutAsync(dav, path, Ics(uid, "Doppelt", day), r => r.Headers.TryAddWithoutValidation("If-None-Match", "*"))).Response.StatusCode);
// im Web sichtbar: eigene Termine werden dem Anleger zugeordnet
var occurrence = (await RangeAsync(admin, day.Date, day.Date)).Occurrences.Single(o => o.Title == "Zahnarzt");
Assert.Equal(("Praxis", "Karte mitnehmen"), (occurrence.Location, occurrence.Notes));
var get = await SendAsync(dav, HttpMethod.Get, path);
Assert.Equal(HttpStatusCode.OK, get.Response.StatusCode);
Assert.Equal("text/calendar", get.Response.Content.Headers.ContentType?.MediaType);
Assert.Contains($"UID:{uid}", get.Body);
Assert.Contains("SUMMARY:Zahnarzt", get.Body);
Assert.Contains("BEGIN:VTIMEZONE", get.Body);
var etag1 = get.Response.Headers.ETag!.Tag;
Assert.Equal(etag1, await ETagOfAsync(dav, path));
Assert.Equal(HttpStatusCode.NotModified, (await SendAsync(dav, HttpMethod.Get, path, tweak: r => r.Headers.TryAddWithoutValidation("If-None-Match", etag1))).Response.StatusCode);
// Änderung mit passendem If-Match; veraltetes If-Match -> 412
Assert.Equal(HttpStatusCode.NoContent, (await PutAsync(dav, path, Ics(uid, "Zahnarzt Kontrolle", day, "LOCATION:Praxis\r\n"), r => r.Headers.TryAddWithoutValidation("If-Match", etag1))).Response.StatusCode);
var etag2 = await ETagOfAsync(dav, path);
Assert.NotEqual(etag1, etag2);
Assert.Equal(HttpStatusCode.PreconditionFailed, (await PutAsync(dav, path, Ics(uid, "Veraltet", day), r => r.Headers.TryAddWithoutValidation("If-Match", etag1))).Response.StatusCode);
Assert.Equal(HttpStatusCode.PreconditionFailed, (await DeleteAsync(dav, path, etag1)).Response.StatusCode);
Assert.Equal(HttpStatusCode.PreconditionFailed, (await PutAsync(dav, ResourcePath(calendarId, Guid.NewGuid().ToString("D")), Ics("x", "x", day), r => r.Headers.TryAddWithoutValidation("If-Match", etag1))).Response.StatusCode);
// Notiz wurde nicht verändert, obwohl der Client keine DESCRIPTION mehr sendet -> jetzt leer (Client ist maßgeblich), Personen-Tags bleiben
var reloaded = (await RangeAsync(admin, day.Date, day.Date)).Occurrences.Single(o => o.EventId == occurrence.EventId);
Assert.Equal(("Zahnarzt Kontrolle", null), (reloaded.Title, reloaded.Notes));
Assert.Equal(occurrence.PersonIds, reloaded.PersonIds);
// GET/PUT erzeugen keine „Für:“-Zeilen: wiederholtes Speichern verändert die Notiz nicht
var withNotes = Ics(uid, "Zahnarzt Kontrolle", day, "DESCRIPTION:Karte\r\n");
for (var i = 0; i < 3; i++)
{
Assert.Equal(HttpStatusCode.NoContent, (await PutAsync(dav, path, withNotes)).Response.StatusCode);
}
Assert.Equal("Karte", (await RangeAsync(admin, day.Date, day.Date)).Occurrences.Single(o => o.EventId == occurrence.EventId).Notes);
// Web-Änderung ändert den ETag
var before = await ETagOfAsync(dav, path);
var webOccurrence = (await RangeAsync(admin, day.Date, day.Date)).Occurrences.Single(o => o.EventId == occurrence.EventId);
var save = new SaveEventRequest(null, calendarId, "Zahnarzt (Web)", null, "Karte", false, DateOnly.FromDateTime(day), TimeOnly.FromDateTime(day), DateOnly.FromDateTime(day), TimeOnly.FromDateTime(day).AddHours(1),
webOccurrence.PersonIds, false, null, webOccurrence.Version);
Assert.Equal(HttpStatusCode.NoContent, (await admin.PutAsJsonAsync($"/api/calendar/events/{occurrence.EventId}", save, Ct)).StatusCode);
Assert.NotEqual(before, await ETagOfAsync(dav, path));
Assert.Contains("SUMMARY:Zahnarzt (Web)", (await SendAsync(dav, HttpMethod.Get, path)).Body);
var currentEtag = (await SendAsync(dav, HttpMethod.Get, path)).Response.Headers.ETag!.Tag;
Assert.Equal(HttpStatusCode.NoContent, (await DeleteAsync(dav, path, currentEtag)).Response.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await SendAsync(dav, HttpMethod.Get, path)).Response.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await DeleteAsync(dav, path)).Response.StatusCode);
Assert.DoesNotContain((await RangeAsync(admin, day.Date, day.Date)).Occurrences, o => o.EventId == occurrence.EventId);
}
[Fact]
public async Task Series_with_override_roundtrips_and_unsupported_input_is_rejected()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var calendarId = await FamilyCalendarIdAsync(admin);
using var dav = DavClient(AppFixture.AdminUserName, await NewSecretAsync(admin));
var uid = Guid.NewGuid().ToString("D");
var path = ResourcePath(calendarId, uid);
var monday = TestDay().AddHours(17);
var series = Wrap(
Vevent(uid, "Schwimmen", monday, $"RRULE:FREQ=WEEKLY;COUNT=3\r\nEXDATE:{monday.AddDays(14):yyyyMMdd'T'HHmmss}Z\r\n"),
Override(uid, "Schwimmen (Halle zu)", monday.AddDays(7), monday.AddDays(7).AddHours(2)));
Assert.Equal(HttpStatusCode.Created, (await PutAsync(dav, path, series)).Response.StatusCode);
var occurrences = (await RangeAsync(admin, monday.Date, monday.Date.AddDays(30))).Occurrences.Where(o => o.Title.StartsWith("Schwimmen")).ToList();
Assert.Equal(["Schwimmen", "Schwimmen (Halle zu)"], occurrences.OrderBy(o => o.StartUtc).Select(o => o.Title));
var get = (await SendAsync(dav, HttpMethod.Get, path)).Body;
Assert.Contains("RRULE:FREQ=WEEKLY;COUNT=3", get);
Assert.Contains("EXDATE", get);
Assert.Equal(2, CountOf(get, "BEGIN:VEVENT"));
Assert.Contains("RECURRENCE-ID", get);
// Override wieder entfernen (Client schickt nur noch die Serie): Ausnahme verschwindet, ETag ändert sich
var etag = await ETagOfAsync(dav, path);
Assert.Equal(HttpStatusCode.NoContent, (await PutAsync(dav, path, Wrap(Vevent(uid, "Schwimmen", monday, "RRULE:FREQ=WEEKLY;COUNT=3\r\n")))).Response.StatusCode);
Assert.NotEqual(etag, await ETagOfAsync(dav, path));
Assert.Equal(1, CountOf((await SendAsync(dav, HttpMethod.Get, path)).Body, "BEGIN:VEVENT"));
// nicht unterstützt -> 403 mit Vorbedingung, nichts wird gespeichert
var todo = await PutAsync(dav, ResourcePath(calendarId, "todo-1"), Wrap("BEGIN:VTODO\r\nUID:todo-1\r\nDTSTAMP:20400101T000000Z\r\nSUMMARY:x\r\nEND:VTODO\r\n"));
Assert.Equal(HttpStatusCode.Forbidden, todo.Response.StatusCode);
Assert.Contains("supported-calendar-component", todo.Body);
var hourly = await PutAsync(dav, ResourcePath(calendarId, "hourly-1"), Wrap(Vevent("hourly-1", "Stündlich", monday, "RRULE:FREQ=HOURLY;COUNT=3\r\n")));
Assert.Equal(HttpStatusCode.Forbidden, hourly.Response.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await SendAsync(dav, HttpMethod.Get, ResourcePath(calendarId, "hourly-1"))).Response.StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await PutAsync(dav, ResourcePath(calendarId, "anders"), Wrap(Vevent("etwas-anderes", "x", monday)))).Response.StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await PutAsync(dav, ResourcePath(calendarId, "kaputt"), "kein iCalendar")).Response.StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await SendAsync(dav, new HttpMethod("MKCALENDAR"), "/caldav/calendars/neu/")).Response.StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await DeleteAsync(dav, CalendarPath(calendarId))).Response.StatusCode);
}
[Fact]
public async Task Client_style_payloads_all_day_events_tzid_with_vtimezone_and_alarm_and_week_start_roundtrip()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var calendarId = await FamilyCalendarIdAsync(admin);
using var dav = DavClient(AppFixture.AdminUserName, await NewSecretAsync(admin));
var day = TestDay().Date;
string D8(DateTime d) => d.ToString("yyyyMMdd");
// Ganztägig, ein Tag (iOS: DTEND = Folgetag, exklusiv)
var single = Guid.NewGuid().ToString("D").ToUpperInvariant();
var singleIcs = Wrap($"BEGIN:VEVENT\r\nUID:{single}\r\nDTSTAMP:20400101T000000Z\r\nDTSTART;VALUE=DATE:{D8(day)}\r\nDTEND;VALUE=DATE:{D8(day.AddDays(1))}\r\nSUMMARY:Feiertag\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\n");
Assert.Equal(HttpStatusCode.Created, (await PutAsync(dav, ResourcePath(calendarId, single), singleIcs)).Response.StatusCode);
// Ganztägig, drei Tage (11.–13., DTEND = 14. exklusiv)
var multi = Guid.NewGuid().ToString("D").ToUpperInvariant();
var multiIcs = Wrap($"BEGIN:VEVENT\r\nUID:{multi}\r\nDTSTAMP:20400101T000000Z\r\nDTSTART;VALUE=DATE:{D8(day.AddDays(10))}\r\nDTEND;VALUE=DATE:{D8(day.AddDays(13))}\r\nSUMMARY:Urlaub\r\nEND:VEVENT\r\n");
Assert.Equal(HttpStatusCode.Created, (await PutAsync(dav, ResourcePath(calendarId, multi), multiIcs)).Response.StatusCode);
var range = (await RangeAsync(admin, day, day.AddDays(20))).Occurrences;
var feiertag = range.Single(o => o.Title == "Feiertag");
var urlaub = range.Single(o => o.Title == "Urlaub");
Assert.True(feiertag.IsAllDay);
Assert.Equal((DateOnly.FromDateTime(day), DateOnly.FromDateTime(day)), (feiertag.StartDate, feiertag.EndDate));
Assert.True(urlaub.IsAllDay);
Assert.Equal((DateOnly.FromDateTime(day.AddDays(10)), DateOnly.FromDateTime(day.AddDays(12))), (urlaub.StartDate, urlaub.EndDate)); // Ende im Web inklusiv
var singleGet = (await SendAsync(dav, HttpMethod.Get, ResourcePath(calendarId, single))).Body;
Assert.Contains($"DTSTART;VALUE=DATE:{D8(day)}", singleGet);
Assert.Contains($"DTEND;VALUE=DATE:{D8(day.AddDays(1))}", singleGet);
var multiGet = (await SendAsync(dav, HttpMethod.Get, ResourcePath(calendarId, multi))).Body;
Assert.Contains($"DTSTART;VALUE=DATE:{D8(day.AddDays(10))}", multiGet);
Assert.Contains($"DTEND;VALUE=DATE:{D8(day.AddDays(13))}", multiGet);
// erneutes Speichern durch den Client ändert nichts (kein Daten-Drift bei Ganztägig)
var etag = await ETagOfAsync(dav, ResourcePath(calendarId, multi));
Assert.Equal(HttpStatusCode.NoContent, (await PutAsync(dav, ResourcePath(calendarId, multi), multiGet)).Response.StatusCode);
Assert.Equal(etag, await ETagOfAsync(dav, ResourcePath(calendarId, multi)));
// iOS-Stil: TZID mit mitgeliefertem VTIMEZONE und VALARM
var timed = Guid.NewGuid().ToString("D").ToUpperInvariant();
var local = day.AddDays(3).AddHours(10);
var timedIcs = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Apple Inc.//iPhone OS 18//EN\r\nCALSCALE:GREGORIAN\r\n" +
"BEGIN:VTIMEZONE\r\nTZID:Europe/Berlin\r\nBEGIN:DAYLIGHT\r\nTZOFFSETFROM:+0100\r\nRRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU\r\nDTSTART:19810329T020000\r\nTZNAME:MESZ\r\nTZOFFSETTO:+0200\r\nEND:DAYLIGHT\r\n" +
"BEGIN:STANDARD\r\nTZOFFSETFROM:+0200\r\nRRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU\r\nDTSTART:19961027T030000\r\nTZNAME:MEZ\r\nTZOFFSETTO:+0100\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\n" +
$"BEGIN:VEVENT\r\nCREATED:20400101T000000Z\r\nDTEND;TZID=Europe/Berlin:{local.AddHours(1):yyyyMMdd'T'HHmmss}\r\nDTSTAMP:20400101T000000Z\r\nDTSTART;TZID=Europe/Berlin:{local:yyyyMMdd'T'HHmmss}\r\nSEQUENCE:0\r\nSUMMARY:Zahnarzt iOS\r\nTRANSP:OPAQUE\r\nUID:{timed}\r\n" +
"BEGIN:VALARM\r\nACTION:DISPLAY\r\nDESCRIPTION:Erinnerung\r\nTRIGGER:-PT15M\r\nUID:ALARM-1\r\nEND:VALARM\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
Assert.Equal(HttpStatusCode.Created, (await PutAsync(dav, ResourcePath(calendarId, timed), timedIcs)).Response.StatusCode);
var zahnarzt = (await RangeAsync(admin, local.Date, local.Date)).Occurrences.Single(o => o.Title == "Zahnarzt iOS");
Assert.Equal((new TimeOnly(10, 0), new TimeOnly(11, 0)), (zahnarzt.StartTime, zahnarzt.EndTime));
var timedGet = (await SendAsync(dav, HttpMethod.Get, ResourcePath(calendarId, timed))).Body;
Assert.Contains($"DTSTART;TZID=Europe/Berlin:{local:yyyyMMdd'T'HHmmss}", timedGet);
Assert.DoesNotContain("VALARM", timedGet); // bekannter Verlust, siehe ADR-0017
// Regeln, wie Clients sie senden: INTERVAL=1, UNTIL in UTC, WKST=SU (Android/Google)
foreach (var rule in new[] { "FREQ=WEEKLY;INTERVAL=1;WKST=SU;UNTIL=20991231T225959Z", "FREQ=DAILY;INTERVAL=1;COUNT=5", "FREQ=MONTHLY;BYDAY=2TU;WKST=SU;COUNT=6", "FREQ=YEARLY;BYMONTH=5;BYMONTHDAY=3" })
{
var uid = Guid.NewGuid().ToString("D");
var put = await PutAsync(dav, ResourcePath(calendarId, uid), Wrap(Vevent(uid, "Serie", day.AddHours(18), $"RRULE:{rule}\r\n")));
Assert.True(put.Response.StatusCode == HttpStatusCode.Created, $"{rule}: {(int)put.Response.StatusCode} {put.Body}");
}
}
private static int CountOf(string text, string needle) => (text.Length - text.Replace(needle, string.Empty, StringComparison.Ordinal).Length) / needle.Length;
[Fact]
public async Task Uids_with_special_characters_roundtrip_through_the_proxy()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var calendarId = await FamilyCalendarIdAsync(admin);
using var dav = DavClient(AppFixture.AdminUserName, await NewSecretAsync(admin));
var day = TestDay().AddHours(8);
foreach (var uid in new[] { "abc123@example.com", "uid mit leerzeichen", "a/b:c" })
{
var href = $"{CalendarPath(calendarId)}{Uri.EscapeDataString(uid).Replace("%40", "@")}.ics";
var put = await PutAsync(dav, href, Wrap(Vevent(uid, "Sonderzeichen", day)));
Assert.True(put.Response.StatusCode == HttpStatusCode.Created, $"{uid}: {(int)put.Response.StatusCode} {put.Body}");
Assert.Contains($"UID:{uid}", (await SendAsync(dav, HttpMethod.Get, href)).Body);
var listing = await PropFindAsync(dav, CalendarPath(calendarId), 1, "<D:getetag/>");
Assert.Contains(Xml(listing.Body).Descendants(D + "href").Select(h => Uri.UnescapeDataString((string)h)), h => h.EndsWith(uid + ".ics", StringComparison.Ordinal));
Assert.Equal(HttpStatusCode.NoContent, (await DeleteAsync(dav, href)).Response.StatusCode);
}
}
// ---------- Sync und Reports ----------
[Fact]
public async Task Sync_collection_multiget_and_query_report_changes_deletions_and_ranges()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var calendarResponse = await admin.PostAsJsonAsync("/api/admin/calendars", new SaveCalendarRequest($"Sync {Guid.NewGuid():N}"[..12], "#00897B", false, []), Ct);
var calendarId = (await calendarResponse.Content.ReadFromJsonAsync<CalendarAdminDto>(Ct))!.Id;
using var dav = DavClient(AppFixture.AdminUserName, await NewSecretAsync(admin));
var calendar = CalendarPath(calendarId);
var day = TestDay().AddHours(10);
var (a, b) = (Guid.NewGuid().ToString("D"), Guid.NewGuid().ToString("D"));
string SyncBody(string token) => $"<D:sync-collection xmlns:D='DAV:'><D:sync-token>{token}</D:sync-token><D:sync-level>1</D:sync-level><D:prop><D:getetag/></D:prop></D:sync-collection>";
var initial = await SendAsync(dav, Report, calendar, SyncBody(string.Empty));
Assert.Equal((HttpStatusCode)207, initial.Response.StatusCode);
var token0 = (string)Xml(initial.Body).Root!.Element(D + "sync-token")!;
Assert.Empty(Xml(initial.Body).Root!.Elements(D + "response"));
await PutAsync(dav, ResourcePath(calendarId, a), Ics(a, "Eins", day));
await PutAsync(dav, ResourcePath(calendarId, b), Ics(b, "Zwei", day.AddDays(40)));
var firstSync = await SendAsync(dav, Report, calendar, SyncBody(token0));
var changed = Xml(firstSync.Body).Root!.Elements(D + "response").ToList();
Assert.Equal(2, changed.Count);
Assert.All(changed, r => Assert.NotNull(r.Descendants(D + "getetag").SingleOrDefault()));
var token1 = (string)Xml(firstSync.Body).Root!.Element(D + "sync-token")!;
Assert.NotEqual(token0, token1);
Assert.Equal(token1, (string)Xml((await PropFindAsync(dav, calendar, 0, "<CS:getctag/>")).Body).Descendants(CS + "getctag").Single());
// nichts geändert -> leer; Änderung + Löschung -> nur das Neue
Assert.Empty(Xml((await SendAsync(dav, Report, calendar, SyncBody(token1))).Body).Root!.Elements(D + "response"));
await PutAsync(dav, ResourcePath(calendarId, a), Ics(a, "Eins geändert", day));
await DeleteAsync(dav, ResourcePath(calendarId, b));
var secondSync = Xml((await SendAsync(dav, Report, calendar, SyncBody(token1))).Body).Root!.Elements(D + "response").ToList();
Assert.Equal(2, secondSync.Count);
Assert.EndsWith($"{a}.ics", (string)secondSync.Single(r => r.Element(D + "propstat") is not null).Element(D + "href")!);
var removed = secondSync.Single(r => r.Element(D + "status") is not null);
Assert.EndsWith($"{b}.ics", (string)removed.Element(D + "href")!);
Assert.Equal("HTTP/1.1 404 Not Found", (string)removed.Element(D + "status")!);
// ungültiges Token -> 403 valid-sync-token
var invalid = await SendAsync(dav, Report, calendar, SyncBody("urn:familyplanner:sync:kaputt"));
Assert.Equal(HttpStatusCode.Forbidden, invalid.Response.StatusCode);
Assert.Contains("valid-sync-token", invalid.Body);
// multiget: vorhandene mit calendar-data, fehlende mit 404
var multiget = await SendAsync(dav, Report, calendar,
$"<C:calendar-multiget xmlns:D='DAV:' xmlns:C='urn:ietf:params:xml:ns:caldav'><D:prop><D:getetag/><C:calendar-data/></D:prop><D:href>{ResourcePath(calendarId, a)}</D:href><D:href>{ResourcePath(calendarId, Guid.NewGuid().ToString("D"))}</D:href></C:calendar-multiget>");
var multigetResponses = Xml(multiget.Body).Root!.Elements(D + "response").ToList();
Assert.Equal(2, multigetResponses.Count);
Assert.Contains("SUMMARY:Eins geändert", (string)multigetResponses[0].Descendants(C + "calendar-data").Single());
Assert.Equal("HTTP/1.1 404 Not Found", (string)multigetResponses[1].Element(D + "status")!);
// calendar-query: Zeitbereich um 'a' herum, dann leer
string Query(DateTime from, DateTime to) =>
$"<C:calendar-query xmlns:D='DAV:' xmlns:C='urn:ietf:params:xml:ns:caldav'><D:prop><D:getetag/></D:prop><C:filter><C:comp-filter name='VCALENDAR'><C:comp-filter name='VEVENT'><C:time-range start='{from:yyyyMMdd'T'HHmmss}Z' end='{to:yyyyMMdd'T'HHmmss}Z'/></C:comp-filter></C:comp-filter></C:filter></C:calendar-query>";
Assert.Single(Xml((await SendAsync(dav, Report, calendar, Query(day.AddDays(-1), day.AddDays(1)))).Body).Root!.Elements(D + "response"));
Assert.Empty(Xml((await SendAsync(dav, Report, calendar, Query(day.AddDays(5), day.AddDays(9)))).Body).Root!.Elements(D + "response"));
}
// ---------- Rechte ----------
[Fact]
public async Task Permissions_guest_reads_only_child_edits_only_own_events_and_hidden_calendars_stay_hidden()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var (family, familyClient) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
var (child, childClient) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
var (guest, guestClient) = await fixture.CreateSignedInUserAsync(Roles.Guest, Ct);
using var _f = familyClient;
using var _c = childClient;
using var _g = guestClient;
var calendarId = await FamilyCalendarIdAsync(admin);
using var parent = DavClient(family.UserName, await NewSecretAsync(familyClient));
using var kid = DavClient(child.UserName, await NewSecretAsync(childClient));
using var visitor = DavClient(guest.UserName, await NewSecretAsync(guestClient));
var day = TestDay().AddHours(15);
var (parentUid, kidUid) = (Guid.NewGuid().ToString("D"), Guid.NewGuid().ToString("D"));
Assert.Equal(HttpStatusCode.Created, (await PutAsync(parent, ResourcePath(calendarId, parentUid), Ics(parentUid, "Elternabend", day))).Response.StatusCode);
// Kind: lesen, eigenen Termin anlegen/ändern/löschen, fremden nicht
Assert.Equal(HttpStatusCode.OK, (await SendAsync(kid, HttpMethod.Get, ResourcePath(calendarId, parentUid))).Response.StatusCode);
Assert.Equal(HttpStatusCode.Created, (await PutAsync(kid, ResourcePath(calendarId, kidUid), Ics(kidUid, "Kindergeburtstag", day))).Response.StatusCode);
Assert.Equal(HttpStatusCode.NoContent, (await PutAsync(kid, ResourcePath(calendarId, kidUid), Ics(kidUid, "Kindergeburtstag Lena", day))).Response.StatusCode);
var kidOnParent = await PutAsync(kid, ResourcePath(calendarId, parentUid), Ics(parentUid, "Gekapert", day));
Assert.Equal(HttpStatusCode.Forbidden, kidOnParent.Response.StatusCode);
Assert.Contains("need-privileges", kidOnParent.Body);
Assert.Equal(HttpStatusCode.Forbidden, (await DeleteAsync(kid, ResourcePath(calendarId, parentUid))).Response.StatusCode);
Assert.Contains("SUMMARY:Elternabend", (await SendAsync(parent, HttpMethod.Get, ResourcePath(calendarId, parentUid))).Body);
// Privilegien je Ressource: Kind darf eigenen schreiben, fremden nur lesen
var ownPrivileges = Xml((await PropFindAsync(kid, ResourcePath(calendarId, kidUid), 0, "<D:current-user-privilege-set/>")).Body);
var foreignPrivileges = Xml((await PropFindAsync(kid, ResourcePath(calendarId, parentUid), 0, "<D:current-user-privilege-set/>")).Body);
Assert.NotNull(ownPrivileges.Descendants(D + "write-content").FirstOrDefault());
Assert.Null(foreignPrivileges.Descendants(D + "write-content").FirstOrDefault());
Assert.NotNull(foreignPrivileges.Descendants(D + "read").FirstOrDefault());
// Familie darf auch fremde Termine ändern (Kalender mit Schreibrecht)
Assert.Equal(HttpStatusCode.NoContent, (await PutAsync(parent, ResourcePath(calendarId, kidUid), Ics(kidUid, "Kindergeburtstag (Abholung 17 Uhr)", day))).Response.StatusCode);
Assert.Equal(HttpStatusCode.NoContent, (await DeleteAsync(kid, ResourcePath(calendarId, kidUid))).Response.StatusCode);
// Gast: lesen ja, schreiben/löschen nein, Kalender nur lesend angekündigt
Assert.Equal(HttpStatusCode.OK, (await SendAsync(visitor, HttpMethod.Get, ResourcePath(calendarId, parentUid))).Response.StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await PutAsync(visitor, ResourcePath(calendarId, Guid.NewGuid().ToString("D")), Ics("g", "Gast", day))).Response.StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await PutAsync(visitor, ResourcePath(calendarId, parentUid), Ics(parentUid, "Gast", day))).Response.StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await DeleteAsync(visitor, ResourcePath(calendarId, parentUid))).Response.StatusCode);
var guestCalendar = Xml((await PropFindAsync(visitor, CalendarPath(calendarId), 0, "<D:current-user-privilege-set/>")).Body);
Assert.Null(guestCalendar.Descendants(D + "bind").FirstOrDefault());
// Kalender ohne Freigabe: für die Rolle unsichtbar (404), nur lesend freigegeben: 403
var hiddenId = await CreateCalendarAsync(admin, "Versteckt", new CalendarGrantDto(Roles.Family, null, CalendarAccess.None), new CalendarGrantDto(Roles.Child, null, CalendarAccess.None));
var readOnlyId = await CreateCalendarAsync(admin, "Nur lesen", new CalendarGrantDto(Roles.Family, null, CalendarAccess.Read));
using var adminDav = DavClient(AppFixture.AdminUserName, await NewSecretAsync(admin));
var adminUid = Guid.NewGuid().ToString("D");
Assert.Equal(HttpStatusCode.Created, (await PutAsync(adminDav, ResourcePath(hiddenId, adminUid), Ics(adminUid, "Geheim", day))).Response.StatusCode);
Assert.Equal(HttpStatusCode.Created, (await PutAsync(adminDav, ResourcePath(readOnlyId, adminUid), Ics(adminUid, "Lesbar", day))).Response.StatusCode);
foreach (var client in new[] { parent, kid, visitor })
{
Assert.Equal(HttpStatusCode.NotFound, (await SendAsync(client, HttpMethod.Get, ResourcePath(hiddenId, adminUid))).Response.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await PutAsync(client, ResourcePath(hiddenId, Guid.NewGuid().ToString("D")), Ics("h", "h", day))).Response.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await PropFindAsync(client, CalendarPath(hiddenId), 0, "<D:displayname/>")).Response.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await SendAsync(client, Report, CalendarPath(hiddenId), "<D:sync-collection xmlns:D='DAV:'><D:sync-token/></D:sync-collection>")).Response.StatusCode);
var home = await PropFindAsync(client, "/caldav/calendars/", 1, "<D:displayname/>");
Assert.DoesNotContain(CalendarPath(hiddenId), Xml(home.Body).Descendants(D + "href").Select(h => (string)h));
}
Assert.Equal(HttpStatusCode.OK, (await SendAsync(parent, HttpMethod.Get, ResourcePath(readOnlyId, adminUid))).Response.StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await PutAsync(parent, ResourcePath(readOnlyId, Guid.NewGuid().ToString("D")), Ics("r", "Nö", day))).Response.StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await PutAsync(parent, ResourcePath(readOnlyId, adminUid), Ics(adminUid, "Nö", day))).Response.StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await DeleteAsync(parent, ResourcePath(readOnlyId, adminUid))).Response.StatusCode);
// Fremde Principals sind nicht erreichbar
Assert.Equal(HttpStatusCode.NotFound, (await PropFindAsync(parent, $"/caldav/principals/{child.Id:D}/", 0, "<D:displayname/>")).Response.StatusCode);
}
private async Task<Guid> CreateCalendarAsync(HttpClient admin, string name, params CalendarGrantDto[] grants)
{
var response = await admin.PostAsJsonAsync("/api/admin/calendars", new SaveCalendarRequest($"{name} {Guid.NewGuid():N}"[..14], "#6D4C41", false, grants), Ct);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
return (await response.Content.ReadFromJsonAsync<CalendarAdminDto>(Ct))!.Id;
}
// ---------- Zugangsdaten ----------
[Fact]
public async Task Credentials_wrong_normal_password_revoked_and_cross_scheme_access_are_rejected()
{
var (user, client) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
using var _ = client;
var created = await client.PostAsJsonAsync("/api/caldav/credentials", new CreateCalDavCredentialRequest("iPhone von Anna"), Ct);
var secret = (await created.Content.ReadFromJsonAsync<CalDavCredentialSecretDto>(Ct))!;
Assert.Matches(@"^([a-z2-9]{4}-){4}[a-z2-9]{4}$", secret.Secret);
// Info + Liste: nie das Geheimnis im Klartext, Anzeige nur mit Präfix
var info = (await client.GetFromJsonAsync<CalDavInfoDto>("/api/caldav/info", Ct))!;
Assert.StartsWith("https://", info.ServerUrl); // Schema kommt über Caddy/Web/YARP bis zur Api (Voraussetzung für CalDav:RequireHttps)
Assert.True(info.IsSecure);
Assert.EndsWith("/caldav/", info.ServerUrl);
Assert.Equal(user.UserName, info.UserName);
var listBody = await client.GetStringAsync("/api/caldav/credentials", Ct);
Assert.DoesNotContain(secret.Secret, listBody);
Assert.DoesNotContain("SecretHash", listBody, StringComparison.OrdinalIgnoreCase);
using var good = DavClient(user.UserName, secret.Secret);
Assert.Equal((HttpStatusCode)207, (await PropFindAsync(good, "/caldav/", 0, "<D:resourcetype/>")).Response.StatusCode);
var item = (await client.GetFromJsonAsync<List<CalDavCredentialDto>>("/api/caldav/credentials", Ct))!.Single(c => c.Id == secret.Credential.Id);
Assert.NotNull(item.LastUsedAt);
Assert.Equal("iPhone von Anna", item.Label);
// Spaces/Großschreibung beim Abtippen sind egal
using var typed = DavClient(user.UserName.ToUpperInvariant(), secret.Secret.ToUpperInvariant().Replace('-', ' '));
Assert.Equal((HttpStatusCode)207, (await PropFindAsync(typed, "/caldav/", 0, "<D:resourcetype/>")).Response.StatusCode);
// falsch: falsches Geheimnis, normales Passwort, fremder Benutzername, kein Header, Cookie ohne Basic
var challenge = await SendAsync(DavClient(user.UserName, CalDavSecret.Generate()), HttpMethod.Options, "/caldav/");
Assert.Equal(HttpStatusCode.Unauthorized, challenge.Response.StatusCode);
Assert.Contains("Basic", challenge.Response.Headers.WwwAuthenticate.ToString());
Assert.Contains("realm", challenge.Response.Headers.WwwAuthenticate.ToString());
Assert.Equal(HttpStatusCode.Unauthorized, (await SendAsync(DavClient(user.UserName, AppFixture.UserPassword), PropFind, "/caldav/")).Response.StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await SendAsync(DavClient(AppFixture.AdminUserName, secret.Secret), PropFind, "/caldav/")).Response.StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await SendAsync(fixture.CreateBrowserClient(), PropFind, "/caldav/")).Response.StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await SendAsync(client, PropFind, "/caldav/")).Response.StatusCode); // Cookie gilt hier nicht
// App-Passwort öffnet keine /api-Endpunkte
Assert.Equal(HttpStatusCode.Unauthorized, (await SendAsync(good, HttpMethod.Get, "/api/calendars")).Response.StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await SendAsync(good, HttpMethod.Get, "/api/caldav/credentials")).Response.StatusCode);
// widerrufen -> sofort ungültig; Widerruf anderer Benutzer wirkt nicht
var (_, other) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
using var __ = other;
await other.DeleteAsync($"/api/caldav/credentials/{secret.Credential.Id}", Ct);
Assert.Equal((HttpStatusCode)207, (await PropFindAsync(good, "/caldav/", 0, "<D:resourcetype/>")).Response.StatusCode);
Assert.Equal(HttpStatusCode.NoContent, (await client.DeleteAsync($"/api/caldav/credentials/{secret.Credential.Id}", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await PropFindAsync(good, "/caldav/", 0, "<D:resourcetype/>")).Response.StatusCode);
Assert.NotNull((await client.GetFromJsonAsync<List<CalDavCredentialDto>>("/api/caldav/credentials", Ct))!.Single(c => c.Id == secret.Credential.Id).RevokedAt);
}
[Fact]
public async Task Deactivated_users_lose_access_and_credential_creation_is_validated_and_limited()
{
var (user, client) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
using var _ = client;
Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsJsonAsync("/api/caldav/credentials", new CreateCalDavCredentialRequest(" "), Ct)).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsJsonAsync("/api/caldav/credentials", new CreateCalDavCredentialRequest(new string('x', 61)), Ct)).StatusCode);
var secret = await NewSecretAsync(client);
for (var i = 1; i < CalDavCredential.MaxActivePerUser; i++)
{
await NewSecretAsync(client, $"Gerät {i}");
}
Assert.Equal(HttpStatusCode.Conflict, (await client.PostAsJsonAsync("/api/caldav/credentials", new CreateCalDavCredentialRequest("Eins zu viel"), Ct)).StatusCode);
using var dav = DavClient(user.UserName, secret);
Assert.Equal((HttpStatusCode)207, (await PropFindAsync(dav, "/caldav/", 0, "<D:resourcetype/>")).Response.StatusCode);
using var admin = await fixture.CreateAdminClientAsync(Ct);
var disable = await admin.PutAsJsonAsync($"/api/admin/users/{user.Id}", new FamilyPlanner.Shared.Admin.UpdateUserRequest(user.DisplayName, null, user.Role, user.Color, false, null), Ct);
Assert.True(disable.IsSuccessStatusCode, await disable.Content.ReadAsStringAsync(Ct));
Assert.Equal(HttpStatusCode.Unauthorized, (await PropFindAsync(dav, "/caldav/", 0, "<D:resourcetype/>")).Response.StatusCode);
}
[Fact]
public async Task Repeated_wrong_credentials_are_throttled_with_429()
{
var (user, client) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
using var _ = client;
var secret = await NewSecretAsync(client);
using var wrong = DavClient(user.UserName, CalDavSecret.Generate());
HttpStatusCode last = default;
for (var i = 0; i < 12 && last != HttpStatusCode.TooManyRequests; i++)
{
last = (await SendAsync(wrong, HttpMethod.Options, "/caldav/")).Response.StatusCode;
}
Assert.Equal(HttpStatusCode.TooManyRequests, last);
// auch das richtige Geheimnis ist dann kurz gesperrt (Schutz vor Raten), andere Benutzernamen derselben Adresse nicht automatisch
using var good = DavClient(user.UserName, secret);
Assert.Equal(HttpStatusCode.TooManyRequests, (await SendAsync(good, HttpMethod.Options, "/caldav/")).Response.StatusCode);
}
}
@@ -0,0 +1,518 @@
using System.Text;
using System.Xml.Linq;
using FamilyPlanner.Api.Features.Calendar.Ics;
using FamilyPlanner.Api.Features.CalDav;
using FamilyPlanner.Domain.Calendar;
using FamilyPlanner.Domain.Common;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
namespace FamilyPlanner.UnitTests.CalDav;
public sealed class CalDavProtocolTests
{
private static readonly Guid CalendarId = Guid.NewGuid();
private static readonly DateTimeOffset T0 = new(2026, 10, 1, 12, 0, 0, TimeSpan.Zero);
private static CalendarEvent Row(string uid, DateTimeOffset updated, Guid? master = null, bool deleted = false, byte version = 1, DateTimeOffset? recurrenceId = null) => new()
{
Id = Guid.NewGuid(),
CalendarId = CalendarId,
Uid = uid,
Title = uid,
SeriesMasterId = master,
RecurrenceId = recurrenceId,
UpdatedAt = updated,
IsDeleted = deleted,
RowVersion = [version],
StartUtc = T0,
EndUtc = T0.AddHours(1),
};
// ---------- Adressen ----------
[Theory]
[InlineData("/caldav", DavKind.Root)]
[InlineData("/caldav/", DavKind.Root)]
[InlineData("/caldav/principals/", DavKind.PrincipalsRoot)]
[InlineData("/caldav/calendars/", DavKind.Home)]
[InlineData("/caldav/calendars", DavKind.Home)]
[InlineData("/caldav/unbekannt/x", DavKind.Unknown)]
[InlineData("/caldav/calendars/kein-guid/", DavKind.Unknown)]
[InlineData("/ics/abc", DavKind.Unknown)]
[InlineData("", DavKind.Unknown)]
public void Parse_recognizes_collections(string path, DavKind expected) => Assert.Equal(expected, DavPath.Parse(path).Kind);
[Fact]
public void Parse_extracts_principal_calendar_and_event()
{
var user = Guid.NewGuid();
Assert.Equal(new DavPath(DavKind.Principal, PrincipalId: user), DavPath.Parse(DavPath.Principal(user)));
Assert.Equal(new DavPath(DavKind.Calendar, CalendarId: CalendarId), DavPath.Parse(DavPath.Calendar(CalendarId)));
Assert.Equal(new DavPath(DavKind.Event, CalendarId: CalendarId, Uid: "abc@familyplanner"), DavPath.Parse(DavPath.Event(CalendarId, "abc@familyplanner")));
}
[Theory]
[InlineData("0f7e3c2a8b1d4e5f9a6b7c8d9e0f1a2b@familyplanner")]
[InlineData("A1B2C3D4-E5F6-47A8-9B0C-D1E2F3A4B5C6")]
[InlineData("termin mit leerzeichen")]
[InlineData("a/b\\c:d?e#f%g+h&i=j")]
[InlineData("Müllabfuhr–Ärger €")]
[InlineData("100%25")]
public void Event_href_roundtrips_odd_uids_as_single_segment(string uid)
{
var href = DavPath.Event(CalendarId, uid);
Assert.DoesNotContain('/', href[DavPath.Calendar(CalendarId).Length..]);
Assert.Equal(uid, DavPath.Parse(href).Uid);
}
[Fact]
public void Parse_treats_proxy_double_encoded_slash_as_slash()
{
Assert.Equal("a/b", DavPath.Parse(DavPath.Calendar(CalendarId) + "a%252Fb.ics").Uid); // YARP: %2F wird zu %252F
Assert.Equal("a/b", DavPath.Parse(DavPath.Calendar(CalendarId) + "a%2Fb.ics").Uid);
}
[Fact]
public void Parse_accepts_absolute_urls_and_queries_and_decodes_only_once()
{
var href = "https://familie.example/" + DavPath.Event(CalendarId, "x%25y")[1..] + "?a=b";
Assert.Equal("x%25y", DavPath.Parse(href).Uid);
Assert.Equal(DavKind.Unknown, DavPath.Parse(DavPath.Calendar(CalendarId) + "ohne-endung").Kind);
}
// ---------- ETag, Gruppierung, Sync ----------
[Fact]
public void ETag_is_stable_quoted_and_changes_with_any_row()
{
var master = Row("u1", T0);
var change = Row("u1", T0, master.Id, recurrenceId: T0.AddDays(1));
var etag = CalDavResources.ETag([master, change]);
Assert.StartsWith("\"", etag);
Assert.EndsWith("\"", etag);
Assert.Equal(etag, CalDavResources.ETag([change, master]));
change.RowVersion = [2];
Assert.NotEqual(etag, CalDavResources.ETag([master, change]));
Assert.NotEqual(CalDavResources.ETag([master]), CalDavResources.ETag([master, change]));
}
[Theory]
[InlineData("\"a\"", "\"a\"", true)]
[InlineData("\"b\", \"a\"", "\"a\"", true)]
[InlineData("*", "\"a\"", true)]
[InlineData("W/\"a\"", "\"a\"", true)]
[InlineData("\"b\"", "\"a\"", false)]
[InlineData("", "\"a\"", false)]
[InlineData(null, "\"a\"", false)]
public void MatchesAny_handles_lists_wildcards_and_weak_tags(string? header, string etag, bool expected) =>
Assert.Equal(expected, CalDavResources.MatchesAny(header, etag));
[Fact]
public void Build_groups_overrides_into_one_resource_and_reports_deleted_series_as_tombstones()
{
var master = Row("serie", T0);
var change = Row("serie", T0.AddMinutes(5), master.Id, recurrenceId: T0);
var gone = Row("weg", T0.AddMinutes(10), deleted: true);
var single = Row("einzel", T0);
var state = CalDavResources.Build(CalendarId, T0.AddDays(-1), [master, change, gone, single]);
Assert.Equal(["einzel", "serie"], state.Resources.Select(r => r.Uid));
Assert.Equal(change, Assert.Single(state.Resources.Single(r => r.Uid == "serie").Overrides));
Assert.Equal(T0.AddMinutes(5), state.Resources.Single(r => r.Uid == "serie").LastModified);
Assert.Equal("weg", Assert.Single(state.Tombstones).Uid);
Assert.Equal(T0.AddMinutes(10), state.Stamp);
}
[Fact]
public void Deleted_override_changes_the_resource_but_is_not_listed()
{
var master = Row("serie", T0);
var removed = Row("serie", T0.AddHours(1), master.Id, deleted: true, recurrenceId: T0);
var resource = Assert.Single(CalDavResources.Build(CalendarId, T0, [master, removed]).Resources);
Assert.Empty(resource.Overrides);
Assert.Equal(T0.AddHours(1), resource.LastModified);
}
[Fact]
public void Recreated_uid_is_a_live_resource_not_a_tombstone()
{
var old = Row("u", T0, deleted: true);
var fresh = Row("u", T0.AddHours(1));
var state = CalDavResources.Build(CalendarId, T0, [old, fresh]);
Assert.Single(state.Resources);
Assert.Empty(state.Tombstones);
}
[Fact]
public void Sync_token_roundtrips_and_rejects_garbage()
{
var token = CalDavSyncToken.Create(T0);
Assert.True(CalDavSyncToken.TryParse(token, out var since));
Assert.Equal(T0, since);
Assert.True(CalDavSyncToken.TryParse(null, out var initial));
Assert.Null(initial);
Assert.True(CalDavSyncToken.TryParse(" ", out _));
Assert.False(CalDavSyncToken.TryParse("urn:familyplanner:sync:abc", out _));
Assert.False(CalDavSyncToken.TryParse("urn:familyplanner:sync:-5", out _));
Assert.False(CalDavSyncToken.TryParse("http://example.com/token", out _));
Assert.False(CalDavSyncToken.TryParse("urn:familyplanner:sync:99999999999999999999", out _));
}
[Fact]
public void Delta_returns_everything_initially_and_only_newer_changes_afterwards()
{
var old = Row("alt", T0);
var fresh = Row("neu", T0.AddMinutes(1));
var gone = Row("weg", T0.AddMinutes(2), deleted: true);
var earlierGone = Row("frueher-weg", T0.AddSeconds(-5), deleted: true);
var state = CalDavResources.Build(CalendarId, T0, [old, fresh, gone, earlierGone]);
var (all, noTombstones) = CalDavSyncToken.Delta(state, null);
var (changed, removed) = CalDavSyncToken.Delta(state, T0);
Assert.Equal(["alt", "neu"], all.Select(r => r.Uid));
Assert.Empty(noTombstones);
Assert.Equal(["neu"], changed.Select(r => r.Uid)); // strikt größer: Gleichstand gilt als gesehen
Assert.Equal(["weg"], removed.Select(t => t.Uid));
Assert.Empty(CalDavSyncToken.Delta(state, state.Stamp).Changed);
}
// ---------- App-Passwörter ----------
[Fact]
public void Secret_has_expected_format_and_is_hashed_independent_of_formatting()
{
var secret = CalDavSecret.Generate();
Assert.Matches(@"^([a-z2-9]{4}-){4}[a-z2-9]{4}$", secret);
Assert.NotEqual(secret, CalDavSecret.Generate());
Assert.True(CalDavSecret.IsWellFormed(secret));
Assert.True(CalDavSecret.IsWellFormed(secret.ToUpperInvariant().Replace('-', ' ')));
Assert.Equal(CalDavSecret.Hash(secret), CalDavSecret.Hash(secret.ToUpperInvariant().Replace("-", string.Empty)));
Assert.Equal(64, CalDavSecret.Hash(secret).Length);
Assert.DoesNotContain(secret.Replace("-", string.Empty), CalDavSecret.Hash(secret));
Assert.Equal(secret[..4], CalDavSecret.Prefix(secret));
Assert.False(CalDavSecret.IsWellFormed("Eigenes Passwort 456"));
Assert.False(CalDavSecret.IsWellFormed(""));
}
[Theory]
[InlineData("Basic YW5uYTpzZWNyZXQ=", true, "anna", "secret")]
[InlineData("basic YW5uYTpzZWNyZXQ=", true, "anna", "secret")]
[InlineData("Basic YW5uYTpzZTpjcmV0", true, "anna", "se:cret")] // Doppelpunkt im Passwort bleibt erhalten
[InlineData("Basic OnNlY3JldA==", false, "", "")] // leerer Benutzer
[InlineData("Basic YW5uYTo=", false, "", "")] // leeres Passwort
[InlineData("Basic !!!", false, "", "")]
[InlineData("Bearer abc", false, "", "")]
public void Basic_header_is_decoded(string header, bool ok, string user, string secret)
{
Assert.Equal(ok, CalDavBasicHandler.TryDecode(header, out var u, out var s));
Assert.Equal((user, secret), (u, s));
}
[Fact]
public void Failure_throttle_blocks_per_user_and_ip_then_expires_and_resets()
{
var clock = new MutableTimeProvider(T0);
var throttle = new CalDavFailureThrottle(clock, Options.Create(new CalDavOptions { MaxFailuresPerUser = 3, MaxFailuresPerIp = 5, FailureWindowMinutes = 10 }));
for (var i = 0; i < 2; i++)
{
throttle.RegisterFailure("1.1.1.1", "anna");
}
Assert.Null(throttle.BlockedFor("1.1.1.1", "anna"));
throttle.RegisterFailure("1.1.1.1", "Anna");
Assert.NotNull(throttle.BlockedFor("1.1.1.1", "ANNA"));
Assert.Null(throttle.BlockedFor("1.1.1.1", "ben")); // andere Benutzer derselben IP bleiben möglich
Assert.Null(throttle.BlockedFor("2.2.2.2", "anna")); // Fremde sperren niemanden von anderen Adressen aus
clock.Now = T0.AddMinutes(11);
Assert.Null(throttle.BlockedFor("1.1.1.1", "anna"));
for (var i = 0; i < 5; i++)
{
throttle.RegisterFailure("3.3.3.3", $"user{i}");
}
Assert.NotNull(throttle.BlockedFor("3.3.3.3", "irgendwer"));
throttle.Reset("3.3.3.3", "user0");
}
private sealed class MutableTimeProvider(DateTimeOffset start) : TimeProvider
{
public DateTimeOffset Now { get; set; } = start;
public override DateTimeOffset GetUtcNow() => Now;
}
// ---------- XML ----------
[Fact]
public void Propfind_body_variants_are_understood()
{
var prop = CalDavXml.TryParse("<D:propfind xmlns:D='DAV:' xmlns:C='urn:ietf:params:xml:ns:caldav'><D:prop><D:getetag/><C:calendar-data/><x:foo xmlns:x='urn:x'/></D:prop></D:propfind>");
var request = DavPropRequest.FromPropfind(prop);
Assert.False(request.AllProp);
Assert.Equal([CalDavXml.D + "getetag", CalDavXml.C + "calendar-data", XName.Get("foo", "urn:x")], request.Names);
Assert.True(DavPropRequest.FromPropfind(null).AllProp);
Assert.True(DavPropRequest.FromPropfind(CalDavXml.TryParse("<D:propfind xmlns:D='DAV:'><D:allprop/></D:propfind>")).AllProp);
Assert.True(DavPropRequest.FromPropfind(CalDavXml.TryParse("<D:propfind xmlns:D='DAV:'><D:propname/></D:propfind>")).PropNameOnly);
}
[Fact]
public void Xml_parser_rejects_dtd_and_garbage()
{
Assert.Null(CalDavXml.TryParse("<?xml version='1.0'?><!DOCTYPE x [<!ENTITY e SYSTEM 'file:///etc/passwd'>]><x>&e;</x>"));
Assert.Null(CalDavXml.TryParse("<unclosed"));
Assert.Null(CalDavXml.TryParse(""));
Assert.Null(CalDavXml.TryParse(null));
}
[Fact]
public void Multistatus_has_found_and_missing_props_status_responses_and_sync_token()
{
var xml = CalDavXml.Multistatus(
[
CalDavXml.Response("/caldav/calendars/x/a.ics", [new XElement(CalDavXml.D + "getetag", "\"e\"")], [CalDavXml.C + "unbekannt"]),
CalDavXml.StatusResponse("/caldav/calendars/x/b.ics", 404),
],
"urn:familyplanner:sync:1");
var doc = XDocument.Parse(xml);
var responses = doc.Root!.Elements(CalDavXml.D + "response").ToList();
Assert.Equal(CalDavXml.D + "multistatus", doc.Root.Name);
Assert.Equal(2, responses.Count);
Assert.Equal(["HTTP/1.1 200 OK", "HTTP/1.1 404 Not Found"], responses[0].Elements(CalDavXml.D + "propstat").Select(p => (string)p.Element(CalDavXml.D + "status")!));
Assert.Equal("\"e\"", (string?)responses[0].Descendants(CalDavXml.D + "getetag").Single());
Assert.Equal("HTTP/1.1 404 Not Found", (string?)responses[1].Element(CalDavXml.D + "status"));
Assert.Equal("urn:familyplanner:sync:1", (string?)doc.Root.Element(CalDavXml.D + "sync-token"));
Assert.StartsWith("<?xml version=\"1.0\" encoding=\"utf-8\"?>", xml);
}
// ---------- calendar-query ----------
private static DavResource Resource(CalendarEvent master, params CalendarEvent[] overrides) =>
new(CalendarId, master.Uid, master, overrides, T0, CalDavResources.ETag(overrides.Prepend(master)));
private static CalendarQuery Query(string filterXml) => CalendarQuery.Parse(XDocument.Parse(
$"<C:calendar-query xmlns:C='urn:ietf:params:xml:ns:caldav' xmlns:D='DAV:'><D:prop><D:getetag/></D:prop><C:filter>{filterXml}</C:filter></C:calendar-query>").Root!);
[Fact]
public void Query_time_range_matches_single_events_and_expands_series()
{
var zone = FamilyTime.Berlin;
var single = Row("einzel", T0);
single.StartUtc = FamilyTime.ToUtc(new DateTime(2026, 11, 5, 10, 0, 0), zone);
single.EndUtc = single.StartUtc.AddHours(1);
var weekly = Row("woechentlich", T0);
weekly.StartUtc = FamilyTime.ToUtc(new DateTime(2026, 10, 1, 9, 0, 0), zone);
weekly.EndUtc = weekly.StartUtc.AddHours(1);
weekly.RecurrenceRule = "FREQ=WEEKLY";
var november = Query("<C:comp-filter name='VCALENDAR'><C:comp-filter name='VEVENT'><C:time-range start='20261101T000000Z' end='20261201T000000Z'/></C:comp-filter></C:comp-filter>");
var october = Query("<C:comp-filter name='VCALENDAR'><C:comp-filter name='VEVENT'><C:time-range start='20261001T000000Z' end='20261031T000000Z'/></C:comp-filter></C:comp-filter>");
var farFuture = Query("<C:comp-filter name='VCALENDAR'><C:comp-filter name='VEVENT'><C:time-range start='20300101T000000Z'/></C:comp-filter></C:comp-filter>");
Assert.True(november.Matches(Resource(single), T0));
Assert.False(october.Matches(Resource(single), T0));
Assert.True(october.Matches(Resource(weekly), T0));
Assert.True(farFuture.Matches(Resource(weekly), T0));
Assert.False(farFuture.Matches(Resource(single), T0));
}
[Fact]
public void Query_respects_exdate_and_prop_filters_and_other_components()
{
var zone = FamilyTime.Berlin;
var daily = Row("taeglich", T0);
daily.Title = "Gassi gehen";
daily.StartUtc = FamilyTime.ToUtc(new DateTime(2026, 10, 1, 9, 0, 0), zone);
daily.EndUtc = daily.StartUtc.AddHours(1);
daily.RecurrenceRule = "FREQ=DAILY;COUNT=2";
daily.ExceptionDates = [daily.StartUtc.AddDays(1)];
var day2 = Query("<C:comp-filter name='VCALENDAR'><C:comp-filter name='VEVENT'><C:time-range start='20261002T000000Z' end='20261003T000000Z'/></C:comp-filter></C:comp-filter>");
var summary = Query("<C:comp-filter name='VCALENDAR'><C:comp-filter name='VEVENT'><C:prop-filter name='SUMMARY'><C:text-match>gassi</C:text-match></C:prop-filter></C:comp-filter></C:comp-filter>");
var notSummary = Query("<C:comp-filter name='VCALENDAR'><C:comp-filter name='VEVENT'><C:prop-filter name='SUMMARY'><C:text-match negate-condition='yes'>gassi</C:text-match></C:prop-filter></C:comp-filter></C:comp-filter>");
var uid = Query("<C:comp-filter name='VCALENDAR'><C:comp-filter name='VEVENT'><C:prop-filter name='UID'><C:text-match match-type='equals'>taeglich</C:text-match></C:prop-filter></C:comp-filter></C:comp-filter>");
var todos = Query("<C:comp-filter name='VCALENDAR'><C:comp-filter name='VTODO'/></C:comp-filter>");
Assert.False(day2.Matches(Resource(daily), T0)); // EXDATE
Assert.True(summary.Matches(Resource(daily), T0));
Assert.False(notSummary.Matches(Resource(daily), T0));
Assert.True(uid.Matches(Resource(daily), T0));
Assert.False(todos.Matches(Resource(daily), T0));
Assert.True(CalendarQuery.Parse(XDocument.Parse("<C:calendar-query xmlns:C='urn:ietf:params:xml:ns:caldav'/>").Root!).Matches(Resource(daily), T0));
}
// ---------- PUT-Prüfung ----------
private static string Ics(params string[] events) =>
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//EN\r\n" + string.Concat(events) + "END:VCALENDAR\r\n";
private static string Vevent(string uid, string extra = "", string start = "20261110T100000Z") =>
$"BEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:20261001T000000Z\r\nDTSTART:{start}\r\nDTEND:20261110T110000Z\r\nSUMMARY:Test\r\n{extra}END:VEVENT\r\n";
private static async Task<(int Status, string Body)> Execute(IResult result)
{
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
await result.ExecuteAsync(context);
context.Response.Body.Position = 0;
return (context.Response.StatusCode, await new StreamReader(context.Response.Body).ReadToEndAsync());
}
private static IResult? Validate(string text, string uid, out CalendarEvent? master, out List<CalendarEvent> overrides) =>
CalDavEndpoints.Validate(text, uid, FamilyTime.Berlin, out master, out overrides);
[Fact]
public void Validate_accepts_single_event_and_series_with_override()
{
Assert.Null(Validate(Ics(Vevent("u1")), "u1", out var single, out var none));
Assert.Equal("Test", single!.Title);
Assert.Empty(none);
var series = Ics(
Vevent("u2", "RRULE:FREQ=DAILY;COUNT=3\r\n"),
Vevent("u2", "RECURRENCE-ID:20261111T100000Z\r\n", "20261111T120000Z"));
Assert.Null(Validate(series, "u2", out var master, out var overrides));
Assert.Equal("FREQ=DAILY;COUNT=3", master!.RecurrenceRule);
var change = Assert.Single(overrides);
Assert.Equal(new DateTimeOffset(2026, 11, 11, 10, 0, 0, TimeSpan.Zero), change.RecurrenceId);
}
[Theory]
[InlineData("todo")]
[InlineData("journal")]
[InlineData("garbage")]
[InlineData("rule")]
[InlineData("uid")]
[InlineData("two-uids")]
[InlineData("override-only")]
[InlineData("override-without-series")]
[InlineData("empty")]
public async Task Validate_rejects_with_403_and_precondition(string kind)
{
var text = kind switch
{
"todo" => Ics("BEGIN:VTODO\r\nUID:u1\r\nDTSTAMP:20261001T000000Z\r\nSUMMARY:x\r\nEND:VTODO\r\n"),
"journal" => Ics(Vevent("u1"), "BEGIN:VJOURNAL\r\nUID:j\r\nDTSTAMP:20261001T000000Z\r\nEND:VJOURNAL\r\n"),
"garbage" => "das ist kein iCalendar",
"rule" => Ics(Vevent("u1", "RRULE:FREQ=HOURLY;COUNT=3\r\n")),
"uid" => Ics(Vevent("anders")),
"two-uids" => Ics(Vevent("u1"), Vevent("u2")),
"override-only" => Ics(Vevent("u1", "RECURRENCE-ID:20261111T100000Z\r\n")),
"override-without-series" => Ics(Vevent("u1"), Vevent("u1", "RECURRENCE-ID:20261111T100000Z\r\n")),
_ => Ics(),
};
var result = Validate(text, "u1", out var master, out _);
Assert.NotNull(result);
Assert.Null(master);
var (status, body) = await Execute(result);
Assert.Equal(403, status);
Assert.Contains("<D:error", body);
}
[Fact]
public async Task Validate_reports_specific_preconditions()
{
var todo = await Execute(Validate(Ics("BEGIN:VTODO\r\nUID:u1\r\nEND:VTODO\r\n"), "u1", out _, out _)!);
var rule = await Execute(Validate(Ics(Vevent("u1", "RRULE:FREQ=HOURLY\r\n")), "u1", out _, out _)!);
var garbage = await Execute(Validate("nope", "u1", out _, out _)!);
Assert.Contains("supported-calendar-component", todo.Body);
Assert.Contains("valid-calendar-object-resource", rule.Body);
Assert.Contains("valid-calendar-data", garbage.Body);
}
[Theory]
[InlineData("FREQ=WEEKLY;INTERVAL=1;WKST=SU;UNTIL=20261231T225959Z", "FREQ=WEEKLY;INTERVAL=1;UNTIL=20261231T225959Z")]
[InlineData("FREQ=DAILY;WKST=SU;COUNT=3", "FREQ=DAILY;COUNT=3")]
[InlineData("FREQ=WEEKLY;WKST=SU;BYDAY=MO,SU", "FREQ=WEEKLY;BYDAY=MO,SU")]
[InlineData("FREQ=WEEKLY;INTERVAL=2;WKST=SU;BYDAY=MO", "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO")]
[InlineData("FREQ=WEEKLY;INTERVAL=2;WKST=SU;BYDAY=MO,SU", "FREQ=WEEKLY;INTERVAL=2;WKST=SU;BYDAY=MO,SU")] // ändert die Termine: bleibt stehen -> abgelehnt
[InlineData("FREQ=WEEKLY;WKST=MO", "FREQ=WEEKLY;WKST=MO")]
public void Week_start_is_dropped_only_when_it_cannot_change_the_occurrences(string input, string expected) =>
Assert.Equal($"RRULE:{expected}\r\n", CalDavEndpoints.NormalizeWeekStart($"RRULE:{input}\r\n"));
[Fact]
public void Validate_accepts_client_style_rules_with_week_start_and_rejects_changing_ones()
{
Assert.Null(Validate(Ics(Vevent("u1", "RRULE:FREQ=WEEKLY;INTERVAL=1;WKST=SU;UNTIL=20991231T225959Z\r\n")), "u1", out var master, out _));
Assert.Equal("FREQ=WEEKLY;UNTIL=20991231T225959Z", master!.RecurrenceRule);
Assert.NotNull(Validate(Ics(Vevent("u1", "RRULE:FREQ=WEEKLY;INTERVAL=2;WKST=SU;BYDAY=MO,SU\r\n")), "u1", out _, out _));
}
[Fact]
public void Validate_maps_all_day_events_with_exclusive_end_and_ignores_alarms()
{
var ics = Ics(
"BEGIN:VEVENT\r\nUID:u1\r\nDTSTAMP:20261001T000000Z\r\nDTSTART;VALUE=DATE:20261110\r\nDTEND;VALUE=DATE:20261113\r\nSUMMARY:Urlaub\r\n" +
"BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-P1D\r\nDESCRIPTION:x\r\nEND:VALARM\r\nEND:VEVENT\r\n");
Assert.Null(Validate(ics, "u1", out var master, out _));
Assert.True(master!.IsAllDay);
Assert.Equal(FamilyTime.StartOfDayUtc(new DateOnly(2026, 11, 10), FamilyTime.Berlin), master.StartUtc);
Assert.Equal(FamilyTime.StartOfDayUtc(new DateOnly(2026, 11, 13), FamilyTime.Berlin), master.EndUtc);
}
// ---------- Export als CalDAV-Ressource ----------
[Fact]
public void Resource_export_has_no_person_line_feed_headers_or_categories_so_edits_do_not_accumulate()
{
var anna = Guid.NewGuid();
var e = Row("u1", T0);
e.Notes = "Bitte Kuchen mitbringen";
e.PersonIds = [anna];
e.ForWholeFamily = true;
var names = new Dictionary<Guid, string> { [CalendarId] = "Familie" };
var resource = IcsBuilder.Build(string.Empty, "Europe/Berlin", [e], names, new Dictionary<Guid, string> { [anna] = "Anna" }, [], IcsTaskMode.None, resourceMode: true);
var feed = IcsBuilder.Build("Familie", "Europe/Berlin", [e], names, new Dictionary<Guid, string> { [anna] = "Anna" }, [], IcsTaskMode.None);
Assert.Contains("DESCRIPTION:Bitte Kuchen mitbringen", resource);
Assert.DoesNotContain("Für:", resource);
Assert.DoesNotContain("X-WR-CALNAME", resource);
Assert.DoesNotContain("REFRESH-INTERVAL", resource);
Assert.DoesNotContain("CATEGORIES", resource);
Assert.Contains("BEGIN:VTIMEZONE", resource);
Assert.Contains("Für:", feed); // Feed unverändert
}
[Fact]
public void Resource_export_roundtrips_through_the_importer_unchanged()
{
var e = Row("u1", T0);
e.Title = "Zahnarzt";
e.Location = "Praxis";
e.Notes = "Karte mitnehmen";
e.StartUtc = FamilyTime.ToUtc(new DateTime(2026, 11, 3, 14, 0, 0), FamilyTime.Berlin);
e.EndUtc = e.StartUtc.AddHours(1);
e.RecurrenceRule = "FREQ=WEEKLY;BYDAY=TU;COUNT=4";
var ics = IcsBuilder.Build(string.Empty, "Europe/Berlin", [e], new Dictionary<Guid, string>(), new Dictionary<Guid, string>(), [], IcsTaskMode.None, resourceMode: true);
Assert.Null(Validate(ics, "u1", out var back, out _));
Assert.Equal((e.Title, e.Location, e.Notes, e.StartUtc, e.EndUtc), (back!.Title, back.Location, back.Notes, back.StartUtc, back.EndUtc));
Assert.Equal(e.RecurrenceRule, back.RecurrenceRule);
Assert.Equal("Europe/Berlin", back.TimeZoneId);
}
}
@@ -28,6 +28,15 @@ public sealed class AccountSettingsPageTests : BunitContext
Assert.Equal("konto/benachrichtigungen", cut.Find(".fp-settings-notifications").GetAttribute("href"));
}
[Fact]
public void Links_to_the_calendar_phone_connection()
{
var cut = Render<AccountSettings>();
Assert.Equal("konto/kalender-verbinden", cut.Find(".fp-settings-caldav").GetAttribute("href"));
Assert.Contains("Mit Handy verbinden", cut.Markup);
}
[Fact]
public void Choosing_dark_updates_the_theme_state()
{
@@ -0,0 +1,5 @@
@* Testhülle: MudBlazor-Provider + Seite „Kalender mit dem Handy verbinden“. *@
<MudPopoverProvider />
<MudDialogProvider />
<MudSnackbarProvider />
<FamilyPlanner.Web.Client.Pages.CalDavConnect />
@@ -0,0 +1,138 @@
using FamilyPlanner.Shared.Calendar;
using FamilyPlanner.Web.Client.Services;
using Microsoft.Extensions.DependencyInjection;
using MudBlazor.Services;
namespace FamilyPlanner.Web.Tests.Calendar;
/// <summary>Seite „Kalender mit dem Handy verbinden“ (CalDAV, ADR-0017): Verbindungsdaten, App-Passwort einmalig anzeigen, Widerruf, Anleitungen.</summary>
public sealed class CalDavConnectTests : BunitContext
{
private readonly FakeCalDavApi _api = new();
public CalDavConnectTests()
{
JSInterop.Mode = JSRuntimeMode.Loose;
Services.AddMudServices();
Services.AddSingleton<ICalDavApi>(_api);
}
[Fact]
public void Shows_server_url_user_name_and_instructions_for_all_clients()
{
var cut = Render<CalDavConnectHost>();
cut.WaitForAssertion(() => Assert.Equal("https://familie.example/caldav/", cut.Find("#caldav-url").GetAttribute("value")));
Assert.Equal("anna", cut.Find("#caldav-user").GetAttribute("value"));
Assert.Contains("CalDAV-Account hinzufügen", cut.Markup); // iPhone
Assert.Contains("DAVx5", cut.Markup);
Assert.Contains("Thunderbird", cut.Markup);
Assert.Contains("Outlook kann kein CalDAV", cut.Markup);
Assert.Contains("nicht dein normales Passwort", cut.Markup);
Assert.Empty(cut.FindAll(".fp-caldav-insecure"));
Assert.Contains("Noch keine App-Passwörter", cut.Markup);
}
[Fact]
public void Warns_when_page_is_not_served_over_https()
{
_api.Info = new CalDavInfoDto("http://192.168.0.5/caldav/", "anna", false);
var cut = Render<CalDavConnectHost>();
cut.WaitForElement(".fp-caldav-insecure");
}
[Fact]
public void Creating_a_password_requires_a_label_shows_the_secret_once_and_lists_it()
{
var cut = Render<CalDavConnectHost>();
cut.WaitForElement("#caldav-form");
cut.Find("#caldav-form").Submit();
cut.WaitForAssertion(() => Assert.Contains("Bezeichnung", cut.Find("[role=alert]").TextContent));
Assert.Empty(_api.Created);
cut.Find("#caldav-label").Input("iPhone von Anna");
cut.Find("#caldav-form").Submit();
cut.WaitForElement(".fp-caldav-secret");
Assert.Equal("iPhone von Anna", Assert.Single(_api.Created).Label);
Assert.Equal("abcd-efgh-jkmn-pqrs-tuvw", cut.Find("#caldav-secret").GetAttribute("value"));
Assert.Contains("nur jetzt sichtbar", cut.Find(".fp-caldav-secret").TextContent);
Assert.Contains("iPhone von Anna", cut.Find(".fp-caldav-table").TextContent);
Assert.Contains("(abcd…)", cut.Find(".fp-caldav-table").TextContent);
Assert.Contains("noch nie", cut.Find(".fp-caldav-table").TextContent);
Assert.DoesNotContain("abcd-efgh-jkmn-pqrs-tuvw", cut.Find(".fp-caldav-table").InnerHtml);
}
[Fact]
public void Server_errors_such_as_the_limit_are_shown()
{
_api.CreateError = "Es sind höchstens 10 App-Passwörter gleichzeitig möglich.";
var cut = Render<CalDavConnectHost>();
cut.WaitForElement("#caldav-form");
cut.Find("#caldav-label").Input("Noch eins");
cut.Find("#caldav-form").Submit();
cut.WaitForAssertion(() => Assert.Contains("höchstens 10", cut.Find("[role=alert]").TextContent));
Assert.Empty(cut.FindAll(".fp-caldav-secret"));
}
[Fact]
public void Revoking_asks_first_and_marks_the_password_revoked()
{
_api.Credentials.Add(new CalDavCredentialDto(Guid.NewGuid(), "Thunderbird PC", "wxyz", DateTimeOffset.UtcNow.AddDays(-3), DateTimeOffset.UtcNow.AddHours(-1), "Thunderbird/128", null));
var cut = Render<CalDavConnectHost>();
cut.WaitForElement(".fp-caldav-table");
Assert.Contains("Thunderbird/128", cut.Find(".fp-caldav-table").TextContent);
cut.Find(".fp-caldav-revoke").Click();
cut.WaitForElement(".mud-message-box");
cut.FindAll(".mud-message-box button").Single(b => b.TextContent.Contains("Widerrufen")).Click();
cut.WaitForAssertion(() => Assert.Equal([_api.Credentials[0].Id], _api.Revoked));
cut.WaitForAssertion(() => Assert.Contains("widerrufen", cut.Find(".fp-caldav-table").TextContent));
Assert.Empty(cut.FindAll(".fp-caldav-revoke"));
}
}
internal sealed class FakeCalDavApi : ICalDavApi
{
public CalDavInfoDto Info { get; set; } = new("https://familie.example/caldav/", "anna", true);
public List<CalDavCredentialDto> Credentials { get; } = [];
public List<CreateCalDavCredentialRequest> Created { get; } = [];
public List<Guid> Revoked { get; } = [];
public string? CreateError { get; set; }
public Task<ApiResult<CalDavInfoDto>> GetInfoAsync(CancellationToken ct = default) => Task.FromResult(ApiResult<CalDavInfoDto>.Ok(Info));
public Task<ApiResult<List<CalDavCredentialDto>>> GetCredentialsAsync(CancellationToken ct = default) =>
Task.FromResult(ApiResult<List<CalDavCredentialDto>>.Ok(Credentials.ToList()));
public Task<ApiResult<CalDavCredentialSecretDto>> CreateCredentialAsync(CreateCalDavCredentialRequest request, CancellationToken ct = default)
{
if (CreateError is not null)
{
return Task.FromResult(ApiResult<CalDavCredentialSecretDto>.Fail(new ApiProblem { Status = 409, Detail = CreateError }));
}
Created.Add(request);
var credential = new CalDavCredentialDto(Guid.NewGuid(), request.Label, "abcd", DateTimeOffset.UtcNow, null, null, null);
Credentials.Add(credential);
return Task.FromResult(ApiResult<CalDavCredentialSecretDto>.Ok(new CalDavCredentialSecretDto(credential, "abcd-efgh-jkmn-pqrs-tuvw")));
}
public Task<ApiResult<bool>> RevokeCredentialAsync(Guid id, CancellationToken ct = default)
{
Revoked.Add(id);
var index = Credentials.FindIndex(c => c.Id == id);
Credentials[index] = Credentials[index] with { RevokedAt = DateTimeOffset.UtcNow };
return Task.FromResult(ApiResult<bool>.Ok(true));
}
}