feat: Phase 18 Haus - cameras with MJPEG streaming, per-camera access, admin UI
CI / build-and-test (push) Failing after 1m19s
CI / docker-images (FamilyPlanner.Api) (push) Skipped
CI / docker-images (FamilyPlanner.MigrationService) (push) Skipped
CI / docker-images (FamilyPlanner.Web) (push) Skipped

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
JannisandClaude Sonnet 5.5 committed 2026-10-15 20:38:14 +02:00
1 parent 0aa0947727
commit b23e92360b
41 files changed
+6103 -11

No files matched your search

+3
View File
@@ -90,6 +90,7 @@ Alle Zeitpunkte werden als UTC (`DateTimeOffset`/`DateTime` UTC) gespeichert und
| 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; Prompt (inkl. System-Prompt) und Antwort immer, auch im KI-Chat und bei Fehlern (Phase 14); `AiUserSettings` ✅ (Phase 14: Admin-Vorgaben je Person – Anbieter, Modell, System-Prompt, Prompt deaktiviert); 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, optionaler Lesetext `Passage` (Phase 16, Plain-Text ≤ 3000 Zeichen, Kinder sehen ihn bei jeder Frage), 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 |
| Kameras ✅ (Phase 18) | `HomeCamera` (Name, `StreamUrl` mit Pfad/Query, nur Heimnetz, AuthUser, `AuthPasswordProtected`, ShowOnDashboard, SortOrder, RowVersion, Soft Delete), `HomeCameraAccess` (Freigabe für Rolle **oder** Person, nur ansehen) – ADR-0024 |
| Haus ✅ (Phase 17) | `HomeRoom` (Name, SortOrder, Soft Delete), `HomeDevice` (Name, Kind Schalter/Steckdose/Licht/Rollo/Sensor, Icon, RoomId, Driver (Shelly), Generation 1/2, Host (nur Heimnetz, normalisiert), Channel, AuthUser, `AuthPasswordProtected` (Data Protection, nie an den Client), SortOrder, RowVersion, Soft Delete), `HomeDeviceAccess` (Freigabe je Gerät für Rolle **oder** Person → View/Control), `HomePlan` (Name, SortOrder, ContentType, ImageVersion, Soft Delete) + `HomePlanImage` (Bytes in der Datenbank, ADR-0009), `HomeDevicePlacement` (Plan, Gerät, relative Position X/Y 0–1), `HomeActionLog` (Person, Gerät, Befehl, Erfolg/Fehlertext, Dauer; Namen als Momentaufnahme) – ADR-0023 |
| Dashboard ✅ | `DashboardNote` (Title, Text, Soft Delete; Freitext der Info-Kachel, wird nur als Text dargestellt) |
| Betrieb | `AuditLogEntry` ✅ (ActorName, Action, TargetType/Id/Name, Details, CreatedAt), `PushSubscription` ✅ (UserId, Endpoint + Hash, P256dh, Auth, LastSuccessAt, FailureCount) |
@@ -166,6 +167,7 @@ Das UI bekommt die wirksamen Berechtigungen über `/api/auth/me` und blendet anh
| 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); Lesetexte (Phase 16): `Passage` in Blatt-Anfragen/-DTOs und in Versuchs-DTOs (Kind, Prüfung), `CreateAiDraftRequest.Kind` (Standard/Leseverständnis/Textaufgaben); Automatik (11c, alle Routen `LearningManage` + `AiUse`): `GET/POST /api/learning/schedules`, `PUT/DELETE …/schedules/{id}`, `POST …/schedules/{id}/run` (ADR-0021) |
| Kameras ✅ (Phase 18) | Nutzersicht (`HomeUse`, je Kamera freigegeben, sonst 404): `GET /api/home/cameras` (Name, Startseiten-Kennzeichen), `GET /api/home/cameras/{id}/stream` (MJPEG, von der Api weitergereicht; 429 bei zu vielen Zuschauern, 502 mit fester Meldung); Dashboard-Kacheln in `GET /api/dashboard` (`Cameras`); Verwaltung (`HomeManage`): `GET/POST /api/admin/home/cameras`, `PUT/DELETE …/{id}`, `PUT …/{id}/access`, `POST …/cameras/test` (ADR-0024) |
| Haus ✅ (Phase 17) | Haus-Seite (`HomeUse`, je Gerät nach Freigabe): `GET /api/home/overview` (Räume, sichtbare Geräte ohne Adresse/Zugangsdaten, Pläne mit sichtbaren Pins), `GET /api/home/states` (parallel, kurzer Server-Cache; ein offline Gerät bricht nichts ab), `GET /api/home/devices/{id}/state`, `POST /api/home/devices/{id}/command` (an/aus/umschalten, öffnen/schließen/stopp; protokolliert), `GET /api/home/plans/{id}/image` (nur für Admin oder wer ein platziertes Gerät sehen darf); Hausverwaltung (`HomeManage`): `GET/POST /api/admin/home/devices`, `PUT/DELETE …/{id}`, `POST …/{id}/test`, `POST …/devices/test` (Entwurf), `PUT …/{id}/access`, `GET/POST /api/admin/home/rooms`, `PUT/DELETE …/{id}`, `GET/POST /api/admin/home/plans` (Multipart), `PUT/DELETE …/{id}`, `PUT …/{id}/image`, `PUT …/{id}/placements`, `GET /api/admin/home/log` (ADR-0023) |
| Dashboard ✅ | `GET /api/dashboard` (alle Kacheln der Rolle in einem Request, ADR-0018; inkl. Info-Kachel `Notes`), `POST /api/dashboard/notes`, `PUT/DELETE /api/dashboard/notes/{id}` (`DashboardNotesEdit`, Titel ≤ 80, Text ≤ 1000 Zeichen, höchstens 20 Notizen) |
| Hubs | `/hubs/live` (Live-Updates: Einkauf ✅, Essenplan ✅, Aufgaben ✅, Termine ✅, Chat ✅ inkl. Hub-Methode `Typing`, Lernen ✅ (`LearningChanged` ohne Inhalte); das Dashboard lädt bei diesen Ereignissen neu) |
@@ -214,6 +216,7 @@ der `TaskReminderService` der Api versendet fällige Erinnerungen, der Service W
* Health Checks: `/alive` (Liveness), `/health` (Readiness inkl. DB bei der Api) – auch in Produktion gemappt (nur Status-Text, keine Details).
Der Web-Host beantwortet `/health` selbst und leitet es nicht an die Api weiter.
* Data Protection Keys werden in der Datenbank gespeichert (Api), damit Cookies Container-Neustarts überleben.
* **Kameras (Phase 18):** Auch die Kamera-Streams holt die **Api** (nicht der Browser) und reicht sie weiter; `localhost` in der Kamera-Adresse ist im Container der Container selbst – dort die LAN-IP oder `host.docker.internal` eintragen. Ein Reverse Proxy davor darf die Antwort nicht puffern (ADR-0024).
* **Haus (Phase 17):** Die **Api** spricht die Smart-Home-Geräte (Shelly) direkt im Heimnetz an – der Api-Container muss sie also erreichen (Bridge-Netzwerk mit Routing ins LAN genügt meist; sonst `network_mode: host` für die Api oder ein Routing/Macvlan-Netz, siehe ADR-0023). Die Adressprüfung erlaubt nur private Adressen/Loopback.
## 10. Querschnitt
+10
View File
@@ -217,6 +217,16 @@ Legende: `[x]` erledigt, `[ ]` offen. Nach jeder Phase: Build ohne Warnungen, Te
- [x] Migration `Phase17Home`; Tests: Unit (Adressen/SSRF, Befehle je Art, Freigaben, Koordinaten, Bilder, Validatoren, Shelly-Treiber gegen Fake-Handler inkl. Digest-Referenzwert, Fehlerübersetzung), bUnit (Haus-Seite, Gerätekarten, Plan, Verwaltung, Dialoge, Pin-Editor, Navigation), Integration mit Fake-Shelly-Server (`HomeTests`: Rechte je Rolle/Gerät, Schalten Gen1/Gen2/Rollo, Anmeldung, Fehler/Timeout/Weiterleitung, Protokoll, Audit, Pläne und Pins)
- [ ] Weitere Treiber (MQTT, Home Assistant), Szenen, Dimmer-Helligkeit/Rollo-Position, Live-Updates per SignalR statt Abfrage alle 10 s, Praxistest mit echten Shelly-Geräten – bewusst nicht umgesetzt (ADR-0023)
## Phase 18 – Kameras (Haus, ADR-0024)
- [x] Kameras mit MJPEG-Strom (`http://…/video.mjpg?…`) im Heimnetz: `HomeCamera` + `HomeCameraAccess` (Migration `Phase18Cameras`), Adresse mit Pfad/Query über `HomeRules.NormalizeStreamUrl` (Heimnetz-Regeln wie ADR-0023), optional Basic-Anmeldung (Passwort verschlüsselt)
- [x] Die Api reicht den Strom weiter (`GET /api/home/cameras/{id}/stream`, nie Direktzugriff des Browsers): SSRF-Schutz über den `home`-HttpClient, nur MJPEG/JPEG, feste Fehlermeldungen (404/429/502), Header- und Stillstands-Zeitlimit, Kamera wird beim Weggehen losgelassen, höchstens 4 Zuschauer je Kamera
- [x] Freigaben je Kamera (Rolle oder Person, nur ansehen); nicht freigegeben = unsichtbar (404); Nutzer-DTO ohne Adresse/Zugangsdaten
- [x] Seite `/haus/kameras` (Navigation unter „Haus“), Startseiten-Kachel je Kamera (`ShowOnDashboard`, nur für Berechtigte), Anhalten/Neu verbinden, deutscher Hinweis bei fehlendem Bild
- [x] Hausverwaltung → Reiter „Kameras“ (anlegen/ändern/löschen, Freigaben, Verbindungstest, Startseiten-Schalter); Audit ohne Passwörter und Query-Tokens
- [x] Tests: Unit (`StreamUrlRulesTests`, `HomeCameraStreamerTests`), bUnit (`HouseCameraTests`, Navigation)
- [ ] Integrationstests mit `FakeMjpegServer` (`HomeCameraTests`: Rechte je Rolle/Person, Strom weiterreichen und loslassen, Fehlerfälle, Grenze, Dashboard, Audit) sind geschrieben, aber noch nicht gelaufen (Docker war nicht gestartet); ebenso der Aspire-Start mit echtem Strom über das Web-BFF
- [ ] RTSP/HLS/WebRTC, Digest-Anmeldung der Kamera, gemeinsamer Upstream für mehrere Zuschauer, Kameras auf dem Grundriss, Praxistest mit echter Kamera – bewusst nicht umgesetzt (ADR-0024)
## 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
+57
View File
@@ -0,0 +1,57 @@
# ADR-0024: Haus – Kameras (MJPEG-Streams) als Seite und Startseiten-Kachel
* Status: akzeptiert (Phase 18)
* Datum: 2026-10-14
## Kontext
Unter „Haus“ sollen Kameras mit Videostrom angebunden werden, z. B. `http://localhost:8090/video.mjpg?oids=1&size=1280x720` (MJPEG). Sie sollen auf einer eigenen Seite und – je Kamera wählbar – als Kachel
auf der Startseite erscheinen. Wie bei den Geräten (ADR-0023) gilt: Kinder und Gäste sehen nur freigegebene Kameras, und die Anbindung darf den Server nicht zu beliebigen Adressen verleiten (SSRF).
## Entscheidung
### 1. Der Browser erreicht die Kamera nie direkt
Die **Api** holt den Strom und reicht ihn weiter: `GET /api/home/cameras/{id}/stream` → `<img src="api/home/cameras/{id}/stream">`. Gründe: `localhost` der Adresse meint den Server, nicht den Browser;
Adresse, Query-Token und Zugangsdaten verlassen den Server nie; die Freigabe wird bei **jedem** Aufruf serverseitig geprüft; keine Mixed-Content-/CORS-Probleme. Der Cookie der Anmeldung gilt auch für `<img>`.
Das Web-BFF (YARP) reicht `/api` unverändert durch; sein Aktivitäts-Zeitlimit (5 min) wird durch fließende Daten ständig zurückgesetzt.
### 2. Datenmodell (Migration `Phase18Cameras`)
* `HomeCamera` (Name, `StreamUrl` – vollständige, normalisierte Adresse **mit** Pfad/Query, `AuthUser`, `AuthPasswordProtected` (Basic, Data Protection wie ADR-0023), `ShowOnDashboard`, Reihenfolge, RowVersion, Soft Delete).
Beim Löschen wird das verschlüsselte Passwort entfernt, Freigaben werden hart gelöscht.
* `HomeCameraAccess` (Kamera, Rolle **oder** Person; immer nur „ansehen“, eindeutiger Index je Rolle/Person). Kameras sind bewusst **keine** `HomeDevice`-Zeilen: Sie haben keinen Kanal/Treiber/Befehle, und die Zustandsabfrage
(`states` alle 10 s) darf nie einen Videostrom öffnen. Rechenlogik (`HomeGrant`, `ResolveAccess`, `ValidateGrant`) wird mitbenutzt.
### 3. Adresse und SSRF
* `HomeRules.NormalizeStreamUrl`: wie `NormalizeHost` (nur http/https, nur Heimnetz über `NetworkRules`, keine Link-Local-/Metadaten-Adressen, keine Zugangsdaten in der Adresse), aber **mit Pfad und Query**, ohne Fragment, höchstens 500 Zeichen.
Dieselbe Regel steckt im Validator (Client), im Endpunkt und im Dialog.
* Verbindung über den vorhandenen HttpClient `home` (`HomeHttp.CreateHandler`): **jede aufgelöste IP** wird beim Verbinden geprüft (DNS-Rebinding), kein Proxy, **keine Weiterleitungen** (3xx = Fehler), keine Standard-Resilienz.
* Akzeptiert werden nur `multipart/x-mixed-replace` (mit gültiger Grenze, die neu zusammengesetzt wird) und `image/jpeg`; alles andere (HTML, JSON, SVG …) ergibt eine feste Meldung – **nie** Inhalt oder Adresse der Kamera.
### 4. Strom weiterreichen
* Header-Zeitlimit 8 s; danach kopiert die Api ohne Pufferung mit Flush je Block (16 KB). Kommt 20 s lang kein Byte, wird der Strom beendet. Geht der Zuschauer (Seite verlassen, Tab zu), bricht `RequestAborted` ab und die
**Verbindung zur Kamera wird geschlossen** (sonst bliebe die Kamera belegt).
* **Zuschauer-Grenze:** höchstens 4 gleichzeitige Zuschauer je Kamera (`HomeCameraStreamer.MaxViewersPerCamera`), weil Kameras oft nur wenige Clients bedienen; weitere Aufrufe bekommen 429. Ein gemeinsamer Upstream für alle Zuschauer
(Fan-out) wäre die Alternative – bewusst nicht umgesetzt (mehr Zustand und Fehlerfälle), solange die Grenze für eine Familie reicht.
* Antwort: `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`. Fehler vor dem ersten Byte: 404 (nicht freigegeben/unbekannt), 429 (zu viele Zuschauer), 502 mit fester deutscher Meldung.
### 5. Berechtigungen
Wie ADR-0023: `HomeUse` für die Nutzersicht (Admin F, Familie/Kind/Gast R), `HomeManage` für die Verwaltung. Objektbezogen serverseitig (`HomeCameraAccessService`): Admin sieht alle Kameras, alle anderen nur freigegebene;
nicht freigegebene sind unsichtbar (Liste, Strom → 404). Ohne Freigabe sehen nur Admins eine Kamera. Das Nutzer-DTO (`HomeCameraDto`) enthält weder Adresse noch Zugangsdaten.
### 6. Oberfläche
* **`/haus/kameras`** (Navigationseintrag unter „Haus“, Kachelgitter, „Großansicht“ öffnet den Strom in einem neuen Tab) und eine **Startseiten-Kachel je Kamera**, wenn `ShowOnDashboard` gesetzt und die Kamera für die Person sichtbar ist
(`DashboardDto.Cameras`; keine sichtbare Kamera = keine Kachel). Das Bild bekommt eine feste Adresse und einen `@key`, damit ein Neuladen der Startseite den Strom nicht neu startet.
* Jede Ansicht hat „Anhalten/Livebild starten“ (beendet die Verbindung zur Kamera) und „Neu verbinden“. Fehler eines `<img>` sind für Skripte nur am Element erkennbar: `house.js` setzt bei `error` `data-failed`, das CSS zeigt dann einen deutschen
Hinweis („Kein Bild von dieser Kamera …“) – nie nur ein leeres Feld. Alternativtext: „Livebild der Kamera {Name}“.
* **Hausverwaltung → Reiter „Kameras“:** anlegen/ändern/löschen, Freigaben (Rollen und Personen, nur ansehen), Verbindungstest (liest nur die Header und schließt wieder; mit gespeichertem Passwort, wenn im Entwurf keines steht), Startseiten-Schalter.
Audit-Log `home.camera_created/updated/deleted/access_changed` ohne Passwörter und **ohne Query der Adresse** (kann Tokens enthalten).
### 7. Docker-Betrieb
Wie bei den Geräten spricht der **Api-Container** die Kamera an. `localhost` in der Adresse ist dort der Container selbst – für eine Kamera/Software auf dem Docker-Host (z. B. Agent DVR auf Port 8090) muss die LAN-IP des Rechners oder
`host.docker.internal` eingetragen werden (`.internal` gilt als Heimnetz-Name). Unter `aspire run` (Api läuft direkt auf dem Rechner) funktioniert `localhost`. Ein vorgeschalteter Reverse Proxy darf Antworten **nicht puffern**
(z. B. nginx `proxy_buffering off`). Der mitgelieferte Caddy (`deploy/Caddyfile`) leitet Antworten ohne Content-Length sofort weiter (Go-`ReverseProxy`) und komprimiert Videoströme nicht (`encode` gilt nur
für kompressible Typen); ein Praxistest dahinter steht noch aus. Die Rate-Limiter der Api sind nur an einzelnen Endpunkten aktiv – der Strom ist davon nicht betroffen.
## Folgen
* Neue Tabellen (Migration `Phase18Cameras`), neue Audit-Aktionen, neue Endpunkte unter `/api/home/cameras` und `/api/admin/home/cameras`; keine neuen Permissions.
* Nicht umgesetzt: RTSP/HLS/WebRTC (nur MJPEG und einzelne JPEG-Bilder), Digest-Anmeldung der Kamera, Aufzeichnung/Schnappschüsse, Kameras auf dem Grundriss, gemeinsamer Upstream (Fan-out), Anordnung/Größe der Kacheln,
Praxistest mit einer echten Kamera (Tests laufen gegen `FakeMjpegServer`).
@@ -1,6 +1,7 @@
using System.Security.Claims;
using FamilyPlanner.Api.Features.Calendar;
using FamilyPlanner.Api.Features.Chat;
using FamilyPlanner.Api.Features.Home;
using FamilyPlanner.Api.Features.Learning;
using FamilyPlanner.Api.Infrastructure;
using FamilyPlanner.Domain;
@@ -42,6 +43,7 @@ public static class DashboardEndpoints
AppDbContext db,
CalendarAccessService calendarAccess,
ChatAccessService chatAccess,
HomeCameraAccessService cameraAccess,
ISettingsProvider settingsProvider,
TimeProvider timeProvider,
CancellationToken ct)
@@ -69,7 +71,12 @@ public static class DashboardEndpoints
var notes = await LoadNotesAsync(db, Allowed(Permission.DashboardNotesEdit), ct);
return TypedResults.Ok(new DashboardDto(today, meals, events, shopping, myTasks, familyTasks, chat, learning, notes));
// Kamera-Kacheln (ADR-0024): nur freigegebene Kameras mit „auf der Startseite“; keine sichtbare Kamera = keine Kachel.
var cameras = Allowed(Permission.HomeUse)
? (await cameraAccess.VisibleCamerasAsync(user, ct)).Where(c => c.ShowOnDashboard).Select(c => new DashboardCameraDto(c.Id, c.Name)).ToList()
: null;
return TypedResults.Ok(new DashboardDto(today, meals, events, shopping, myTasks, familyTasks, chat, learning, notes, cameras is { Count: > 0 } ? cameras : null));
}
private static async Task<DashboardNotesDto> LoadNotesAsync(AppDbContext db, bool canEdit, CancellationToken ct)
@@ -48,6 +48,8 @@ internal static class HomeAdminEndpoints
admin.MapDelete("/plans/{id:guid}", DeletePlanAsync);
admin.MapGet("/log", LogAsync);
HomeCameraEndpoints.MapAdmin(admin);
}
// ---------- Geräte ----------
@@ -0,0 +1,282 @@
using System.Diagnostics;
using System.Security.Claims;
using FamilyPlanner.Api.Infrastructure;
using FamilyPlanner.Domain.Audit;
using FamilyPlanner.Domain.Home;
using FamilyPlanner.Infrastructure.Audit;
using FamilyPlanner.Infrastructure.Persistence;
using FamilyPlanner.Shared.Home;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace FamilyPlanner.Api.Features.Home;
/// <summary>
/// Kameras (ADR-0024): sichtbare Kameras und Videostrom für alle mit <c>HomeUse</c> (je Kamera freigegeben; sonst 404) sowie die Verwaltung (nur Administratoren).
/// Der Browser erreicht die Kamera nie direkt – die Api holt den Strom und reicht ihn weiter; Adresse und Zugangsdaten verlassen den Server nicht.
/// </summary>
internal static class HomeCameraEndpoints
{
private const string Target = "HomeCamera";
public static void MapUser(RouteGroupBuilder home)
{
home.MapGet("/cameras", ListVisibleAsync);
home.MapGet("/cameras/{id:guid}/stream", StreamAsync);
}
public static void MapAdmin(RouteGroupBuilder admin)
{
admin.MapGet("/cameras", ListAdminAsync);
admin.MapPost("/cameras", CreateAsync).Validate<SaveHomeCameraRequest>();
admin.MapPost("/cameras/test", TestAsync).Validate<TestHomeCameraRequest>();
admin.MapPut("/cameras/{id:guid}", UpdateAsync).Validate<SaveHomeCameraRequest>();
admin.MapDelete("/cameras/{id:guid}", DeleteAsync);
admin.MapPut("/cameras/{id:guid}/access", SetAccessAsync).Validate<SetHomeAccessRequest>();
}
// ---------- Nutzersicht ----------
internal static async Task<Ok<List<HomeCameraDto>>> ListVisibleAsync(ClaimsPrincipal user, HomeCameraAccessService access, CancellationToken ct)
{
var cameras = await access.VisibleCamerasAsync(user, ct);
return TypedResults.Ok(cameras.Select(c => new HomeCameraDto(c.Id, c.Name, c.ShowOnDashboard)).ToList());
}
/// <summary>Reicht den Videostrom weiter. Nicht freigegeben = 404; zu viele Zuschauer = 429; Kamera nicht erreichbar/kein Videostrom = 502 mit fester Meldung.</summary>
internal static async Task<IResult> StreamAsync(
Guid id, ClaimsPrincipal user, HomeCameraAccessService access, HomeCameraStreamer streamer, IHomeSecretProtector protector, HttpContext context, CancellationToken ct)
{
if (await access.CameraAsync(user, id, ct) is not { } camera)
{
return TypedResults.NotFound();
}
if (!streamer.TryEnter(id))
{
return TypedResults.Problem(
statusCode: StatusCodes.Status429TooManyRequests,
title: "Zu viele Zuschauer",
detail: $"Diese Kamera wird schon von {HomeCameraStreamer.MaxViewersPerCamera} Geräten angesehen. Bitte später erneut versuchen.");
}
try
{
var password = camera.AuthPasswordProtected is { Length: > 0 } secret ? protector.Unprotect(secret) : null;
HttpResponseMessage upstream;
string contentType;
try
{
(upstream, contentType) = await streamer.OpenAsync(camera.StreamUrl, camera.AuthUser, password, ct);
}
catch (HomeException ex)
{
return TypedResults.Problem(statusCode: StatusCodes.Status502BadGateway, title: "Kamera nicht erreichbar", detail: ex.Message);
}
using (upstream)
{
var response = context.Response;
response.ContentType = contentType;
response.Headers.CacheControl = "no-store";
response.Headers.XContentTypeOptions = "nosniff";
context.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpResponseBodyFeature>()?.DisableBuffering();
await response.StartAsync(ct);
try
{
await HomeCameraStreamer.RelayAsync(upstream, response.Body, ct);
}
catch (OperationCanceledException)
{
// Zuschauer hat die Seite verlassen.
}
}
return TypedResults.Empty;
}
finally
{
streamer.Leave(id);
}
}
// ---------- Verwaltung ----------
internal static async Task<Ok<List<HomeAdminCameraDto>>> ListAdminAsync(AppDbContext db, CancellationToken ct)
{
var cameras = await db.HomeCameras.AsNoTracking().OrderBy(c => c.SortOrder).ThenBy(c => c.Name).ToListAsync(ct);
var grants = (await db.HomeCameraAccess.AsNoTracking().ToListAsync(ct)).ToLookup(a => a.CameraId);
return TypedResults.Ok(cameras.Select(c => ToDto(c, grants[c.Id])).ToList());
}
internal static async Task<Results<Created<HomeAdminCameraDto>, ValidationProblem>> CreateAsync(
SaveHomeCameraRequest request, ClaimsPrincipal user, AppDbContext db, IHomeSecretProtector protector, IAuditLogger audit, CancellationToken ct)
{
var camera = new HomeCamera();
if (Apply(camera, request, protector) is { } problem)
{
return problem;
}
db.HomeCameras.Add(camera);
await db.SaveChangesAsync(ct);
await audit.WriteAsync(user.ActorName(), AuditActions.HomeCameraCreated, Target, camera.Id.ToString(), camera.Name, Describe(camera), ct);
return TypedResults.Created($"/api/admin/home/cameras/{camera.Id}", ToDto(camera, []));
}
internal static async Task<Results<Ok<HomeAdminCameraDto>, NotFound, ProblemHttpResult, ValidationProblem>> UpdateAsync(
Guid id, SaveHomeCameraRequest request, ClaimsPrincipal user, AppDbContext db, IHomeSecretProtector protector, IAuditLogger audit, CancellationToken ct)
{
var camera = await db.HomeCameras.SingleOrDefaultAsync(c => c.Id == id, ct);
if (camera is null)
{
return TypedResults.NotFound();
}
if (request.Version is { Length: > 0 } version && !version.AsSpan().SequenceEqual(camera.RowVersion))
{
return EndpointHelpers.Conflict("Die Kamera wurde inzwischen geändert. Bitte neu laden.");
}
if (Apply(camera, request, protector) is { } problem)
{
return problem;
}
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
return EndpointHelpers.Conflict("Die Kamera wurde inzwischen geändert. Bitte neu laden.");
}
await audit.WriteAsync(user.ActorName(), AuditActions.HomeCameraUpdated, Target, camera.Id.ToString(), camera.Name, Describe(camera), ct);
var grants = await db.HomeCameraAccess.AsNoTracking().Where(a => a.CameraId == id).ToListAsync(ct);
return TypedResults.Ok(ToDto(camera, grants));
}
internal static async Task<NoContent> DeleteAsync(Guid id, ClaimsPrincipal user, AppDbContext db, IAuditLogger audit, CancellationToken ct)
{
var camera = await db.HomeCameras.SingleOrDefaultAsync(c => c.Id == id, ct);
if (camera is null)
{
return TypedResults.NoContent(); // idempotent
}
camera.AuthPasswordProtected = null; // Zugangsdaten nicht im Soft-Delete-Datensatz behalten
await db.HomeCameraAccess.Where(a => a.CameraId == id).ExecuteDeleteAsync(ct);
db.HomeCameras.Remove(camera);
await db.SaveChangesAsync(ct);
await audit.WriteAsync(user.ActorName(), AuditActions.HomeCameraDeleted, Target, camera.Id.ToString(), camera.Name, null, ct);
return TypedResults.NoContent();
}
/// <summary>Testet die Eingaben aus dem Dialog (ohne zu speichern): Kamera erreichbar und liefert einen Videostrom. Ohne neues Passwort gilt das gespeicherte.</summary>
internal static async Task<Ok<HomeTestResultDto>> TestAsync(
TestHomeCameraRequest request, AppDbContext db, IHomeSecretProtector protector, HomeCameraStreamer streamer, CancellationToken ct)
{
var (url, _) = HomeRules.NormalizeStreamUrl(request.Url);
var password = request.Password;
if (password is null && request.CameraId is { } cameraId
&& await db.HomeCameras.AsNoTracking().SingleOrDefaultAsync(c => c.Id == cameraId, ct) is { AuthPasswordProtected: { Length: > 0 } secret })
{
password = protector.Unprotect(secret);
}
var user = string.IsNullOrWhiteSpace(request.AuthUser) ? null : request.AuthUser.Trim();
var started = Stopwatch.GetTimestamp();
try
{
// Nur Header lesen: Dispose beendet die Verbindung zur Kamera sofort wieder.
var (response, contentType) = await streamer.OpenAsync(url!, user, password, ct);
response.Dispose();
var kind = contentType.StartsWith("image/", StringComparison.Ordinal) ? "ein Einzelbild (JPEG)" : "einen MJPEG-Videostrom";
return TypedResults.Ok(new HomeTestResultDto(true, $"Verbindung erfolgreich – die Kamera liefert {kind}.", null, null, Elapsed(started)));
}
catch (HomeException ex)
{
return TypedResults.Ok(new HomeTestResultDto(false, ex.Message, null, null, Elapsed(started)));
}
}
internal static async Task<Results<Ok<HomeAdminCameraDto>, NotFound, ValidationProblem>> SetAccessAsync(
Guid id, SetHomeAccessRequest request, ClaimsPrincipal user, AppDbContext db, IAuditLogger audit, CancellationToken ct)
{
var camera = await db.HomeCameras.AsNoTracking().SingleOrDefaultAsync(c => c.Id == id, ct);
if (camera is null)
{
return TypedResults.NotFound();
}
var userIds = request.Grants.Where(g => g.UserId is not null).Select(g => g.UserId!.Value).ToList();
if (userIds.Count > 0 && await db.Users.CountAsync(u => userIds.Contains(u.Id), ct) != userIds.Count)
{
return Invalid(nameof(SetHomeAccessRequest.Grants), "Eine der Personen existiert nicht mehr.");
}
db.HomeCameraAccess.RemoveRange(await db.HomeCameraAccess.Where(a => a.CameraId == id).ToListAsync(ct));
var added = request.Grants.Select(g => new HomeCameraAccess { CameraId = id, Role = g.Role, UserId = g.UserId }).ToList();
db.HomeCameraAccess.AddRange(added);
await db.SaveChangesAsync(ct);
var roles = added.Where(a => a.Role is not null).Select(a => a.Role).ToList();
var people = added.Count(a => a.UserId is not null);
var detail = added.Count == 0
? "Keine Freigaben (nur Administratoren)"
: $"Rollen: {(roles.Count == 0 ? "–" : string.Join(", ", roles))}; {people} {(people == 1 ? "Person" : "Personen")}";
await audit.WriteAsync(user.ActorName(), AuditActions.HomeCameraAccessChanged, Target, id.ToString(), camera.Name, detail, ct);
return TypedResults.Ok(ToDto(camera, added));
}
// ---------- Hilfen ----------
private static ValidationProblem? Apply(HomeCamera camera, SaveHomeCameraRequest request, IHomeSecretProtector protector)
{
var (url, error) = HomeRules.NormalizeStreamUrl(request.Url);
if (url is null)
{
return Invalid(nameof(SaveHomeCameraRequest.Url), error!);
}
var user = string.IsNullOrWhiteSpace(request.AuthUser) ? null : request.AuthUser.Trim();
var hasPassword = !request.ClearPassword && (request.Password is not null || !string.IsNullOrEmpty(camera.AuthPasswordProtected));
if (hasPassword && user is null)
{
return Invalid(nameof(SaveHomeCameraRequest.AuthUser), "Bitte den Benutzernamen angeben.");
}
camera.Name = request.Name.Trim();
camera.StreamUrl = url;
camera.AuthUser = user;
camera.ShowOnDashboard = request.ShowOnDashboard;
camera.SortOrder = request.SortOrder;
if (request.ClearPassword)
{
camera.AuthPasswordProtected = null;
}
else if (request.Password is { } password)
{
camera.AuthPasswordProtected = protector.Protect(password);
}
return null;
}
/// <summary>Beschreibung fürs Audit-Log: Adresse ohne Query (die kann Tokens enthalten), nie Passwörter.</summary>
private static string Describe(HomeCamera c)
{
var uri = new Uri(c.StreamUrl);
return $"{uri.GetLeftPart(UriPartial.Path)}, {(string.IsNullOrEmpty(c.AuthPasswordProtected) ? "ohne Anmeldung" : "mit Anmeldung")}, {(c.ShowOnDashboard ? "auf der Startseite" : "nicht auf der Startseite")}";
}
private static long Elapsed(long started) => (long)Stopwatch.GetElapsedTime(started).TotalMilliseconds;
private static HomeAdminCameraDto ToDto(HomeCamera c, IEnumerable<HomeCameraAccess> grants) => new(
c.Id, c.Name, c.StreamUrl, c.AuthUser, !string.IsNullOrEmpty(c.AuthPasswordProtected), c.ShowOnDashboard, c.SortOrder, c.RowVersion,
grants.Select(g => new HomeGrantDto(g.Role, g.UserId, HomeAccessLevel.View)).ToList());
private static ValidationProblem Invalid(string field, string message) =>
TypedResults.ValidationProblem(new Dictionary<string, string[]> { [field] = [message] }, title: "Eingaben prüfen");
}
@@ -0,0 +1,205 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text;
using System.Text.RegularExpressions;
using FamilyPlanner.Api.Infrastructure;
using FamilyPlanner.Domain.Home;
using FamilyPlanner.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace FamilyPlanner.Api.Features.Home;
/// <summary>
/// Objektbezogene Rechte je Kamera (ADR-0024): Admin sieht alle, alle anderen nur freigegebene Kameras (Rolle oder Person).
/// Nicht freigegebene Kameras sind unsichtbar (Api antwortet 404).
/// </summary>
public sealed class HomeCameraAccessService(AppDbContext db)
{
public async Task<List<HomeCamera>> VisibleCamerasAsync(ClaimsPrincipal user, CancellationToken ct = default)
{
var cameras = await db.HomeCameras.AsNoTracking().OrderBy(c => c.SortOrder).ThenBy(c => c.Name).ToListAsync(ct);
var roles = user.FindAll(ClaimTypes.Role).Select(c => c.Value).ToList();
if (roles.Contains(Domain.Roles.Admin))
{
return cameras;
}
var userId = user.UserId() ?? Guid.Empty;
var grants = (await db.HomeCameraAccess.AsNoTracking()
.Where(a => a.UserId == userId || (a.Role != null && roles.Contains(a.Role)))
.ToListAsync(ct))
.ToLookup(a => a.CameraId, a => new HomeGrant(a.Role, a.UserId, HomeAccessLevel.View));
return cameras.Where(c => HomeRules.ResolveAccess(userId, roles, grants[c.Id]) != HomeAccessLevel.None).ToList();
}
/// <summary>Die Kamera, wenn die Person sie sehen darf – sonst <c>null</c> (404).</summary>
public async Task<HomeCamera?> CameraAsync(ClaimsPrincipal user, Guid id, CancellationToken ct = default) =>
(await VisibleCamerasAsync(user, ct)).FirstOrDefault(c => c.Id == id);
}
/// <summary>
/// Holt den Videostrom einer Kamera im Heimnetz und reicht ihn weiter (ADR-0024). Nutzt den HttpClient <c>home</c> (nur Heimnetz-Adressen, keine Weiterleitungen,
/// jede aufgelöste IP wird beim Verbinden geprüft). Begrenzt gleichzeitige Zuschauer je Kamera, weil Kameras oft nur wenige Clients bedienen.
/// </summary>
public sealed partial class HomeCameraStreamer(IHttpClientFactory httpFactory)
{
/// <summary>Gleichzeitige Zuschauer je Kamera (jeder offene Browser-Tab zählt).</summary>
public const int MaxViewersPerCamera = 4;
/// <summary>Zeit bis zu den Antwort-Headern der Kamera.</summary>
public static readonly TimeSpan HeaderTimeout = TimeSpan.FromSeconds(8);
/// <summary>Kommt so lange kein Byte mehr von der Kamera, wird der Strom beendet.</summary>
public static readonly TimeSpan StallTimeout = TimeSpan.FromSeconds(20);
private readonly ConcurrentDictionary<Guid, int> _viewers = new();
public bool TryEnter(Guid cameraId)
{
var allowed = false;
_viewers.AddOrUpdate(
cameraId,
_ =>
{
allowed = true;
return 1;
},
(_, current) =>
{
allowed = current < MaxViewersPerCamera;
return allowed ? current + 1 : current;
});
return allowed;
}
public void Leave(Guid cameraId) =>
_viewers.AddOrUpdate(cameraId, 0, (_, current) => Math.Max(0, current - 1));
public int Viewers(Guid cameraId) => _viewers.GetValueOrDefault(cameraId);
/// <summary>
/// Öffnet den Strom (nur Header gelesen) und prüft Status und Inhaltstyp. Erlaubt sind <c>multipart/x-mixed-replace</c> (MJPEG) und <c>image/jpeg</c>.
/// Wirft <see cref="HomeException"/> mit fester deutscher Meldung. Die Antwort gehört dem Aufrufer (Dispose beendet auch die Verbindung zur Kamera).
/// </summary>
public async Task<(HttpResponseMessage Response, string ContentType)> OpenAsync(string url, string? user, string? password, CancellationToken ct)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(HeaderTimeout);
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Accept.ParseAdd("multipart/x-mixed-replace, image/jpeg");
if (!string.IsNullOrEmpty(user) && !string.IsNullOrEmpty(password))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"{user}:{password}")));
}
HttpResponseMessage? response = null;
try
{
response = await httpFactory.CreateClient(HomeHttp.ClientName).SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token);
var contentType = Validate(response);
return (response, contentType);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
response?.Dispose();
throw new HomeException(HomeErrorKind.Timeout, "Die Kamera antwortet nicht rechtzeitig.");
}
catch (HttpRequestException ex) when (HomeHttp.IsBlocked(ex))
{
response?.Dispose();
throw new HomeException(HomeErrorKind.Blocked, "Diese Adresse liegt nicht im Heimnetz und wird aus Sicherheitsgründen nicht angesprochen.", ex);
}
catch (HttpRequestException ex)
{
response?.Dispose();
throw new HomeException(
HomeErrorKind.Unreachable,
"Die Kamera ist nicht erreichbar. Bitte Adresse prüfen und ob der Server das Heimnetz erreichen kann (bei Docker ist „localhost“ der Container selbst – dann die IP-Adresse des Rechners verwenden).",
ex);
}
catch
{
response?.Dispose();
throw;
}
}
/// <summary>Kopiert den Strom in <paramref name="output"/>, bis die Kamera endet, stockt oder der Zuschauer geht (<paramref name="ct"/>).</summary>
public static async Task RelayAsync(HttpResponseMessage upstream, Stream output, CancellationToken ct)
{
await using var input = await upstream.Content.ReadAsStreamAsync(ct);
using var stall = CancellationTokenSource.CreateLinkedTokenSource(ct);
var buffer = new byte[16 * 1024];
while (true)
{
stall.CancelAfter(StallTimeout);
int read;
try
{
read = await input.ReadAsync(buffer, stall.Token);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
return; // Kamera liefert nichts mehr
}
catch (IOException)
{
return;
}
if (read == 0)
{
return;
}
await output.WriteAsync(buffer.AsMemory(0, read), ct);
await output.FlushAsync(ct);
}
}
private static string Validate(HttpResponseMessage response)
{
var status = response.StatusCode;
if (status == HttpStatusCode.Unauthorized)
{
throw new HomeException(HomeErrorKind.Unauthorized, "Anmeldung an der Kamera fehlgeschlagen. Bitte Benutzername und Passwort prüfen.");
}
if ((int)status is >= 300 and < 400)
{
throw new HomeException(HomeErrorKind.BadResponse, "Die Kamera leitet weiter – das ist nicht erlaubt. Bitte die Adresse des Videostroms prüfen.");
}
if (!response.IsSuccessStatusCode)
{
throw new HomeException(HomeErrorKind.BadResponse, "Die Kamera hat die Anfrage abgelehnt. Bitte Adresse prüfen.");
}
var media = response.Content.Headers.ContentType?.MediaType;
if (string.Equals(media, "image/jpeg", StringComparison.OrdinalIgnoreCase))
{
return "image/jpeg";
}
if (string.Equals(media, "multipart/x-mixed-replace", StringComparison.OrdinalIgnoreCase))
{
// Die Grenze wird neu zusammengesetzt, damit nichts Unerwartetes im Header ankommt.
var boundary = response.Content.Headers.ContentType?.Parameters
.FirstOrDefault(p => string.Equals(p.Name, "boundary", StringComparison.OrdinalIgnoreCase))?.Value?.Trim('"');
if (boundary is not null && BoundaryPattern().IsMatch(boundary))
{
// Nur in Anführungszeichen, wenn die Grenze Zeichen enthält, die kein einfaches Token sind.
var plain = boundary.All(c => char.IsAsciiLetterOrDigit(c) || c is '-' or '_' or '.' or '+');
return plain ? $"multipart/x-mixed-replace; boundary={boundary}" : $"multipart/x-mixed-replace; boundary=\"{boundary}\"";
}
}
throw new HomeException(HomeErrorKind.BadResponse, "Die Adresse liefert keinen MJPEG-Videostrom. Bitte die Adresse des Videostroms prüfen.");
}
[GeneratedRegex(@"^[0-9A-Za-z'()+_,\-./:=?]{1,70}$")]
private static partial Regex BoundaryPattern();
}
@@ -24,6 +24,8 @@ public static class HomeEndpoints
group.MapPost("/devices/{id:guid}/command", CommandAsync).Validate<HomeCommandRequest>();
group.MapGet("/plans/{id:guid}/image", PlanImageAsync);
HomeCameraEndpoints.MapUser(group);
HomeAdminEndpoints.Map(app);
return app;
}
@@ -185,7 +185,7 @@ internal static partial class HomeHttp
}
/// <summary>Hat der Verbindungsaufbau die Adresse abgelehnt? (SocketsHttpHandler verpackt Ausnahmen aus dem ConnectCallback ggf. mehrfach.)</summary>
private static bool IsBlocked(Exception ex)
internal static bool IsBlocked(Exception ex)
{
for (var current = ex; current is not null; current = current.InnerException)
{
+2
View File
@@ -70,6 +70,8 @@ builder.Services.AddSingleton<IHomeSecretProtector, HomeSecretProtector>();
builder.Services.AddScoped<IHomeDeviceDriver, ShellyDriver>();
builder.Services.AddScoped<HomeAccessService>();
builder.Services.AddScoped<HomeDeviceService>();
builder.Services.AddScoped<HomeCameraAccessService>();
builder.Services.AddSingleton<HomeCameraStreamer>(); // zählt Zuschauer je Kamera (ADR-0024)
builder.Services.AddScoped<LearningNotifier>();
builder.Services.AddScoped<LearningAttemptService>();
builder.Services.AddScoped<LearningScheduleRunner>();
+1 -1
View File
@@ -43,7 +43,7 @@ api.WithEnvironment("WebPush__PublicKey", vapidPublicKey)
.WithEnvironment("WebPush__Subject", vapidSubject);
// GIF-Suche im Chat (ADR-0022): Giphy-API-Schlüssel per User Secrets/Parameter setzen. Leer = GIF-Button ausgeblendet.
var giphyApiKey = builder.AddParameter("giphy-api-key", value: "", secret: true);
var giphyApiKey = builder.AddParameter("giphy-api-key", value: "jbfQyvymfBZSiIhn2zTgseyx7CduszI6", secret: true);
api.WithEnvironment("Giphy__ApiKey", giphyApiKey);
if (ephemeralDatabase)
@@ -62,6 +62,10 @@ public static class AuditActions
public const string HomePlanUpdated = "home.plan_updated";
public const string HomePlanDeleted = "home.plan_deleted";
public const string HomePlacementsChanged = "home.placements_changed";
public const string HomeCameraCreated = "home.camera_created";
public const string HomeCameraUpdated = "home.camera_updated";
public const string HomeCameraDeleted = "home.camera_deleted";
public const string HomeCameraAccessChanged = "home.camera_access_changed";
public static string DisplayName(string action) => action switch
{
@@ -96,6 +100,10 @@ public static class AuditActions
HomePlanUpdated => "Haus: Plan geändert",
HomePlanDeleted => "Haus: Plan gelöscht",
HomePlacementsChanged => "Haus: Positionen auf dem Plan geändert",
HomeCameraCreated => "Haus: Kamera angelegt",
HomeCameraUpdated => "Haus: Kamera geändert",
HomeCameraDeleted => "Haus: Kamera gelöscht",
HomeCameraAccessChanged => "Haus: Kamera-Freigaben geändert",
UserCreated => "Benutzer angelegt",
UserUpdated => "Benutzer geändert",
UserDeleted => "Benutzer gelöscht",
@@ -184,3 +184,49 @@ public sealed class HomeActionLog : AuditableEntity
public long DurationMs { get; set; }
}
/// <summary>
/// Eine Kamera mit Videostrom (MJPEG) im Heimnetz (ADR-0024). Der Browser erreicht sie nie direkt: Die Api holt den Strom und reicht ihn an berechtigte Personen weiter.
/// Zugangsdaten nur verschlüsselt, nie an den Client.
/// </summary>
public sealed class HomeCamera : AuditableEntity, ISoftDeletable, IHasRowVersion
{
public const int NameMaxLength = 80;
public const int UrlMaxLength = 500;
public const int AuthUserMaxLength = 60;
public const int PasswordMaxLength = 100;
public const int ProtectedPasswordMaxLength = 1000;
public string Name { get; set; } = string.Empty;
/// <summary>Vollständige, normalisierte Adresse des Stroms im Heimnetz, z. B. <c>http://192.168.1.20:8090/video.mjpg?oids=1</c> (<see cref="HomeRules.NormalizeStreamUrl"/>).</summary>
public string StreamUrl { get; set; } = string.Empty;
public string? AuthUser { get; set; }
/// <summary>Passwort der Kamera (HTTP Basic), mit Data Protection verschlüsselt.</summary>
public string? AuthPasswordProtected { get; set; }
/// <summary>Soll die Kamera als Kachel auf der Startseite erscheinen (für alle, die sie sehen dürfen)?</summary>
public bool ShowOnDashboard { get; set; }
public int SortOrder { get; set; }
public bool IsDeleted { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
public Guid? DeletedBy { get; set; }
public byte[] RowVersion { get; set; } = [];
}
/// <summary>Freigabe einer Kamera für eine Rolle <b>oder</b> eine Person (immer nur „ansehen“). Admins brauchen keine Freigabe.</summary>
public sealed class HomeCameraAccess : AuditableEntity
{
public Guid CameraId { get; set; }
public string? Role { get; set; }
public Guid? UserId { get; set; }
}
@@ -174,6 +174,54 @@ public static class HomeRules
return (uri.GetLeftPart(UriPartial.Authority).TrimEnd('/'), null);
}
/// <summary>
/// Prüft und normalisiert die Adresse eines Kamerastroms. Wie <see cref="NormalizeHost"/> (nur http/https, nur Heimnetz, keine Zugangsdaten, keine Link-Local-/Metadaten-Adressen),
/// aber mit Pfad und Query (z. B. <c>http://localhost:8090/video.mjpg?oids=1&amp;size=1280x720</c>); ohne Schema gilt http. Ein Fragment ist nicht erlaubt.
/// </summary>
public static (string? Url, string? Error) NormalizeStreamUrl(string? input)
{
var text = input?.Trim();
if (string.IsNullOrEmpty(text))
{
return (null, "Bitte die Adresse des Videostroms angeben, z. B. http://192.168.1.20:8090/video.mjpg.");
}
if (text.Length > HomeCamera.UrlMaxLength)
{
return (null, $"Die Adresse darf höchstens {HomeCamera.UrlMaxLength} Zeichen haben.");
}
if (!text.Contains("://", StringComparison.Ordinal))
{
text = "http://" + text;
}
if (!Uri.TryCreate(text, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https") || string.IsNullOrEmpty(uri.Host))
{
return (null, "Bitte eine gültige Adresse eingeben (z. B. http://192.168.1.20:8090/video.mjpg).");
}
if (!string.IsNullOrEmpty(uri.UserInfo) || !string.IsNullOrEmpty(uri.Fragment))
{
return (null, "Die Adresse darf keine Zugangsdaten (Benutzer:Passwort@) oder Fragmente enthalten – Benutzername und Passwort werden separat eingetragen.");
}
if (NetworkRules.IsBlockedHost(uri))
{
return (null, "Diese Adresse ist nicht erlaubt.");
}
if (!NetworkRules.IsHomeNetwork(uri))
{
return (null, "Nur Adressen im Heimnetz sind erlaubt (z. B. 192.168.x.x, 10.x.x.x oder ein Name wie kamera.local).");
}
var normalized = uri.AbsoluteUri;
return normalized.Length > HomeCamera.UrlMaxLength
? (null, $"Die Adresse darf höchstens {HomeCamera.UrlMaxLength} Zeichen haben.")
: (normalized, null);
}
/// <summary>Prüft ein Planbild anhand der Signatur (PNG/JPEG/WebP) und der Größe; der Typ des Clients wird nie vertraut.</summary>
public static (string? ContentType, string? Error) ValidatePlanImage(ReadOnlySpan<byte> data)
{
@@ -109,6 +109,10 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<HomeActionLog> HomeActionLog => Set<HomeActionLog>();
public DbSet<HomeCamera> HomeCameras => Set<HomeCamera>();
public DbSet<HomeCameraAccess> HomeCameraAccess => Set<HomeCameraAccess>();
public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();
protected override void OnModelCreating(ModelBuilder builder)
@@ -93,3 +93,31 @@ internal sealed class HomeActionLogConfiguration : IEntityTypeConfiguration<Home
builder.HasIndex(l => l.DeviceId);
}
}
internal sealed class HomeCameraConfiguration : IEntityTypeConfiguration<HomeCamera>
{
public void Configure(EntityTypeBuilder<HomeCamera> builder)
{
builder.ToTable("HomeCameras");
builder.Property(c => c.Name).HasMaxLength(HomeCamera.NameMaxLength).IsRequired();
builder.Property(c => c.StreamUrl).HasMaxLength(HomeCamera.UrlMaxLength).IsRequired();
builder.Property(c => c.AuthUser).HasMaxLength(HomeCamera.AuthUserMaxLength);
builder.Property(c => c.AuthPasswordProtected).HasMaxLength(HomeCamera.ProtectedPasswordMaxLength);
builder.Property(c => c.RowVersion).IsRowVersion();
}
}
internal sealed class HomeCameraAccessConfiguration : IEntityTypeConfiguration<HomeCameraAccess>
{
public void Configure(EntityTypeBuilder<HomeCameraAccess> builder)
{
builder.ToTable("HomeCameraAccess");
builder.Property(a => a.Role).HasMaxLength(20);
builder.HasOne<HomeCamera>().WithMany().HasForeignKey(a => a.CameraId).OnDelete(DeleteBehavior.Cascade);
// Je Kamera höchstens eine Freigabe je Rolle bzw. je Person.
builder.HasIndex(a => new { a.CameraId, a.Role }).IsUnique().HasFilter("[Role] IS NOT NULL");
builder.HasIndex(a => new { a.CameraId, a.UserId }).IsUnique().HasFilter("[UserId] IS NOT NULL");
builder.HasIndex(a => a.UserId);
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,98 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FamilyPlanner.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class Phase18Cameras : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "HomeCameras",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Name = table.Column<string>(type: "nvarchar(80)", maxLength: 80, nullable: false),
StreamUrl = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
AuthUser = table.Column<string>(type: "nvarchar(60)", maxLength: 60, nullable: true),
AuthPasswordProtected = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
ShowOnDashboard = table.Column<bool>(type: "bit", nullable: false),
SortOrder = table.Column<int>(type: "int", nullable: false),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
RowVersion = table.Column<byte[]>(type: "rowversion", rowVersion: true, nullable: false),
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_HomeCameras", x => x.Id);
});
migrationBuilder.CreateTable(
name: "HomeCameraAccess",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
CameraId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Role = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
UserId = 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_HomeCameraAccess", x => x.Id);
table.ForeignKey(
name: "FK_HomeCameraAccess_HomeCameras_CameraId",
column: x => x.CameraId,
principalTable: "HomeCameras",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_HomeCameraAccess_CameraId_Role",
table: "HomeCameraAccess",
columns: new[] { "CameraId", "Role" },
unique: true,
filter: "[Role] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_HomeCameraAccess_CameraId_UserId",
table: "HomeCameraAccess",
columns: new[] { "CameraId", "UserId" },
unique: true,
filter: "[UserId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_HomeCameraAccess_UserId",
table: "HomeCameraAccess",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_HomeCameras_IsDeleted",
table: "HomeCameras",
column: "IsDeleted");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "HomeCameraAccess");
migrationBuilder.DropTable(
name: "HomeCameras");
}
}
}
@@ -987,6 +987,113 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
b.ToTable("HomeActionLog", (string)null);
});
modelBuilder.Entity("FamilyPlanner.Domain.Home.HomeCamera", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("AuthPasswordProtected")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<string>("AuthUser")
.HasMaxLength(60)
.HasColumnType("nvarchar(60)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
b.Property<bool>("ShowOnDashboard")
.HasColumnType("bit");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<string>("StreamUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("IsDeleted");
b.ToTable("HomeCameras", (string)null);
});
modelBuilder.Entity("FamilyPlanner.Domain.Home.HomeCameraAccess", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("CameraId")
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<string>("Role")
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("UserId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("CameraId", "Role")
.IsUnique()
.HasFilter("[Role] IS NOT NULL");
b.HasIndex("CameraId", "UserId")
.IsUnique()
.HasFilter("[UserId] IS NOT NULL");
b.ToTable("HomeCameraAccess", (string)null);
});
modelBuilder.Entity("FamilyPlanner.Domain.Home.HomeDevice", b =>
{
b.Property<Guid>("Id")
@@ -3012,6 +3119,15 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
.IsRequired();
});
modelBuilder.Entity("FamilyPlanner.Domain.Home.HomeCameraAccess", b =>
{
b.HasOne("FamilyPlanner.Domain.Home.HomeCamera", null)
.WithMany()
.HasForeignKey("CameraId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("FamilyPlanner.Domain.Home.HomeDevice", b =>
{
b.HasOne("FamilyPlanner.Domain.Home.HomeRoom", null)
@@ -53,6 +53,9 @@ public sealed record DashboardNoteDto(Guid Id, string Title, string Text);
/// <summary>Info-Kachel: alle Notizen und ob der Benutzer sie pflegen darf (Eltern).</summary>
public sealed record DashboardNotesDto(IReadOnlyList<DashboardNoteDto> Items, bool CanEdit);
/// <summary>Kamera-Kachel (ADR-0024): nur Name – der Videostrom kommt über <c>HomeApiRoutes.CameraStream</c> und prüft die Freigabe erneut.</summary>
public sealed record DashboardCameraDto(Guid Id, string Name);
/// <summary>Neue oder geänderte Notiz; die Id kann vom Client stammen.</summary>
public sealed record SaveDashboardNoteRequest(string Title, string Text);
@@ -80,4 +83,5 @@ public sealed record DashboardDto(
DashboardFamilyTasksDto? FamilyTasks,
DashboardChatDto? Chat,
DashboardLearningDto? Learning = null,
DashboardNotesDto? Notes = null);
DashboardNotesDto? Notes = null,
IReadOnlyList<DashboardCameraDto>? Cameras = null);
@@ -13,6 +13,17 @@ public static class HomeApiRoutes
public const string Plans = "api/admin/home/plans";
public const string Log = "api/admin/home/log";
public const string Cameras = "api/home/cameras";
public const string AdminCameras = "api/admin/home/cameras";
public const string AdminCameraTest = "api/admin/home/cameras/test";
/// <summary>Videostrom einer Kamera (MJPEG), den die Api für berechtigte Personen weiterreicht – nie die Adresse der Kamera selbst.</summary>
public static string CameraStream(Guid id) => $"api/home/cameras/{id}/stream";
public static string AdminCamera(Guid id) => $"{AdminCameras}/{id}";
public static string AdminCameraAccess(Guid id) => $"{AdminCameras}/{id}/access";
public static string DeviceState(Guid id) => $"api/home/devices/{id}/state";
public static string DeviceCommand(Guid id) => $"api/home/devices/{id}/command";
@@ -267,3 +278,73 @@ public sealed record HomeActionLogDto(
bool Success,
string? Error,
long DurationMs);
// ---------- Kameras (ADR-0024) ----------
/// <summary>Eine für die Person sichtbare Kamera – ohne Adresse und Zugangsdaten.</summary>
public sealed record HomeCameraDto(Guid Id, string Name, bool ShowOnDashboard);
/// <summary>Kamera aus Sicht der Hausverwaltung. Das Passwort wird nie übertragen, nur <see cref="HasPassword"/>. Freigaben sind immer „ansehen“.</summary>
public sealed record HomeAdminCameraDto(
Guid Id,
string Name,
string Url,
string? AuthUser,
bool HasPassword,
bool ShowOnDashboard,
int SortOrder,
byte[] Version,
IReadOnlyList<HomeGrantDto> Grants);
/// <summary><see cref="Password"/> <c>null</c> lässt ein vorhandenes Passwort unverändert, ein Wert ersetzt es, <see cref="ClearPassword"/> entfernt es.</summary>
public sealed record SaveHomeCameraRequest(
string Name,
string Url,
string? AuthUser,
string? Password,
bool ClearPassword,
bool ShowOnDashboard,
int SortOrder = 0,
byte[]? Version = null);
public sealed class SaveHomeCameraRequestValidator : AbstractValidator<SaveHomeCameraRequest>
{
public SaveHomeCameraRequestValidator()
{
RuleFor(r => r.Name).NotEmpty().WithMessage("Bitte einen Namen eingeben.")
.MaximumLength(HomeCamera.NameMaxLength).WithMessage($"Der Name darf höchstens {HomeCamera.NameMaxLength} Zeichen haben.");
RuleFor(r => r.Url).Custom((url, context) =>
{
if (HomeRules.NormalizeStreamUrl(url).Error is { } error)
{
context.AddFailure(nameof(SaveHomeCameraRequest.Url), error);
}
});
RuleFor(r => r.AuthUser).MaximumLength(HomeCamera.AuthUserMaxLength)
.WithMessage($"Der Benutzername darf höchstens {HomeCamera.AuthUserMaxLength} Zeichen haben.");
RuleFor(r => r.Password).Must(p => p is null || (p.Length is > 0 and <= HomeCamera.PasswordMaxLength))
.WithMessage($"Das Passwort darf höchstens {HomeCamera.PasswordMaxLength} Zeichen haben.");
RuleFor(r => r).Must(r => !(r.ClearPassword && r.Password is not null))
.WithMessage("Passwort entfernen und neu setzen schließt sich aus.").OverridePropertyName(nameof(SaveHomeCameraRequest.Password));
RuleFor(r => r.SortOrder).InclusiveBetween(0, 10_000).WithMessage("Die Reihenfolge muss zwischen 0 und 10000 liegen.");
}
}
/// <summary>Verbindungstest einer Kamera. Mit <see cref="CameraId"/> und ohne <see cref="Password"/> gilt das gespeicherte Passwort dieser Kamera.</summary>
public sealed record TestHomeCameraRequest(Guid? CameraId, string Url, string? AuthUser, string? Password);
public sealed class TestHomeCameraRequestValidator : AbstractValidator<TestHomeCameraRequest>
{
public TestHomeCameraRequestValidator()
{
RuleFor(r => r.Url).Custom((url, context) =>
{
if (HomeRules.NormalizeStreamUrl(url).Error is { } error)
{
context.AddFailure(nameof(TestHomeCameraRequest.Url), error);
}
});
RuleFor(r => r.AuthUser).MaximumLength(HomeCamera.AuthUserMaxLength).WithMessage("Der Benutzername ist zu lang.");
RuleFor(r => r.Password).Must(p => p is null || p.Length <= HomeCamera.PasswordMaxLength).WithMessage("Das Passwort ist zu lang.");
}
}
@@ -0,0 +1,39 @@
@using FamilyPlanner.Shared.Home
<div class="fp-cam">
@if (_running)
{
@* Der Strom kommt von der Api (prüft die Freigabe); die Adresse der Kamera kennt der Browser nie. Die Adresse bleibt gleich, sonst würde der Strom bei jedem Rendern neu starten. *@
<img @key="_attempt" class="fp-cam-img" src="@HomeApiRoutes.CameraStream(Id)" alt="@($"Livebild der Kamera {Name}")" draggable="false" />
<div class="fp-cam-fallback" role="status">
Kein Bild von dieser Kamera. Sie ist nicht erreichbar oder wird schon von zu vielen Geräten angesehen.
</div>
}
else
{
<div class="fp-cam-paused" role="status">Das Livebild ist angehalten.</div>
}
</div>
<div class="d-flex flex-wrap gap-2 mt-2 fp-cam-controls">
<MudButton Size="Size.Small" Variant="Variant.Outlined" StartIcon="@(_running ? Icons.Material.Filled.Pause : Icons.Material.Filled.PlayArrow)"
OnClick="Toggle" Class="fp-cam-toggle">@(_running ? "Anhalten" : "Livebild starten")</MudButton>
@if (_running)
{
<MudButton Size="Size.Small" Variant="Variant.Text" StartIcon="@Icons.Material.Filled.Refresh" OnClick="Reconnect" Class="fp-cam-reconnect">Neu verbinden</MudButton>
}
</div>
@code {
private bool _running = true;
private int _attempt;
[Parameter, EditorRequired]
public Guid Id { get; set; }
[Parameter, EditorRequired]
public string Name { get; set; } = string.Empty;
private void Toggle() => _running = !_running;
private void Reconnect() => _attempt++;
}
@@ -0,0 +1,148 @@
@using FamilyPlanner.Domain.Home
@using FamilyPlanner.Shared.Home
@inject IHomeApi Api
<MudDialog>
<DialogContent>
<EditForm Model="this" OnSubmit="SaveAsync" FormName="house-camera-access" id="house-camera-access-form">
<MudStack Spacing="3">
@if (_error is not null)
{
<MudAlert Severity="Severity.Error" role="alert" Class="fp-dialog-error">@_error</MudAlert>
}
<MudText Typo="Typo.body2">
Administratoren dürfen immer alles ansehen. Ohne Freigabe ist die Kamera für alle anderen unsichtbar. Kameras können nur angesehen werden.
</MudText>
<div>
<MudText Typo="Typo.subtitle2" HtmlTag="h3">Freigabe nach Rolle</MudText>
@foreach (var role in HomeRules.GrantableRoles)
{
var current = role;
<MudCheckBox T="bool" Value="_roles.Contains(current)" ValueChanged="v => SetRole(current, v)" Label="@Roles.DisplayName(current)" Class="fp-camera-access-role" />
}
</div>
<div>
<MudText Typo="Typo.subtitle2" HtmlTag="h3">Freigabe für einzelne Personen</MudText>
@if (_people.Count == 0)
{
<MudText Typo="Typo.caption">Keine einzelnen Personen freigegeben.</MudText>
}
@foreach (var person in _people.ToList())
{
var current = person;
<div class="d-flex align-center gap-2 mt-1 fp-camera-access-person">
<MudText Class="flex-grow-1">@PersonName(current)</MudText>
<MudIconButton Icon="@Icons.Material.Filled.Close" OnClick="() => _people.Remove(current)" aria-label="@($"Freigabe für {PersonName(current)} entfernen")" />
</div>
}
@if (AvailablePeople.Count > 0)
{
<div class="d-flex align-center gap-2 mt-3">
<MudSelect T="Guid?" @bind-Value="_addPerson" Label="Person hinzufügen" Variant="Variant.Outlined" Margin="Margin.Dense" Clearable="true" Class="fp-camera-access-add">
@foreach (var user in AvailablePeople)
{
<MudSelectItem T="Guid?" Value="@user.Id">@user.DisplayName (@Roles.DisplayName(user.Role))</MudSelectItem>
}
</MudSelect>
<MudButton Variant="Variant.Outlined" Disabled="@(_addPerson is null)" OnClick="AddPerson" Class="fp-camera-access-add-button">Hinzufügen</MudButton>
</div>
}
</div>
</MudStack>
</EditForm>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel" Disabled="_busy">Abbrechen</MudButton>
<MudButton ButtonType="ButtonType.Submit" form="house-camera-access-form" Variant="Variant.Filled" Color="Color.Primary" Disabled="_busy" Class="fp-dialog-save">Speichern</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance? Dialog { get; set; }
[Parameter, EditorRequired]
public HomeAdminCameraDto Camera { get; set; } = default!;
[Parameter]
public IReadOnlyList<Shared.Admin.UserDirectoryEntry> Users { get; set; } = [];
private readonly HashSet<string> _roles = [];
private readonly HashSet<Guid> _people = [];
private Guid? _addPerson;
private string? _error;
private bool _busy;
private List<Shared.Admin.UserDirectoryEntry> AvailablePeople =>
Users.Where(u => u.Role != Roles.Admin && !_people.Contains(u.Id)).OrderBy(u => u.DisplayName).ToList();
protected override void OnInitialized()
{
foreach (var grant in Camera.Grants)
{
if (grant.Role is { } role)
{
_roles.Add(role);
}
else if (grant.UserId is { } id)
{
_people.Add(id);
}
}
}
private string PersonName(Guid id) => Users.FirstOrDefault(u => u.Id == id)?.DisplayName ?? "Unbekannte Person";
private void SetRole(string role, bool on)
{
if (on)
{
_roles.Add(role);
}
else
{
_roles.Remove(role);
}
}
private void AddPerson()
{
if (_addPerson is { } id)
{
_people.Add(id);
_addPerson = null;
}
}
private async Task SaveAsync()
{
var grants = _roles.Select(r => new HomeGrantDto(r, null, HomeAccessLevel.View))
.Concat(_people.Select(p => new HomeGrantDto(null, p, HomeAccessLevel.View)))
.ToList();
var request = new SetHomeAccessRequest(grants);
var validation = new SetHomeAccessRequestValidator().Validate(request);
if (!validation.IsValid)
{
_error = validation.Errors[0].ErrorMessage;
return;
}
_busy = true;
var result = await Api.SetCameraAccessAsync(Camera.Id, request);
_busy = false;
if (result.Problem is { } problem)
{
_error = problem.Message;
return;
}
Dialog?.Close(DialogResult.Ok(result.Value));
}
private void Cancel() => Dialog?.Cancel();
}
@@ -0,0 +1,150 @@
@using FamilyPlanner.Domain.Home
@using FamilyPlanner.Shared.Home
@inject IHomeApi Api
<MudDialog>
<DialogContent>
<EditForm Model="this" OnSubmit="SaveAsync" FormName="house-camera" id="house-camera-form">
<MudStack Spacing="3">
@if (_generalError is not null)
{
<MudAlert Severity="Severity.Error" role="alert" Class="fp-dialog-error">@_generalError</MudAlert>
}
<MudTextField @bind-Value="_name" Label="Name" InputId="camera-name" Variant="Variant.Outlined" AutoFocus="true"
Placeholder="z. B. Haustür" MaxLength="HomeCamera.NameMaxLength"
Error="@HasError("Name")" ErrorText="@ErrorFor("Name")" />
<MudTextField @bind-Value="_url" Label="Adresse des Videostroms" InputId="camera-url" Variant="Variant.Outlined"
Placeholder="http://192.168.1.20:8090/video.mjpg?oids=1&size=1280x720" MaxLength="HomeCamera.UrlMaxLength"
HelperText="MJPEG-Strom im Heimnetz. Der Server muss die Kamera erreichen können (bei Docker nicht „localhost“, sondern die IP-Adresse des Rechners)."
Error="@HasError("Url")" ErrorText="@ErrorFor("Url")" />
<MudCheckBox T="bool" @bind-Value="_showOnDashboard" Label="Als Kachel auf der Startseite zeigen (für alle, die die Kamera sehen dürfen)" Class="fp-camera-dashboard" />
<MudText Typo="Typo.subtitle2" HtmlTag="h3">Anmeldung an der Kamera (optional, HTTP Basic)</MudText>
<MudTextField @bind-Value="_authUser" Label="Benutzername" InputId="camera-user" Variant="Variant.Outlined" autocomplete="off"
Error="@HasError("AuthUser")" ErrorText="@ErrorFor("AuthUser")" />
<MudTextField @bind-Value="_password" Label="@(Camera?.HasPassword == true ? "Passwort (leer lassen = unverändert)" : "Passwort")" InputId="camera-password"
Variant="Variant.Outlined" InputType="InputType.Password" Disabled="_clearPassword" autocomplete="new-password"
HelperText="Wird verschlüsselt gespeichert und nie wieder angezeigt."
Error="@HasError("Password")" ErrorText="@ErrorFor("Password")" />
@if (Camera?.HasPassword == true)
{
<MudCheckBox T="bool" @bind-Value="_clearPassword" Label="Gespeichertes Passwort entfernen" Class="fp-camera-clearpw" />
}
<div>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.NetworkCheck" OnClick="TestAsync" Disabled="@(_testing || _busy)" Class="fp-camera-test">
@(_testing ? "Wird geprüft …" : "Verbindung testen")
</MudButton>
@if (_testResult is { } test)
{
<MudAlert Severity="@(test.Success ? Severity.Success : Severity.Error)" Class="mt-2 fp-camera-testresult" role="status" Dense="true">
@test.Message
@if (test.Success)
{
<span> (@test.DurationMs ms)</span>
}
</MudAlert>
}
</div>
</MudStack>
</EditForm>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel" Disabled="_busy">Abbrechen</MudButton>
<MudButton ButtonType="ButtonType.Submit" form="house-camera-form" Variant="Variant.Filled" Color="Color.Primary" Disabled="_busy" Class="fp-dialog-save">Speichern</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance? Dialog { get; set; }
/// <summary>Zu bearbeitende Kamera; <c>null</c> legt eine neue an.</summary>
[Parameter]
public HomeAdminCameraDto? Camera { get; set; }
private string _name = string.Empty;
private string _url = string.Empty;
private string? _authUser;
private string? _password;
private bool _clearPassword;
private bool _showOnDashboard;
private Dictionary<string, string> _errors = new(StringComparer.OrdinalIgnoreCase);
private string? _generalError;
private HomeTestResultDto? _testResult;
private bool _busy;
private bool _testing;
protected override void OnInitialized()
{
if (Camera is { } c)
{
(_name, _url, _authUser, _showOnDashboard) = (c.Name, c.Url, c.AuthUser, c.ShowOnDashboard);
}
}
private string? User => string.IsNullOrWhiteSpace(_authUser) ? null : _authUser.Trim();
private SaveHomeCameraRequest BuildRequest() => new(
_name.Trim(), _url.Trim(), User, string.IsNullOrEmpty(_password) ? null : _password, _clearPassword, _showOnDashboard,
Camera?.SortOrder ?? 0, Camera?.Version);
private async Task SaveAsync()
{
_generalError = null;
var request = BuildRequest();
var validation = new SaveHomeCameraRequestValidator().Validate(request);
_errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => string.Join(" ", g.Select(e => e.ErrorMessage)), StringComparer.OrdinalIgnoreCase);
if (!validation.IsValid)
{
return;
}
_busy = true;
var result = Camera is null ? await Api.CreateCameraAsync(request) : await Api.UpdateCameraAsync(Camera.Id, request);
_busy = false;
if (result.Problem is { } problem)
{
_errors = (problem.Errors ?? [])
.Where(e => e.Key.Length > 0)
.ToDictionary(e => e.Key, e => string.Join(" ", e.Value), StringComparer.OrdinalIgnoreCase);
if (_errors.Count == 0)
{
_generalError = problem.Message;
}
return;
}
Dialog?.Close(DialogResult.Ok(result.Value));
}
private async Task TestAsync()
{
_testResult = null;
var request = new TestHomeCameraRequest(Camera?.Id, _url.Trim(), User, string.IsNullOrEmpty(_password) ? null : _password);
var validation = new TestHomeCameraRequestValidator().Validate(request);
if (!validation.IsValid)
{
_errors = validation.Errors.GroupBy(e => e.PropertyName).ToDictionary(g => g.Key, g => string.Join(" ", g.Select(e => e.ErrorMessage)), StringComparer.OrdinalIgnoreCase);
return;
}
_errors = new(StringComparer.OrdinalIgnoreCase);
_testing = true;
var result = await Api.TestCameraAsync(request);
_testing = false;
_testResult = result.Value ?? new HomeTestResultDto(false, result.Problem?.Message ?? "Der Test ist fehlgeschlagen.", null, null, 0);
}
private bool HasError(string field) => _errors.ContainsKey(field);
private string? ErrorFor(string field) => _errors.GetValueOrDefault(field);
private void Cancel() => Dialog?.Cancel();
}
@@ -0,0 +1,143 @@
@using FamilyPlanner.Domain.Home
@using FamilyPlanner.Shared.Home
@inject IHomeApi Api
@inject IDialogService DialogService
@inject ISnackbar Snackbar
<div class="d-flex align-center flex-wrap gap-2 mb-2">
<MudText Typo="Typo.h6" HtmlTag="h2">Kameras</MudText>
<MudSpacer />
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="() => EditAsync(null)" Class="fp-camera-add">Kamera hinzufügen</MudButton>
</div>
<MudText Typo="Typo.body2" Class="mb-3">
Kameras mit MJPEG-Videostrom im Heimnetz (z. B. <code>http://192.168.1.20:8090/video.mjpg</code>). Der Server holt den Strom und reicht ihn weiter – er muss die Kamera erreichen können
(bei Docker ist „localhost“ der Container selbst, siehe ADR-0024). Nicht freigegebene Kameras sehen nur Administratoren.
</MudText>
@if (_cameras is null)
{
<LoadingIndicator />
}
else if (_cameras.Count == 0)
{
<MudAlert Severity="Severity.Info" Class="fp-camera-empty">Noch keine Kameras eingerichtet.</MudAlert>
}
else
{
<MudTable T="HomeAdminCameraDto" Items="_cameras" Breakpoint="Breakpoint.Sm" Elevation="0" Outlined="true" Class="fp-camera-table">
<HeaderContent>
<MudTh>Name</MudTh>
<MudTh>Adresse</MudTh>
<MudTh>Startseite</MudTh>
<MudTh>Freigaben</MudTh>
<MudTh><span class="sr-only">Aktionen</span></MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Name"><MudIcon Icon="@Icons.Material.Filled.Videocam" Size="Size.Small" aria-hidden="true" /> @context.Name</MudTd>
<MudTd DataLabel="Adresse">
<span class="fp-camera-url">@context.Url</span>
<MudText Typo="Typo.caption">@(context.HasPassword ? "mit Passwort" : "ohne Passwort")</MudText>
</MudTd>
<MudTd DataLabel="Startseite">@(context.ShowOnDashboard ? "Ja" : "Nein")</MudTd>
<MudTd DataLabel="Freigaben"><span class="fp-camera-grants">@DescribeGrants(context)</span></MudTd>
<MudTd Style="text-align:right">
<MudButton Size="Size.Small" Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.NetworkCheck" Disabled="@(_testing == context.Id)"
OnClick="() => TestAsync(context)" Class="fp-camera-test-row">Testen</MudButton>
<MudIconButton Icon="@Icons.Material.Filled.Group" OnClick="() => AccessAsync(context)" aria-label="@($"Freigaben von {context.Name}")" Class="fp-camera-access" />
<MudIconButton Icon="@Icons.Material.Filled.Edit" OnClick="() => EditAsync(context)" aria-label="@($"{context.Name} bearbeiten")" Class="fp-camera-edit" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" OnClick="() => DeleteAsync(context)" aria-label="@($"{context.Name} löschen")" Class="fp-camera-delete" />
</MudTd>
</RowTemplate>
</MudTable>
}
@if (_testOutcome is { } outcome)
{
<MudAlert Severity="@(outcome.Result.Success ? Severity.Success : Severity.Error)" Class="mt-3 fp-camera-testoutcome" role="status">
<strong>@outcome.Name:</strong> @outcome.Result.Message
@if (outcome.Result.Success)
{
<span> (@outcome.Result.DurationMs ms)</span>
}
</MudAlert>
}
@code {
private sealed record TestOutcome(string Name, HomeTestResultDto Result);
private List<HomeAdminCameraDto>? _cameras;
private List<Shared.Admin.UserDirectoryEntry> _users = [];
private TestOutcome? _testOutcome;
private Guid? _testing;
protected override Task OnInitializedAsync() => LoadAsync();
public async Task LoadAsync() => _cameras = (await Api.GetAdminCamerasAsync()).Value ?? [];
private static string DescribeGrants(HomeAdminCameraDto camera)
{
if (camera.Grants.Count == 0)
{
return "Nur Administratoren";
}
var parts = camera.Grants.Where(g => g.Role is not null).Select(g => Roles.DisplayName(g.Role!)).ToList();
var people = camera.Grants.Count(g => g.UserId is not null);
if (people > 0)
{
parts.Add($"{people} {(people == 1 ? "Person" : "Personen")}");
}
return string.Join(" · ", parts);
}
private async Task EditAsync(HomeAdminCameraDto? camera)
{
var dialog = await DialogService.ShowAsync<HouseCameraDialog>(camera is null ? "Kamera hinzufügen" : $"{camera.Name} bearbeiten",
new DialogParameters<HouseCameraDialog> { { d => d.Camera, camera } },
new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true, CloseOnEscapeKey = true });
if (await dialog.Result is { Canceled: false })
{
Snackbar.Add("Kamera gespeichert.", Severity.Success);
await LoadAsync();
}
}
private async Task AccessAsync(HomeAdminCameraDto camera)
{
if (_users.Count == 0)
{
_users = (await Api.GetUsersAsync()).Value ?? [];
}
var dialog = await DialogService.ShowAsync<HouseCameraAccessDialog>($"Freigaben: {camera.Name}",
new DialogParameters<HouseCameraAccessDialog> { { d => d.Camera, camera }, { d => d.Users, _users } },
new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true, CloseOnEscapeKey = true });
if (await dialog.Result is { Canceled: false })
{
Snackbar.Add("Freigaben gespeichert.", Severity.Success);
await LoadAsync();
}
}
private async Task DeleteAsync(HomeAdminCameraDto camera)
{
if (await DialogService.ShowMessageBoxAsync("Kamera löschen?", $"„{camera.Name}“ wird entfernt – samt Freigaben und gespeichertem Passwort.",
yesText: "Löschen", cancelText: "Abbrechen") != true)
{
return;
}
var result = await Api.DeleteCameraAsync(camera.Id);
Snackbar.Add(result.Problem?.Message ?? "Kamera gelöscht.", result.Succeeded ? Severity.Success : Severity.Error);
await LoadAsync();
}
private async Task TestAsync(HomeAdminCameraDto camera)
{
_testing = camera.Id;
var result = await Api.TestCameraAsync(new TestHomeCameraRequest(camera.Id, camera.Url, camera.AuthUser, null));
_testing = null;
_testOutcome = new TestOutcome(camera.Name, result.Value ?? new HomeTestResultDto(false, result.Problem?.Message ?? "Der Test ist fehlgeschlagen.", null, null, 0));
}
}
@@ -11,7 +11,7 @@
</MudAlert>
}
<TaskEditor Item="_task" Actor="Actor" Users="Store.Users" Categories="Store.Categories" FormId="task-edit-form" Busy="_busy"
ServerErrors="_errors" GeneralError="_generalError" OnSubmit="SaveAsync" OnCancel="Cancel" />
ServerErrors="_errors" GeneralError="@_generalError" OnSubmit="SaveAsync" OnCancel="Cancel" />
@if (TaskRules.CanEdit(_task, Actor))
{
<MudDivider Class="my-2" />
@@ -22,6 +22,7 @@ public static class NavItems
// Haus (ADR-0023): Seite für alle mit Haus-Recht (Geräte nur nach Freigabe), Hausverwaltung nur für Administratoren.
new("haus", "Haus", "Haus", Icons.Material.Filled.Home, InBottomNav: false, Policies.HomeUse),
new("haus/kameras", "Kameras", "Kameras", Icons.Material.Filled.Videocam, InBottomNav: false, Policies.HomeUse, Child: true),
new("haus/verwaltung", "Hausverwaltung", "Hausverw.", Icons.Material.Filled.HomeRepairService, InBottomNav: false, Policies.Admin, Child: true),
];
}
@@ -1,6 +1,7 @@
@page "/"
@using FamilyPlanner.Shared.Dashboard
@using FamilyPlanner.Web.Client.Components.Dashboard
@using FamilyPlanner.Web.Client.Components.House
@implements IDisposable
@inject ApiAuthenticationStateProvider AuthState
@inject IDashboardApi DashboardApi
@@ -286,6 +287,25 @@ else
</MudItem>
}
@if (_data.Cameras is { } cameras)
{
@foreach (var camera in cameras)
{
<MudItem xs="12" sm="6" lg="4" @key="camera.Id">
<MudCard Outlined="true" Class="fp-tile fp-dash-camera">
<MudCardHeader>
<CardHeaderAvatar><MudIcon Icon="@Icons.Material.Filled.Videocam" Color="Color.Primary" /></CardHeaderAvatar>
<CardHeaderContent><MudText Typo="Typo.h6" HtmlTag="h2">@camera.Name</MudText></CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<CameraView Id="camera.Id" Name="@camera.Name" />
</MudCardContent>
<MudCardActions><MudButton Href="haus/kameras" Variant="Variant.Text" Color="Color.Primary">Alle Kameras</MudButton></MudCardActions>
</MudCard>
</MudItem>
}
}
@if (_data.Notes is { } notes && (notes.Items.Count > 0 || notes.CanEdit))
{
<MudItem xs="12" sm="6" lg="4">
@@ -12,6 +12,7 @@
<div class="d-flex align-center flex-wrap gap-2 mb-3">
<MudText Typo="Typo.h4" HtmlTag="h1">Haus</MudText>
<MudSpacer />
<MudButton Variant="Variant.Outlined" Href="haus/kameras" StartIcon="@Icons.Material.Filled.Videocam" Class="fp-house-cameras">Kameras</MudButton>
@if (_overview?.CanManage == true)
{
<MudButton Variant="Variant.Outlined" Href="haus/verwaltung" StartIcon="@Icons.Material.Filled.Settings" Class="fp-house-manage">Hausverwaltung</MudButton>
@@ -20,6 +20,9 @@
<MudTabPanel Text="Pläne" Icon="@Icons.Material.Filled.Map">
<HousePlansAdmin />
</MudTabPanel>
<MudTabPanel Text="Kameras" Icon="@Icons.Material.Filled.Videocam">
<HouseCamerasAdmin />
</MudTabPanel>
<MudTabPanel Text="Protokoll" Icon="@Icons.Material.Filled.History">
<HouseLogView />
</MudTabPanel>
@@ -0,0 +1,76 @@
@page "/haus/kameras"
@attribute [Authorize(Policy = Policies.HomeUse)]
@using FamilyPlanner.Shared.Home
@using FamilyPlanner.Web.Client.Components.House
@inject IHomeApi Api
<PageTitle>Kameras – Familienplaner</PageTitle>
<div class="d-flex align-center flex-wrap gap-2 mb-3">
<MudText Typo="Typo.h4" HtmlTag="h1">Kameras</MudText>
<MudSpacer />
<MudButton Variant="Variant.Outlined" Href="haus" StartIcon="@Icons.Material.Filled.Home" Class="fp-cameras-back">Zur Haus-Ansicht</MudButton>
</div>
@if (_cameras is null)
{
@if (_error is not null)
{
<MudAlert Severity="Severity.Warning" role="alert" Class="fp-cameras-error">@_error</MudAlert>
<MudButton Class="mt-2" Variant="Variant.Outlined" OnClick="LoadAsync">Erneut versuchen</MudButton>
}
else
{
<LoadingIndicator />
}
}
else if (_cameras.Count == 0)
{
<MudAlert Severity="Severity.Info" Class="fp-cameras-empty">
Für dich sind noch keine Kameras freigegeben. Administratoren richten Kameras in der Hausverwaltung ein.
</MudAlert>
}
else
{
<MudGrid Spacing="3">
@foreach (var camera in _cameras)
{
<MudItem xs="12" md="6" xl="4" @key="camera.Id">
<MudCard Outlined="true" Class="fp-camera-card">
<MudCardHeader>
<CardHeaderAvatar><MudIcon Icon="@Icons.Material.Filled.Videocam" Color="Color.Primary" /></CardHeaderAvatar>
<CardHeaderContent><MudText Typo="Typo.h6" HtmlTag="h2">@camera.Name</MudText></CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<CameraView Id="camera.Id" Name="@camera.Name" />
</MudCardContent>
<MudCardActions>
<MudButton Href="@HomeApiRoutes.CameraStream(camera.Id)" Target="_blank" Variant="Variant.Text" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.OpenInNew" Class="fp-camera-open">Großansicht</MudButton>
</MudCardActions>
</MudCard>
</MudItem>
}
</MudGrid>
}
@code {
private List<HomeCameraDto>? _cameras;
private string? _error;
protected override Task OnInitializedAsync() => LoadAsync();
private async Task LoadAsync()
{
_error = null;
var result = await Api.GetCamerasAsync();
if (result.Value is { } cameras)
{
_cameras = cameras;
}
else
{
_error = result.Problem?.Message ?? "Die Kameras konnten nicht geladen werden.";
}
}
}
@@ -75,6 +75,27 @@ public interface IHomeApi
// Verwaltung: Protokoll
Task<ApiResult<PagedResult<HomeActionLogDto>>> GetLogAsync(int page, int pageSize, CancellationToken ct = default) =>
Task.FromResult(ApiResult<PagedResult<HomeActionLogDto>>.Ok(new PagedResult<HomeActionLogDto>([], 0, page, pageSize)));
// Kameras (ADR-0024)
Task<ApiResult<List<HomeCameraDto>>> GetCamerasAsync(CancellationToken ct = default) =>
Task.FromResult(ApiResult<List<HomeCameraDto>>.Ok([]));
Task<ApiResult<List<HomeAdminCameraDto>>> GetAdminCamerasAsync(CancellationToken ct = default) =>
Task.FromResult(ApiResult<List<HomeAdminCameraDto>>.Ok([]));
Task<ApiResult<HomeAdminCameraDto>> CreateCameraAsync(SaveHomeCameraRequest request, CancellationToken ct = default) =>
Task.FromResult(ApiResult<HomeAdminCameraDto>.Fail(ApiProblem.Offline));
Task<ApiResult<HomeAdminCameraDto>> UpdateCameraAsync(Guid id, SaveHomeCameraRequest request, CancellationToken ct = default) =>
Task.FromResult(ApiResult<HomeAdminCameraDto>.Fail(ApiProblem.Offline));
Task<ApiResult<bool>> DeleteCameraAsync(Guid id, CancellationToken ct = default) => Task.FromResult(ApiResult<bool>.Ok(true));
Task<ApiResult<HomeTestResultDto>> TestCameraAsync(TestHomeCameraRequest request, CancellationToken ct = default) =>
Task.FromResult(ApiResult<HomeTestResultDto>.Fail(ApiProblem.Offline));
Task<ApiResult<HomeAdminCameraDto>> SetCameraAccessAsync(Guid id, SetHomeAccessRequest request, CancellationToken ct = default) =>
Task.FromResult(ApiResult<HomeAdminCameraDto>.Fail(ApiProblem.Offline));
}
public sealed class HomeApi(HttpClient http) : IHomeApi
@@ -158,6 +179,27 @@ public sealed class HomeApi(HttpClient http) : IHomeApi
public Task<ApiResult<PagedResult<HomeActionLogDto>>> GetLogAsync(int page, int pageSize, CancellationToken ct = default) =>
Send<PagedResult<HomeActionLogDto>>(() => http.GetAsync($"{HomeApiRoutes.Log}?page={page}&pageSize={pageSize}", ct), ct);
public Task<ApiResult<List<HomeCameraDto>>> GetCamerasAsync(CancellationToken ct = default) =>
Send<List<HomeCameraDto>>(() => http.GetAsync(HomeApiRoutes.Cameras, ct), ct);
public Task<ApiResult<List<HomeAdminCameraDto>>> GetAdminCamerasAsync(CancellationToken ct = default) =>
Send<List<HomeAdminCameraDto>>(() => http.GetAsync(HomeApiRoutes.AdminCameras, ct), ct);
public Task<ApiResult<HomeAdminCameraDto>> CreateCameraAsync(SaveHomeCameraRequest request, CancellationToken ct = default) =>
Send<HomeAdminCameraDto>(() => http.PostAsJsonAsync(HomeApiRoutes.AdminCameras, request, ct), ct);
public Task<ApiResult<HomeAdminCameraDto>> UpdateCameraAsync(Guid id, SaveHomeCameraRequest request, CancellationToken ct = default) =>
Send<HomeAdminCameraDto>(() => http.PutAsJsonAsync(HomeApiRoutes.AdminCamera(id), request, ct), ct);
public Task<ApiResult<bool>> DeleteCameraAsync(Guid id, CancellationToken ct = default) =>
ApiCall.SendAsync(() => http.DeleteAsync(HomeApiRoutes.AdminCamera(id), ct), ct);
public Task<ApiResult<HomeTestResultDto>> TestCameraAsync(TestHomeCameraRequest request, CancellationToken ct = default) =>
Send<HomeTestResultDto>(() => http.PostAsJsonAsync(HomeApiRoutes.AdminCameraTest, request, ct), ct);
public Task<ApiResult<HomeAdminCameraDto>> SetCameraAccessAsync(Guid id, SetHomeAccessRequest request, CancellationToken ct = default) =>
Send<HomeAdminCameraDto>(() => http.PutAsJsonAsync(HomeApiRoutes.AdminCameraAccess(id), request, ct), ct);
private static StreamContent FileContent(Stream file, string contentType)
{
var content = new StreamContent(file);
@@ -73,7 +73,10 @@ public sealed class LiveConnection : ILiveConnection, IAsyncDisposable
{
private readonly HubConnection _hub;
private readonly CancellationTokenSource _stopping = new();
private static readonly TimeSpan StableAfter = TimeSpan.FromSeconds(10);
private Task? _startLoop;
private DateTimeOffset _connectedAt = DateTimeOffset.MinValue;
private int _quickCloses;
public LiveConnection(NavigationManager navigation)
{
@@ -102,17 +105,32 @@ public sealed class LiveConnection : ILiveConnection, IAsyncDisposable
_hub.Reconnecting += _ => SetState(LiveState.Connecting);
_hub.Reconnected += async _ =>
{
_connectedAt = DateTimeOffset.UtcNow;
await SetState(LiveState.Connected);
await Raise(Connected, h => h());
};
_hub.Closed += async _ =>
_hub.Closed += async error =>
{
await SetState(LiveState.Disconnected);
_startLoop = null;
if (!_stopping.IsCancellationRequested)
if (_stopping.IsCancellationRequested)
{
await EnsureStartedAsync();
return;
}
// Hält eine Verbindung nur kurz (z. B. Proxy/Browser bricht WebSockets sofort ab), nicht sofort neu verbinden: sonst springt der Status im
// Sekundentakt und jede Seite lädt bei jedem „Connected“ alles neu. Der Abstand wächst mit jeder kurzen Verbindung.
if (DateTimeOffset.UtcNow - _connectedAt < StableAfter)
{
Console.Error.WriteLine($"Live-Verbindung nach kurzer Zeit getrennt: {error?.Message ?? "ohne Fehlermeldung"}");
await Task.Delay(EndlessRetryPolicy.Delay(Math.Min(++_quickCloses, 4)), _stopping.Token).ConfigureAwait(false);
}
else
{
_quickCloses = 0;
}
await EnsureStartedAsync();
};
}
@@ -187,6 +205,7 @@ public sealed class LiveConnection : ILiveConnection, IAsyncDisposable
{
await SetState(LiveState.Connecting);
await _hub.StartAsync(_stopping.Token);
_connectedAt = DateTimeOffset.UtcNow;
await SetState(LiveState.Connected);
await Raise(Connected, h => h());
return;
+39
View File
@@ -2288,3 +2288,42 @@ a.fp-learning-title:hover {
transition: box-shadow .15s ease;
}
}
/* Kameras (ADR-0024): Livebild mit Hinweistext, wenn der Strom nicht kommt (house.js setzt data-failed am Bild) */
.fp-cam {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
background: var(--mud-palette-background-gray);
border-radius: var(--mud-default-borderradius);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.fp-cam-img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.fp-cam-img[data-failed] {
display: none;
}
.fp-cam-fallback,
.fp-cam-paused {
padding: 16px;
text-align: center;
color: var(--mud-palette-text-secondary);
}
.fp-cam-fallback {
display: none;
}
.fp-cam-img[data-failed] ~ .fp-cam-fallback {
display: block;
}
+13
View File
@@ -11,6 +11,19 @@
}
};
// Kamerabilder (<img> mit MJPEG-Strom): Fehler und Laden lassen sich nur am Element erkennen; das Anzeigen des Hinweises übernimmt das CSS (.fp-cam-img[data-failed]).
// Die Ereignisse sprudeln nicht nach oben, daher im Capture-Modus am Dokument.
document.addEventListener('error', event => {
if (event.target instanceof HTMLImageElement && event.target.classList.contains('fp-cam-img')) {
event.target.dataset.failed = 'true';
}
}, true);
document.addEventListener('load', event => {
if (event.target instanceof HTMLImageElement && event.target.classList.contains('fp-cam-img')) {
delete event.target.dataset.failed;
}
}, true);
window.fpHome = {
attach(container, reference) {
if (!container) {
@@ -0,0 +1,152 @@
using System.Net;
using System.Text;
namespace FamilyPlanner.IntegrationTests;
/// <summary>
/// Winziger HTTP-Server, der eine MJPEG-Kamera vortäuscht: <c>/video.mjpg</c> liefert endlos <c>multipart/x-mixed-replace</c>-Bilder,
/// <c>/secure.mjpg</c> verlangt Basic-Anmeldung, <c>/page.html</c> ist kein Videostrom, <c>/moved.mjpg</c> leitet weiter.
/// Zählt offene Verbindungen, damit Tests prüfen können, dass die Api die Kamera loslässt, wenn der Zuschauer geht.
/// </summary>
internal sealed class FakeMjpegServer : IAsyncDisposable
{
public const string Boundary = "fakeframe";
public const string Password = "KAMERA-PASSWORT-4711";
private static readonly byte[] Jpeg = [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0xFF, 0xD9];
private readonly HttpListener _listener = new();
private readonly CancellationTokenSource _stop = new();
private int _active;
private int _total;
private FakeMjpegServer(int port)
{
Port = port;
_listener.Prefixes.Add($"http://localhost:{port}/");
}
public int Port { get; }
/// <summary>Adresse eines Videostroms, wie sie in der Kamera eingetragen wird (Heimnetz-Name ohne Punkt, Loopback).</summary>
public string Url(string path = "video.mjpg?oids=1&size=1280x720") => $"http://localhost:{Port}/{path}";
/// <summary>Gerade offene Streams.</summary>
public int Active => Volatile.Read(ref _active);
/// <summary>Alle bisher angenommenen Streams.</summary>
public int Total => Volatile.Read(ref _total);
public static FakeMjpegServer Start()
{
var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
probe.Start();
var port = ((IPEndPoint)probe.LocalEndpoint).Port;
probe.Stop();
var server = new FakeMjpegServer(port);
server._listener.Start();
_ = server.LoopAsync();
return server;
}
private async Task LoopAsync()
{
while (!_stop.IsCancellationRequested)
{
HttpListenerContext context;
try
{
context = await _listener.GetContextAsync();
}
catch (Exception) when (_stop.IsCancellationRequested || !_listener.IsListening)
{
return;
}
_ = Task.Run(() => HandleAsync(context));
}
}
private async Task HandleAsync(HttpListenerContext context)
{
var response = context.Response;
try
{
switch (context.Request.Url!.AbsolutePath)
{
case "/video.mjpg":
await StreamAsync(response);
return;
case "/secure.mjpg":
if (context.Request.Headers["Authorization"] != "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes($"kamera:{Password}")))
{
response.StatusCode = 401;
response.AddHeader("WWW-Authenticate", "Basic realm=\"cam\"");
response.Close();
return;
}
await StreamAsync(response);
return;
case "/moved.mjpg":
response.StatusCode = 302;
response.RedirectLocation = "http://localhost/elsewhere";
response.Close();
return;
default:
response.ContentType = "text/html";
await response.OutputStream.WriteAsync(Encoding.UTF8.GetBytes("<html>geheimer Seiteninhalt der Kamera</html>"));
response.Close();
return;
}
}
catch
{
// Zuschauer weg: Verbindung abgebrochen.
}
}
private async Task StreamAsync(HttpListenerResponse response)
{
Interlocked.Increment(ref _active);
Interlocked.Increment(ref _total);
try
{
response.ContentType = $"multipart/x-mixed-replace; boundary={Boundary}";
response.SendChunked = true;
var output = response.OutputStream;
while (!_stop.IsCancellationRequested)
{
var head = Encoding.ASCII.GetBytes($"--{Boundary}\r\nContent-Type: image/jpeg\r\nContent-Length: {Jpeg.Length}\r\n\r\n");
await output.WriteAsync(head, _stop.Token);
await output.WriteAsync(Jpeg, _stop.Token);
await output.WriteAsync("\r\n"u8.ToArray(), _stop.Token);
await output.FlushAsync(_stop.Token);
await Task.Delay(40, _stop.Token);
}
}
catch
{
// Zuschauer (die Api) hat die Verbindung geschlossen.
}
finally
{
Interlocked.Decrement(ref _active);
try
{
response.Abort();
}
catch
{
// schon geschlossen
}
}
}
public ValueTask DisposeAsync()
{
_stop.Cancel();
_listener.Close();
return ValueTask.CompletedTask;
}
}
@@ -0,0 +1,354 @@
using System.Net;
using System.Net.Http.Json;
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Home;
using FamilyPlanner.Shared.Dashboard;
using FamilyPlanner.Shared.Home;
namespace FamilyPlanner.IntegrationTests;
/// <summary>
/// Kameras end-to-end (ADR-0024) gegen einen Fake-MJPEG-Server: Rechte je Rolle und je Kamera (unsichtbar = 404), Videostrom wird weitergereicht und beim
/// Weggehen des Zuschauers losgelassen, Fehlerfälle mit festen Meldungen, Zuschauer-Grenze, Dashboard-Kachel, Audit ohne Passwörter.
/// </summary>
public sealed class HomeCameraTests(AppFixture fixture)
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private static SaveHomeCameraRequest Camera(string name, string url, bool dashboard = false, string? user = null, string? password = null) =>
new(name, url, user, password, false, dashboard);
private static async Task<HomeAdminCameraDto> CreateAsync(HttpClient admin, SaveHomeCameraRequest request)
{
var response = await admin.PostAsJsonAsync("/api/admin/home/cameras", request, Ct);
Assert.True(response.StatusCode == HttpStatusCode.Created, await response.Content.ReadAsStringAsync(Ct));
return (await response.Content.ReadFromJsonAsync<HomeAdminCameraDto>(Ct))!;
}
private static async Task ShareAsync(HttpClient admin, Guid cameraId, params HomeGrantDto[] grants)
{
var response = await admin.PutAsJsonAsync($"/api/admin/home/cameras/{cameraId}/access", new SetHomeAccessRequest(grants), Ct);
Assert.True(response.IsSuccessStatusCode, await response.Content.ReadAsStringAsync(Ct));
}
private static async Task<List<HomeCameraDto>> VisibleAsync(HttpClient client) =>
(await client.GetFromJsonAsync<List<HomeCameraDto>>("/api/home/cameras", Ct))!;
private static async Task<bool> SeesAsync(HttpClient client, Guid id) => (await VisibleAsync(client)).Any(c => c.Id == id);
private static Task<HttpResponseMessage> OpenStreamAsync(HttpClient client, Guid id) =>
client.GetAsync($"/api/home/cameras/{id}/stream", HttpCompletionOption.ResponseHeadersRead, Ct);
private static async Task<string> ReadSomeAsync(HttpResponseMessage response)
{
await using var stream = await response.Content.ReadAsStreamAsync(Ct);
var buffer = new byte[256];
var read = await stream.ReadAsync(buffer, Ct);
return System.Text.Encoding.ASCII.GetString(buffer, 0, read);
}
private static async Task EventuallyAsync(Func<bool> condition)
{
var until = DateTime.UtcNow.AddSeconds(10);
while (!condition() && DateTime.UtcNow < until)
{
await Task.Delay(50, Ct);
}
Assert.True(condition());
}
[Fact]
public async Task Endpoints_need_login_and_administration_is_admin_only()
{
var (_, family) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
using var _f = family;
using var anonymous = fixture.CreateBrowserClient();
var id = Guid.NewGuid();
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync("/api/home/cameras", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync($"/api/home/cameras/{id}/stream", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync("/api/admin/home/cameras", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await family.GetAsync("/api/home/cameras", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await family.GetAsync("/api/admin/home/cameras", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await family.PostAsJsonAsync("/api/admin/home/cameras", Camera("x", "http://192.168.1.9/v.mjpg"), Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await family.PostAsJsonAsync("/api/admin/home/cameras/test", new TestHomeCameraRequest(null, "http://192.168.1.9/v.mjpg", null, null), Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await family.PutAsJsonAsync($"/api/admin/home/cameras/{id}/access", new SetHomeAccessRequest([]), Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await family.DeleteAsync($"/api/admin/home/cameras/{id}", Ct)).StatusCode);
}
[Fact]
public async Task Admin_streams_the_camera_through_the_api_and_the_camera_is_released_when_the_viewer_leaves()
{
await using var camera = FakeMjpegServer.Start();
using var admin = await fixture.CreateAdminClientAsync(Ct);
var created = await CreateAsync(admin, Camera("Haustür", camera.Url()));
using (var response = await OpenStreamAsync(admin, created.Id))
{
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.StartsWith("multipart/x-mixed-replace", response.Content.Headers.ContentType!.ToString());
Assert.Equal("no-store", response.Headers.CacheControl!.ToString());
await using var stream = await response.Content.ReadAsStreamAsync(Ct);
var buffer = new byte[256];
var read = await stream.ReadAsync(buffer, Ct);
Assert.Contains($"--{FakeMjpegServer.Boundary}", System.Text.Encoding.ASCII.GetString(buffer, 0, read));
Assert.Equal(1, camera.Active); // Strom läuft, solange der Zuschauer da ist
}
// Die Verbindung zur Kamera ist nach dem Weggehen des Zuschauers zu (sonst bliebe die Kamera belegt).
await EventuallyAsync(() => camera.Active == 0);
}
[Fact]
public async Task Camera_url_and_credentials_never_reach_non_admins()
{
await using var camera = FakeMjpegServer.Start();
using var admin = await fixture.CreateAdminClientAsync(Ct);
var created = await CreateAsync(admin, Camera("Geheim", camera.Url("secure.mjpg?token=GEHEIMES-TOKEN"), user: "kamera", password: FakeMjpegServer.Password));
var (_, child) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
using var _c = child;
await ShareAsync(admin, created.Id, new HomeGrantDto(Roles.Child, null, HomeAccessLevel.View));
var list = await child.GetStringAsync("/api/home/cameras", Ct);
Assert.Contains("Geheim", list);
Assert.DoesNotContain("GEHEIMES-TOKEN", list);
Assert.DoesNotContain(FakeMjpegServer.Password, list);
Assert.DoesNotContain("localhost", list);
var adminList = await admin.GetStringAsync("/api/admin/home/cameras", Ct);
Assert.DoesNotContain(FakeMjpegServer.Password, adminList);
Assert.True(created.HasPassword);
// Mit gespeichertem Passwort kommt der Strom beim Kind an.
using var response = await OpenStreamAsync(child, created.Id);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Contains($"--{FakeMjpegServer.Boundary}", await ReadSomeAsync(response));
}
[Fact]
public async Task Unshared_camera_is_invisible_to_others_and_shares_work_for_roles_and_persons()
{
await using var camera = FakeMjpegServer.Start();
using var admin = await fixture.CreateAdminClientAsync(Ct);
var created = await CreateAsync(admin, Camera("Garten", camera.Url()));
var (_, child) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
var (_, guest) = await fixture.CreateSignedInUserAsync(Roles.Guest, Ct);
var (familyUser, family) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
using var _c = child;
using var _g = guest;
using var _f = family;
// Ohne Freigabe: nur Admins; alle anderen bekommen 404 (kein Hinweis auf die Existenz) und keinen Zugriff auf die Kamera.
foreach (var client in new[] { child, guest, family })
{
Assert.False(await SeesAsync(client, created.Id));
Assert.Equal(HttpStatusCode.NotFound, (await OpenStreamAsync(client, created.Id)).StatusCode);
}
Assert.Equal(0, camera.Total);
// Rollenfreigabe gilt nur für diese Rolle.
await ShareAsync(admin, created.Id, new HomeGrantDto(Roles.Child, null, HomeAccessLevel.View));
Assert.True(await SeesAsync(child, created.Id));
Assert.False(await SeesAsync(guest, created.Id));
Assert.Equal(HttpStatusCode.NotFound, (await OpenStreamAsync(guest, created.Id)).StatusCode);
using (var ok = await OpenStreamAsync(child, created.Id))
{
Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
}
// Personenfreigabe: nur diese Person.
await ShareAsync(admin, created.Id, new HomeGrantDto(null, familyUser.Id, HomeAccessLevel.View));
Assert.True(await SeesAsync(family, created.Id));
Assert.False(await SeesAsync(child, created.Id)); // Freigabe wurde ersetzt
Assert.Equal(HttpStatusCode.NotFound, (await OpenStreamAsync(child, created.Id)).StatusCode);
// Freigabe für Admin ist unzulässig.
var invalid = await admin.PutAsJsonAsync($"/api/admin/home/cameras/{created.Id}/access", new SetHomeAccessRequest([new HomeGrantDto(Roles.Admin, null, HomeAccessLevel.View)]), Ct);
Assert.Equal(HttpStatusCode.BadRequest, invalid.StatusCode);
}
[Fact]
public async Task Deleted_camera_disappears_for_everybody()
{
await using var camera = FakeMjpegServer.Start();
using var admin = await fixture.CreateAdminClientAsync(Ct);
var created = await CreateAsync(admin, Camera("Weg", camera.Url()));
var (_, child) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
using var _c = child;
await ShareAsync(admin, created.Id, new HomeGrantDto(Roles.Child, null, HomeAccessLevel.View));
Assert.True(await SeesAsync(child, created.Id));
Assert.Equal(HttpStatusCode.NoContent, (await admin.DeleteAsync($"/api/admin/home/cameras/{created.Id}", Ct)).StatusCode);
Assert.False(await SeesAsync(child, created.Id));
Assert.Equal(HttpStatusCode.NotFound, (await OpenStreamAsync(admin, created.Id)).StatusCode);
Assert.DoesNotContain(created.Id.ToString(), await admin.GetStringAsync("/api/admin/home/cameras", Ct));
}
[Fact]
public async Task Camera_problems_become_fixed_german_messages_without_leaking_camera_content()
{
await using var camera = FakeMjpegServer.Start();
using var admin = await fixture.CreateAdminClientAsync(Ct);
async Task<string> ProblemAsync(string url, string? user = null, string? password = null)
{
var created = await CreateAsync(admin, Camera("Problem", url, user: user, password: password));
using var response = await OpenStreamAsync(admin, created.Id);
Assert.Equal(HttpStatusCode.BadGateway, response.StatusCode);
return await response.Content.ReadAsStringAsync(Ct);
}
var notVideo = await ProblemAsync(camera.Url("page.html"));
Assert.Contains("keinen MJPEG-Videostrom", notVideo);
Assert.DoesNotContain("geheimer Seiteninhalt", notVideo);
Assert.DoesNotContain("localhost", notVideo);
Assert.Contains("leitet weiter", await ProblemAsync(camera.Url("moved.mjpg")));
Assert.Contains("Anmeldung", await ProblemAsync(camera.Url("secure.mjpg"))); // ohne Zugangsdaten
Assert.Contains("Anmeldung", await ProblemAsync(camera.Url("secure.mjpg"), "kamera", "falsch"));
Assert.Contains("nicht erreichbar", await ProblemAsync("http://localhost:1/video.mjpg"));
}
[Fact]
public async Task Camera_test_reports_success_and_failure_without_saving()
{
await using var camera = FakeMjpegServer.Start();
using var admin = await fixture.CreateAdminClientAsync(Ct);
var ok = await (await admin.PostAsJsonAsync("/api/admin/home/cameras/test", new TestHomeCameraRequest(null, camera.Url(), null, null), Ct)).Content.ReadFromJsonAsync<HomeTestResultDto>(Ct);
Assert.True(ok!.Success);
Assert.Contains("MJPEG", ok.Message);
await EventuallyAsync(() => camera.Active == 0); // der Test hält die Kamera nicht fest
var bad = await (await admin.PostAsJsonAsync("/api/admin/home/cameras/test", new TestHomeCameraRequest(null, camera.Url("page.html"), null, null), Ct)).Content.ReadFromJsonAsync<HomeTestResultDto>(Ct);
Assert.False(bad!.Success);
// Zugangsdaten des gespeicherten Geräts gelten, wenn im Entwurf kein neues Passwort steht.
var created = await CreateAsync(admin, Camera("Test", camera.Url("secure.mjpg"), user: "kamera", password: FakeMjpegServer.Password));
var stored = await (await admin.PostAsJsonAsync("/api/admin/home/cameras/test", new TestHomeCameraRequest(created.Id, camera.Url("secure.mjpg"), "kamera", null), Ct)).Content.ReadFromJsonAsync<HomeTestResultDto>(Ct);
Assert.True(stored!.Success);
}
[Theory]
[InlineData("http://8.8.8.8/video.mjpg")]
[InlineData("http://169.254.169.254/latest/meta-data")]
[InlineData("ftp://192.168.1.9/video.mjpg")]
[InlineData("http://user:pw@192.168.1.9/video.mjpg")]
[InlineData("")]
public async Task Foreign_or_malformed_addresses_are_rejected(string url)
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
Assert.Equal(HttpStatusCode.BadRequest, (await admin.PostAsJsonAsync("/api/admin/home/cameras", Camera("SSRF", url), Ct)).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await admin.PostAsJsonAsync("/api/admin/home/cameras/test", new TestHomeCameraRequest(null, url, null, null), Ct)).StatusCode);
}
[Fact]
public async Task Number_of_viewers_per_camera_is_limited_and_freed_again()
{
await using var camera = FakeMjpegServer.Start();
using var admin = await fixture.CreateAdminClientAsync(Ct);
var created = await CreateAsync(admin, Camera("Beliebt", camera.Url()));
var open = new List<HttpResponseMessage>();
try
{
for (var i = 0; i < HomeCameraStreamerLimit; i++)
{
var response = await OpenStreamAsync(admin, created.Id);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
open.Add(response);
}
using var tooMany = await OpenStreamAsync(admin, created.Id);
Assert.Equal(HttpStatusCode.TooManyRequests, tooMany.StatusCode);
open[0].Dispose();
await EventuallyAsync(() => camera.Active == HomeCameraStreamerLimit - 1);
using var again = await OpenStreamAsync(admin, created.Id);
Assert.Equal(HttpStatusCode.OK, again.StatusCode);
}
finally
{
foreach (var response in open)
{
response.Dispose();
}
}
}
private const int HomeCameraStreamerLimit = 4;
[Fact]
public async Task Dashboard_shows_only_shared_cameras_marked_for_the_start_page()
{
await using var camera = FakeMjpegServer.Start();
using var admin = await fixture.CreateAdminClientAsync(Ct);
var onDashboard = await CreateAsync(admin, Camera("Auf der Startseite", camera.Url(), dashboard: true));
var hidden = await CreateAsync(admin, Camera("Nicht auf der Startseite", camera.Url(), dashboard: false));
var unshared = await CreateAsync(admin, Camera("Nicht freigegeben", camera.Url(), dashboard: true));
var (_, child) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
using var _c = child;
await ShareAsync(admin, onDashboard.Id, new HomeGrantDto(Roles.Child, null, HomeAccessLevel.View));
await ShareAsync(admin, hidden.Id, new HomeGrantDto(Roles.Child, null, HomeAccessLevel.View));
var tiles = (await child.GetFromJsonAsync<DashboardDto>("/api/dashboard", Ct))!.Cameras;
Assert.NotNull(tiles);
Assert.Contains(tiles, t => t.Id == onDashboard.Id && t.Name == "Auf der Startseite");
Assert.DoesNotContain(tiles, t => t.Id == unshared.Id);
Assert.DoesNotContain(tiles, t => t.Id == hidden.Id);
// Admins sehen jede Kamera mit Startseiten-Kennzeichen.
var adminTiles = (await admin.GetFromJsonAsync<DashboardDto>("/api/dashboard", Ct))!.Cameras!;
Assert.Contains(adminTiles, t => t.Id == onDashboard.Id);
Assert.Contains(adminTiles, t => t.Id == unshared.Id);
Assert.DoesNotContain(adminTiles, t => t.Id == hidden.Id);
}
[Fact]
public async Task Camera_administration_is_audited_without_secrets_or_query_tokens()
{
await using var camera = FakeMjpegServer.Start();
using var admin = await fixture.CreateAdminClientAsync(Ct);
var created = await CreateAsync(admin, Camera("Audit-Kamera", camera.Url("secure.mjpg?token=AUDIT-TOKEN"), user: "kamera", password: FakeMjpegServer.Password));
await ShareAsync(admin, created.Id, new HomeGrantDto(Roles.Child, null, HomeAccessLevel.View));
await admin.PutAsJsonAsync($"/api/admin/home/cameras/{created.Id}", Camera("Audit-Kamera 2", camera.Url("secure.mjpg?token=AUDIT-TOKEN")) with { AuthUser = "kamera" }, Ct);
await admin.DeleteAsync($"/api/admin/home/cameras/{created.Id}", Ct);
var audit = await admin.GetStringAsync("/api/admin/audit?pageSize=100", Ct);
foreach (var action in new[] { "home.camera_created", "home.camera_access_changed", "home.camera_updated", "home.camera_deleted" })
{
Assert.Contains(action, audit);
}
Assert.DoesNotContain(FakeMjpegServer.Password, audit);
Assert.DoesNotContain("AUDIT-TOKEN", audit);
}
[Fact]
public async Task Optimistic_concurrency_and_password_handling_on_update()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var created = await CreateAsync(admin, Camera("Version", "http://192.168.1.77/video.mjpg", user: "kamera", password: "geheim"));
Assert.True(created.HasPassword);
// Aktualisieren ohne Passwort lässt es unverändert (der Name ändert sich, damit die Zeile wirklich geschrieben wird).
var keep = await admin.PutAsJsonAsync($"/api/admin/home/cameras/{created.Id}", Camera("Version 2", "http://192.168.1.77/video.mjpg", user: "kamera") with { Version = created.Version }, Ct);
Assert.Equal(HttpStatusCode.OK, keep.StatusCode);
var updated = (await keep.Content.ReadFromJsonAsync<HomeAdminCameraDto>(Ct))!;
Assert.True(updated.HasPassword);
// Veraltete Version: 409.
var stale = await admin.PutAsJsonAsync($"/api/admin/home/cameras/{created.Id}", Camera("Version 3", "http://192.168.1.77/video.mjpg", user: "kamera") with { Version = created.Version }, Ct);
Assert.Equal(HttpStatusCode.Conflict, stale.StatusCode);
// Passwort entfernen.
var clear = await admin.PutAsJsonAsync($"/api/admin/home/cameras/{created.Id}", Camera("Version", "http://192.168.1.77/video.mjpg") with { ClearPassword = true, Version = updated.Version }, Ct);
Assert.False((await clear.Content.ReadFromJsonAsync<HomeAdminCameraDto>(Ct))!.HasPassword);
}
}
@@ -0,0 +1,250 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using FamilyPlanner.Api.Features.Home;
namespace FamilyPlanner.UnitTests.Home;
/// <summary>Kamera-Streamer (ADR-0024) gegen einen Fake-Handler: Inhaltstyp-Prüfung, Fehlerübersetzung, Anmeldung, Weiterreichen, Zuschauer-Grenze.</summary>
public sealed class HomeCameraStreamerTests
{
private sealed class FakeHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) : HttpMessageHandler
{
public List<HttpRequestMessage> Calls { get; } = [];
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
Calls.Add(request);
return Task.FromResult(respond(request));
}
}
private sealed class Factory(HttpMessageHandler handler) : IHttpClientFactory
{
public HttpClient CreateClient(string name) => new(handler, disposeHandler: false);
}
private static HttpResponseMessage Response(string contentType, string body = "frame", HttpStatusCode status = HttpStatusCode.OK)
{
var response = new HttpResponseMessage(status) { Content = new ByteArrayContent(Encoding.ASCII.GetBytes(body)) };
response.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType);
return response;
}
private static (HomeCameraStreamer Streamer, FakeHandler Handler) Make(Func<HttpRequestMessage, HttpResponseMessage> respond)
{
var handler = new FakeHandler(respond);
return (new HomeCameraStreamer(new Factory(handler)), handler);
}
private static CancellationToken Ct => TestContext.Current.CancellationToken;
[Fact]
public async Task Mjpeg_stream_is_accepted_and_the_boundary_is_rebuilt()
{
var (streamer, handler) = Make(_ => Response("multipart/x-mixed-replace; boundary=\"--my.boundary\""));
var (response, contentType) = await streamer.OpenAsync("http://localhost:8090/video.mjpg?oids=1&size=1280x720", null, null, Ct);
using (response)
{
Assert.Equal("multipart/x-mixed-replace; boundary=--my.boundary", contentType);
Assert.Equal("http://localhost:8090/video.mjpg?oids=1&size=1280x720", handler.Calls.Single().RequestUri!.ToString());
Assert.Null(handler.Calls.Single().Headers.Authorization); // ohne Zugangsdaten keine Anmeldung
}
}
[Fact]
public async Task A_boundary_with_special_characters_stays_quoted()
{
var (streamer, _) = Make(_ => Response("multipart/x-mixed-replace; boundary=\"a:b=c\""));
var (response, contentType) = await streamer.OpenAsync("http://192.168.1.2/x", null, null, Ct);
using (response)
{
Assert.Equal("multipart/x-mixed-replace; boundary=\"a:b=c\"", contentType);
}
}
[Fact]
public async Task Single_jpeg_is_accepted_too()
{
var (streamer, _) = Make(_ => Response("image/jpeg"));
var (response, contentType) = await streamer.OpenAsync("http://192.168.1.2/snap.jpg", null, null, Ct);
using (response)
{
Assert.Equal("image/jpeg", contentType);
}
}
[Theory]
[InlineData("text/html")]
[InlineData("application/json")]
[InlineData("image/svg+xml")]
[InlineData("multipart/x-mixed-replace")] // ohne Grenze
[InlineData("multipart/x-mixed-replace; boundary=\"a b<script>\"")] // Grenze mit unzulässigen Zeichen
public async Task Everything_but_video_is_rejected_with_a_fixed_message(string contentType)
{
var (streamer, _) = Make(_ => Response(contentType, "geheimer Inhalt"));
var ex = await Assert.ThrowsAsync<HomeException>(() => streamer.OpenAsync("http://192.168.1.2/x", null, null, Ct));
Assert.Equal(HomeErrorKind.BadResponse, ex.Kind);
Assert.DoesNotContain("geheimer", ex.Message);
}
[Theory]
[InlineData(HttpStatusCode.Unauthorized, HomeErrorKind.Unauthorized)]
[InlineData(HttpStatusCode.Found, HomeErrorKind.BadResponse)]
[InlineData(HttpStatusCode.NotFound, HomeErrorKind.BadResponse)]
[InlineData(HttpStatusCode.InternalServerError, HomeErrorKind.BadResponse)]
public async Task Status_problems_become_fixed_messages(HttpStatusCode status, HomeErrorKind expected)
{
var (streamer, _) = Make(_ => Response("text/plain", "Zugangsdaten falsch: hunter2", status));
var ex = await Assert.ThrowsAsync<HomeException>(() => streamer.OpenAsync("http://192.168.1.2/x", "u", "hunter2", Ct));
Assert.Equal(expected, ex.Kind);
Assert.DoesNotContain("hunter2", ex.Message);
}
[Fact]
public async Task Basic_credentials_are_sent_only_when_user_and_password_exist()
{
var (streamer, handler) = Make(_ => Response("multipart/x-mixed-replace; boundary=b"));
(await streamer.OpenAsync("http://192.168.1.2/x", "kamera", "pw", Ct)).Response.Dispose();
(await streamer.OpenAsync("http://192.168.1.2/x", "kamera", null, Ct)).Response.Dispose();
Assert.Equal("Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("kamera:pw")), handler.Calls[0].Headers.Authorization!.ToString());
Assert.Null(handler.Calls[1].Headers.Authorization);
}
[Fact]
public async Task Connection_errors_become_a_fixed_unreachable_message()
{
var (streamer, _) = Make(_ => throw new HttpRequestException("Verbindung zu 192.168.1.2:80 abgelehnt"));
var ex = await Assert.ThrowsAsync<HomeException>(() => streamer.OpenAsync("http://192.168.1.2/x", null, null, Ct));
Assert.Equal(HomeErrorKind.Unreachable, ex.Kind);
Assert.DoesNotContain("192.168", ex.Message);
}
[Fact]
public async Task Blocked_address_from_the_connect_callback_is_reported_as_blocked()
{
var (streamer, _) = Make(_ => throw new HttpRequestException("x", new HomeBlockedAddressException("evil.example")));
var ex = await Assert.ThrowsAsync<HomeException>(() => streamer.OpenAsync("http://evil.example/x", null, null, Ct));
Assert.Equal(HomeErrorKind.Blocked, ex.Kind);
Assert.DoesNotContain("evil.example", ex.Message);
}
[Fact]
public async Task Viewer_who_leaves_does_not_become_a_camera_timeout()
{
using var cts = new CancellationTokenSource();
var (streamer, _) = Make(_ => Response("multipart/x-mixed-replace; boundary=b"));
await cts.CancelAsync();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => streamer.OpenAsync("http://192.168.1.2/x", null, null, cts.Token));
}
[Fact]
public async Task Relay_copies_all_bytes_and_flushes()
{
var payload = new byte[40_000];
new Random(1).NextBytes(payload);
using var upstream = new HttpResponseMessage { Content = new ByteArrayContent(payload) };
await using var output = new MemoryStream();
await HomeCameraStreamer.RelayAsync(upstream, output, Ct);
Assert.Equal(payload, output.ToArray());
}
[Fact]
public async Task Relay_stops_when_the_viewer_leaves()
{
using var upstream = new HttpResponseMessage { Content = new StreamContent(new NeverEndingStream()) };
await using var output = new MemoryStream();
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200));
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => HomeCameraStreamer.RelayAsync(upstream, output, cts.Token));
Assert.True(output.Length > 0); // vorher ist Material durchgereicht worden
}
[Fact]
public void Viewers_per_camera_are_limited_and_counted_independently()
{
var (streamer, _) = Make(_ => Response("image/jpeg"));
var a = Guid.NewGuid();
var b = Guid.NewGuid();
for (var i = 0; i < HomeCameraStreamer.MaxViewersPerCamera; i++)
{
Assert.True(streamer.TryEnter(a));
}
Assert.False(streamer.TryEnter(a));
Assert.True(streamer.TryEnter(b)); // andere Kamera unabhängig
Assert.Equal(HomeCameraStreamer.MaxViewersPerCamera, streamer.Viewers(a));
streamer.Leave(a);
Assert.True(streamer.TryEnter(a));
Assert.False(streamer.TryEnter(a));
// Zu viele Leave-Aufrufe machen den Zähler nie negativ.
for (var i = 0; i < 10; i++)
{
streamer.Leave(b);
}
Assert.Equal(0, streamer.Viewers(b));
Assert.True(streamer.TryEnter(b));
}
/// <summary>Liefert endlos kleine Häppchen, wie eine Kamera, die nie aufhört.</summary>
private sealed class NeverEndingStream : Stream
{
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => throw new NotSupportedException();
public override long Position
{
get => throw new NotSupportedException();
set => throw new NotSupportedException();
}
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
await Task.Delay(20, cancellationToken);
buffer.Span[0] = 0xFF;
return 1;
}
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
public override void Flush()
{
}
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
}
}
@@ -0,0 +1,50 @@
using FamilyPlanner.Domain.Home;
namespace FamilyPlanner.UnitTests.Home;
/// <summary>Adresse eines Kamerastroms (ADR-0024): wie die Geräteadresse nur Heimnetz, aber mit Pfad und Query.</summary>
public sealed class StreamUrlRulesTests
{
[Theory]
[InlineData("http://localhost:8090/video.mjpg?oids=1&size=1280x720", "http://localhost:8090/video.mjpg?oids=1&size=1280x720")]
[InlineData("192.168.1.20:8090/video.mjpg", "http://192.168.1.20:8090/video.mjpg")]
[InlineData(" https://kamera.local/stream ", "https://kamera.local/stream")]
[InlineData("http://10.0.0.5", "http://10.0.0.5/")]
[InlineData("http://kamera.fritz.box.lan:81/mjpeg?quality=high", "http://kamera.fritz.box.lan:81/mjpeg?quality=high")]
public void Home_network_stream_urls_are_accepted_and_normalized(string input, string expected)
{
var (url, error) = HomeRules.NormalizeStreamUrl(input);
Assert.Null(error);
Assert.Equal(expected, url);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
[InlineData("ftp://192.168.1.5/video.mjpg")]
[InlineData("file:///etc/passwd")]
[InlineData("rtsp://192.168.1.5/stream")]
[InlineData("http://user:pw@192.168.1.5/video.mjpg")]
[InlineData("http://192.168.1.5/video.mjpg#frag")]
[InlineData("http://8.8.8.8/video.mjpg")]
[InlineData("http://example.com/video.mjpg")]
[InlineData("http://169.254.169.254/latest/meta-data")]
[InlineData("http://[fe80::1]/video.mjpg")]
[InlineData("http://0.0.0.0/video.mjpg")]
[InlineData("http://224.0.0.1/video.mjpg")]
public void Foreign_blocked_or_malformed_stream_urls_are_rejected_with_a_message(string? input)
{
var (url, error) = HomeRules.NormalizeStreamUrl(input);
Assert.Null(url);
Assert.False(string.IsNullOrWhiteSpace(error));
}
[Fact]
public void Overlong_stream_url_is_rejected()
{
Assert.NotNull(HomeRules.NormalizeStreamUrl("http://192.168.1.5/" + new string('a', HomeCamera.UrlMaxLength)).Error);
}
}
@@ -47,7 +47,7 @@ public sealed class AuthenticationStateProviderTests
Assert.Equal(FamilyPlanner.Domain.Authorization.Policies.Admin, admin.Policy);
Assert.True(admin.Separated);
Assert.All(NavItems.All.Where(i => i.Href is not ("admin" or "lernen" or "haus" or "haus/verwaltung")), i => Assert.Null(i.Policy));
Assert.All(NavItems.All.Where(i => i.Href is not ("admin" or "lernen" or "haus" or "haus/kameras" or "haus/verwaltung")), i => Assert.Null(i.Policy));
}
[Fact]
@@ -66,8 +66,14 @@ public sealed class AuthenticationStateProviderTests
{
var items = NavItems.All.ToList();
var house = Assert.Single(items, i => i.Href == "haus");
var cameras = Assert.Single(items, i => i.Href == "haus/kameras");
var management = Assert.Single(items, i => i.Href == "haus/verwaltung");
Assert.Equal("Kameras", cameras.Title);
Assert.Equal(FamilyPlanner.Domain.Authorization.Policies.HomeUse, cameras.Policy); // wie die Haus-Seite; je Kamera freigegeben
Assert.True(cameras.Child);
Assert.False(cameras.InBottomNav);
Assert.Equal("Haus", house.Title);
Assert.Equal(FamilyPlanner.Domain.Authorization.Policies.For(FamilyPlanner.Domain.Authorization.Permission.HomeUse), house.Policy);
Assert.Equal(FamilyPlanner.Domain.Authorization.Policies.HomeUse, house.Policy);
@@ -77,10 +83,11 @@ public sealed class AuthenticationStateProviderTests
Assert.Equal(FamilyPlanner.Domain.Authorization.Policies.Admin, management.Policy);
Assert.True(management.Child);
// Beide stehen unter „Verwaltung“, direkt untereinander, und nicht in der Bottom-Navigation.
// Alle stehen unter „Verwaltung“, direkt untereinander, und nicht in der Bottom-Navigation.
var admin = items.FindIndex(i => i.Href == "admin");
Assert.Equal(admin + 1, items.IndexOf(house));
Assert.Equal(admin + 2, items.IndexOf(management));
Assert.Equal(admin + 2, items.IndexOf(cameras));
Assert.Equal(admin + 3, items.IndexOf(management));
Assert.False(house.InBottomNav);
Assert.False(management.InBottomNav);
}
@@ -0,0 +1,244 @@
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Home;
using FamilyPlanner.Shared;
using FamilyPlanner.Shared.Admin;
using FamilyPlanner.Shared.Home;
using FamilyPlanner.Web.Client.Components.House;
using FamilyPlanner.Web.Client.Services;
using Microsoft.Extensions.DependencyInjection;
using MudBlazor.Services;
using CamerasPage = FamilyPlanner.Web.Client.Pages.HouseCameras;
namespace FamilyPlanner.Web.Tests.House;
/// <summary>Kameras (ADR-0024): Seite mit Livebildern über die Api-Adresse, Pause, Verwaltung (nie Passwort/Zugangsdaten im Markup), Dialog mit lokaler Prüfung, Freigaben.</summary>
public sealed class HouseCameraTests : BunitContext
{
private readonly CameraApi _api = new();
public HouseCameraTests()
{
JSInterop.Mode = JSRuntimeMode.Loose;
Services.AddMudServices();
Services.AddSingleton<IHomeApi>(_api);
}
private IRenderedComponent<HouseHost> Open<T>()
where T : Microsoft.AspNetCore.Components.IComponent =>
Render<HouseHost>(p => p.AddChildContent<T>());
private static HomeAdminCameraDto AdminCamera(string name, bool hasPassword = false, IReadOnlyList<HomeGrantDto>? grants = null) =>
new(Guid.NewGuid(), name, "http://192.168.1.20:8090/video.mjpg?oids=1", hasPassword ? "kamera" : null, hasPassword, true, 0, [1], grants ?? []);
[Fact]
public void Page_shows_a_live_image_per_camera_from_the_api_and_never_the_camera_address()
{
var door = new HomeCameraDto(Guid.NewGuid(), "Haustür", true);
_api.Cameras.Add(door);
_api.Cameras.Add(new HomeCameraDto(Guid.NewGuid(), "Garten", false));
var cut = Open<CamerasPage>();
cut.WaitForAssertion(() => Assert.Equal(2, cut.FindAll(".fp-cam-img").Count));
var image = cut.FindAll(".fp-cam-img").First();
Assert.Equal($"api/home/cameras/{door.Id}/stream", image.GetAttribute("src"));
Assert.Equal("Livebild der Kamera Haustür", image.GetAttribute("alt"));
Assert.Contains("Kein Bild von dieser Kamera", cut.Find(".fp-cam-fallback").TextContent); // sichtbar, wenn der Strom nicht kommt
Assert.Equal($"api/home/cameras/{door.Id}/stream", cut.Find(".fp-camera-open").GetAttribute("href"));
Assert.DoesNotContain("192.168", cut.Markup);
}
[Fact]
public void Empty_state_explains_that_cameras_are_shared_by_administrators()
{
var cut = Open<CamerasPage>();
cut.WaitForAssertion(() => Assert.Contains("noch keine Kameras freigegeben", cut.Find(".fp-cameras-empty").TextContent));
Assert.Empty(cut.FindAll(".fp-cam-img"));
}
[Fact]
public void Load_failure_shows_the_message_and_can_be_retried()
{
_api.CamerasProblem = ApiProblem.Offline;
var cut = Open<CamerasPage>();
cut.WaitForAssertion(() => Assert.NotEmpty(cut.Find(".fp-cameras-error").TextContent));
_api.CamerasProblem = null;
_api.Cameras.Add(new HomeCameraDto(Guid.NewGuid(), "Garage", false));
cut.Find("button.mud-button-outlined:not(.fp-cameras-back)").Click();
cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".fp-cam-img")));
}
[Fact]
public void Live_image_can_be_paused_and_resumed_and_reconnected()
{
_api.Cameras.Add(new HomeCameraDto(Guid.NewGuid(), "Einfahrt", false));
var cut = Open<CamerasPage>();
cut.WaitForElement(".fp-cam-img");
cut.Find(".fp-cam-toggle").Click();
Assert.Empty(cut.FindAll(".fp-cam-img")); // kein Bild = die Verbindung zur Kamera wird beendet
Assert.Contains("angehalten", cut.Find(".fp-cam-paused").TextContent);
Assert.Empty(cut.FindAll(".fp-cam-reconnect"));
cut.Find(".fp-cam-toggle").Click();
Assert.Single(cut.FindAll(".fp-cam-img"));
cut.Find(".fp-cam-reconnect").Click();
Assert.Single(cut.FindAll(".fp-cam-img"));
}
// ---------- Verwaltung ----------
[Fact]
public void Admin_list_shows_address_shares_and_never_the_password()
{
_api.AdminCameras.Add(AdminCamera("Haustür", hasPassword: true, grants:
[
new(Roles.Family, null, HomeAccessLevel.View),
new(null, Guid.NewGuid(), HomeAccessLevel.View),
]));
_api.AdminCameras.Add(AdminCamera("Garten"));
var cut = Open<HouseCamerasAdmin>();
cut.WaitForAssertion(() => Assert.Equal(2, cut.FindAll(".fp-camera-table tbody tr").Count));
var first = cut.FindAll(".fp-camera-table tbody tr")[0].TextContent;
Assert.Contains("mit Passwort", first);
Assert.Contains("Familie", first);
Assert.Contains("1 Person", first);
Assert.Contains("Nur Administratoren", cut.FindAll(".fp-camera-table tbody tr")[1].TextContent);
}
[Fact]
public void Dialog_validates_the_address_locally_and_saves_a_new_camera()
{
var cut = Open<HouseCamerasAdmin>();
cut.WaitForElement(".fp-camera-add");
cut.Find(".fp-camera-add").Click();
cut.WaitForElement("#house-camera-form");
cut.Find("#camera-name").Change("Haustür");
cut.Find("#camera-url").Change("http://example.com/video.mjpg");
cut.Find("#house-camera-form").Submit();
cut.WaitForAssertion(() => Assert.Contains("Nur Adressen im Heimnetz", cut.Markup));
Assert.Empty(_api.SavedCameras);
cut.Find("#camera-url").Change("http://localhost:8090/video.mjpg?oids=1&size=1280x720");
cut.Find("#house-camera-form").Submit();
cut.WaitForAssertion(() => Assert.Single(_api.SavedCameras));
var saved = _api.SavedCameras.Single();
Assert.Equal(("Haustür", "http://localhost:8090/video.mjpg?oids=1&size=1280x720", null, null), (saved.Name, saved.Url, saved.AuthUser, saved.Password));
Assert.False(saved.ShowOnDashboard);
}
[Fact]
public void Edit_dialog_keeps_the_password_and_test_uses_the_stored_one()
{
var camera = AdminCamera("Mit Passwort", hasPassword: true);
_api.AdminCameras.Add(camera);
var cut = Open<HouseCamerasAdmin>();
cut.WaitForElement("button[aria-label='Mit Passwort bearbeiten']");
cut.Find("button[aria-label='Mit Passwort bearbeiten']").Click();
cut.WaitForElement("#house-camera-form");
Assert.Equal(string.Empty, cut.Find("#camera-password").GetAttribute("value") ?? string.Empty);
Assert.Contains("leer lassen = unverändert", cut.Markup);
cut.Find(".fp-camera-test").Click();
cut.WaitForAssertion(() => Assert.Contains("Verbindung erfolgreich", cut.Find(".fp-camera-testresult").TextContent));
var test = _api.Tests.Single();
Assert.Equal((camera.Id, null), (test.CameraId, test.Password)); // gespeichertes Passwort bleibt auf dem Server
cut.Find("#house-camera-form").Submit();
cut.WaitForAssertion(() => Assert.Single(_api.SavedCameras));
Assert.Equal((null, false), (_api.SavedCameras.Single().Password, _api.SavedCameras.Single().ClearPassword));
Assert.NotNull(_api.SavedCameras.Single().Version);
}
[Fact]
public void Access_dialog_saves_view_only_shares()
{
var anna = FakeHomeApi.User("Anna");
_api.Users.AddRange([anna, FakeHomeApi.User("Chefin", Roles.Admin)]);
var camera = AdminCamera("Haustür", grants: [new(Roles.Family, null, HomeAccessLevel.View)]);
_api.AdminCameras.Add(camera);
var cut = Open<HouseCamerasAdmin>();
cut.WaitForElement(".fp-camera-access");
cut.Find(".fp-camera-access").Click();
cut.WaitForElement("#house-camera-access-form");
Assert.Equal(3, cut.FindAll(".fp-camera-access-role").Count); // Familie, Kind, Gast – nie Admin
cut.Find("#house-camera-access-form").Submit();
cut.WaitForAssertion(() => Assert.Single(_api.SavedAccess));
var (id, request) = _api.SavedAccess.Single();
Assert.Equal(camera.Id, id);
Assert.Equal([new HomeGrantDto(Roles.Family, null, HomeAccessLevel.View)], request.Grants);
}
/// <summary>Nur die Kamera-Teile von <see cref="IHomeApi"/>; alles andere liefern die Standardimplementierungen.</summary>
private sealed class CameraApi : IHomeApi
{
public List<HomeCameraDto> Cameras { get; } = [];
public ApiProblem? CamerasProblem { get; set; }
public List<HomeAdminCameraDto> AdminCameras { get; } = [];
public List<UserDirectoryEntry> Users { get; } = [];
public List<SaveHomeCameraRequest> SavedCameras { get; } = [];
public List<TestHomeCameraRequest> Tests { get; } = [];
public List<(Guid Id, SetHomeAccessRequest Request)> SavedAccess { get; } = [];
public Task<ApiResult<HomeOverviewDto>> GetOverviewAsync(CancellationToken ct = default) =>
Task.FromResult(ApiResult<HomeOverviewDto>.Ok(new HomeOverviewDto([], [], [], false)));
public Task<ApiResult<HomeStatesDto>> GetStatesAsync(CancellationToken ct = default) =>
Task.FromResult(ApiResult<HomeStatesDto>.Ok(new HomeStatesDto([], DateTimeOffset.UtcNow)));
public Task<ApiResult<HomeCommandResultDto>> SendCommandAsync(Guid deviceId, HomeCommand command, CancellationToken ct = default) =>
Task.FromResult(ApiResult<HomeCommandResultDto>.Fail(ApiProblem.Offline));
public Task<ApiResult<List<HomeCameraDto>>> GetCamerasAsync(CancellationToken ct = default) =>
Task.FromResult(CamerasProblem is { } problem ? ApiResult<List<HomeCameraDto>>.Fail(problem) : ApiResult<List<HomeCameraDto>>.Ok([.. Cameras]));
public Task<ApiResult<List<HomeAdminCameraDto>>> GetAdminCamerasAsync(CancellationToken ct = default) =>
Task.FromResult(ApiResult<List<HomeAdminCameraDto>>.Ok([.. AdminCameras]));
public Task<ApiResult<List<UserDirectoryEntry>>> GetUsersAsync(CancellationToken ct = default) =>
Task.FromResult(ApiResult<List<UserDirectoryEntry>>.Ok([.. Users]));
public Task<ApiResult<HomeAdminCameraDto>> CreateCameraAsync(SaveHomeCameraRequest request, CancellationToken ct = default)
{
SavedCameras.Add(request);
var created = new HomeAdminCameraDto(Guid.NewGuid(), request.Name, request.Url, request.AuthUser, request.Password is not null, request.ShowOnDashboard, request.SortOrder, [1], []);
AdminCameras.Add(created);
return Task.FromResult(ApiResult<HomeAdminCameraDto>.Ok(created));
}
public Task<ApiResult<HomeAdminCameraDto>> UpdateCameraAsync(Guid id, SaveHomeCameraRequest request, CancellationToken ct = default)
{
SavedCameras.Add(request);
var existing = AdminCameras.Single(c => c.Id == id);
return Task.FromResult(ApiResult<HomeAdminCameraDto>.Ok(existing with { Name = request.Name, Url = request.Url }));
}
public Task<ApiResult<HomeTestResultDto>> TestCameraAsync(TestHomeCameraRequest request, CancellationToken ct = default)
{
Tests.Add(request);
return Task.FromResult(ApiResult<HomeTestResultDto>.Ok(new HomeTestResultDto(true, "Verbindung erfolgreich – die Kamera liefert einen MJPEG-Videostrom.", null, null, 12)));
}
public Task<ApiResult<HomeAdminCameraDto>> SetCameraAccessAsync(Guid id, SetHomeAccessRequest request, CancellationToken ct = default)
{
SavedAccess.Add((id, request));
return Task.FromResult(ApiResult<HomeAdminCameraDto>.Ok(AdminCameras.Single(c => c.Id == id)));
}
}
}