feat: Phase 18 Haus - cameras with MJPEG streaming, per-camera access, admin UI
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0aa0947727
commit
b23e92360b
41 files changed
+6103
-11
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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&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);
|
||||
}
|
||||
}
|
||||
Generated
+3338
File diff suppressed because it is too large.
Load diff
+98
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user