diff --git a/.env.example b/.env.example index 3ba9c4f..bafc097 100644 --- a/.env.example +++ b/.env.example @@ -20,3 +20,10 @@ ALARM_EXIT_DELAY=30s ALARM_MIN_CONFIDENCE=60 LOG_LEVEL=INFO + +# Mot de passe de publication du flux video. +# Seul l'iPhone doit pouvoir pousser sur le flux "biblio" : sans ce garde-fou, +# n'importe qui sur Internet remplacerait l'image du studio par la sienne. +# Genere au deploiement, comme DB_PASSWORD : +# printf 'STREAM_PUBLISH_PASSWORD=%s\n' "$(openssl rand -hex 16)" >> .env +STREAM_PUBLISH_PASSWORD=CHANGEME diff --git a/.gitignore b/.gitignore index 6c16497..106e4b3 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ data/ .env .env.* !.env.example + +### Genere au deploiement (contient le mot de passe de publication) ### +deploy/mediamtx/mediamtx.yml diff --git a/README.md b/README.md index 53e48cd..c1513c2 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,39 @@ tests plutôt que par la discipline. ## Le flux +### Deux flux, deux chemins + +Le système transporte deux choses de nature très différente, et elles ne +suivent volontairement pas le même chemin : + +``` + ┌─────────────────────┐ + │ iPhone (capteur) │ + └──────────┬──────────┘ + événements │ vidéo + (quelques octets) │ (800 kbps continu) + ┌──────────┴──────────┐ + ▼ ▼ + ┌───────────────┐ ┌────────────────┐ + │ Spring Boot │ │ MediaMTX │ + │ Kafka · JPA │ │ SRT → WebRTC │ + │ domaine │ │ transport pur │ + └───────┬───────┘ └────────┬───────┘ + └──────────┬───────────┘ + ▼ + Console web (une seule page) +``` + +**La vidéo ne traverse jamais la JVM.** Faire transiter 800 kbps continus par +Spring Boot n'apporterait rien et coûterait un thread par spectateur. Le média +est un problème de transport, résolu par un service dédié de 83 Mo ; la JVM +garde le domaine métier. Les deux se rejoignent seulement dans le navigateur. + +Corollaire pratique : MediaMTX peut tomber sans que l'alarme cesse de +fonctionner, et inversement. + +### Le flux d'événements en détail + ``` ┌──────────────────┐ │ iPhone 6s │ Détection de mouvement @@ -166,6 +199,74 @@ implémentation de `AlarmReadModelPort`. --- +## Le flux vidéo + +### Le téléphone pousse, on ne l'interroge pas + +C'est la contrainte qui a dicté toute cette partie. Un iPhone n'a **aucune +adresse joignable depuis Internet** : en 4G il est derrière le CGNAT de +l'opérateur, sur le WiFi du studio derrière le NAT de la box, et l'application +signale `UPnP unavailable`. Le serveur RTSP intégré à l'application caméra est +donc inutilisable de l'extérieur, quelle que soit la configuration. + +La seule direction qui traverse ces deux NAT est la sortante. Le téléphone +**pousse** son flux vers le serveur : + +``` +iPhone ──SRT (UDP 8890)──▶ MediaMTX ──┬──▶ WebRTC < 1 s + └─RTMP (TCP 1935)──▶ └──▶ HLS ~ 6 s +``` + +**SRT plutôt que RTMP** quand le choix est possible : RTMP est du TCP, et sur +un lien mobile un paquet perdu déclenche une retransmission qui bloque tout le +reste. SRT transporte en UDP avec sa propre retransmission sélective — c'est +exactement le protocole conçu pour ce cas. + +**WebRTC plutôt que HLS** côté lecture : voir quelqu'un six secondes après son +passage n'a pas le même intérêt. La console tente WebRTC (WHEP) d'abord et +bascule sur HLS si l'UDP est filtré — ce qui arrive précisément sur les réseaux +d'entreprise. + +### Ce qui est protégé, ce qui ne l'est pas + +| | | +|---|---| +| **Lecture** | publique — le flux est une vitrine, c'est assumé | +| **Publication** | authentifiée — sans ça, n'importe qui sur Internet pousserait sa propre vidéo sur le flux `biblio` | + +### Brancher un vrai téléphone + +Application : **IP Camera Lite** (iOS), menu *Live Push*. + +``` +SRT srt://:8890?streamid=publish:biblio:iphone: +RTMP rtmp://:1935/biblio?user=iphone&pass= +``` + +Le mot de passe est généré au déploiement dans le `.env` du serveur +(`STREAM_PUBLISH_PASSWORD`) et n'existe nulle part dans ce dépôt. + +Pour valider la chaîne sans téléphone, une mire suffit : + +```bash +docker run --rm --network iot-alarm-internal jrottenberg/ffmpeg:6.1-alpine \ + -re -f lavfi -i testsrc=size=640x360:rate=25 \ + -c:v libx264 -preset ultrafast -tune zerolatency -b:v 800k -pix_fmt yuv420p \ + -f flv "rtmp://mediamtx:1935/biblio?user=iphone&pass=$PASS" +``` + +### Le piège du reverse proxy + +MediaMTX répond d'abord une redirection vers un chemin **absolu** +(`/biblio/index.m3u8?cookieCheck=1`). Derrière un `handle_path /hls/*` qui +retire le préfixe, le navigateur suivait cette redirection jusqu'à +l'application Spring Boot et recevait du JSON là où il attendait une playlist. +Le `header_down Location` du [snippet Caddy](deploy/Caddyfile.snippet) réécrit +le préfixe. Symptôme trompeur : `curl -I` renvoyait un 404 sans rapport, le +serveur HLS ne gérant simplement pas la méthode HEAD. + +--- + ## Les décisions non évidentes Les choix ci-dessous ne sont pas les plus courts. Ils sont justifiés ici parce @@ -259,6 +360,8 @@ mvn verify -Pintegration # Testcontainers (Kafka + PostgreSQL réels) | `GET` | `/api/v1/alarm/status` | **Requête** — état courant | | `GET` | `/api/v1/alerts?limit=50` | **Requête** — historique | | `WS` | `/ws` → `/topic/alerts` | Push temps réel (STOMP) | +| `POST` | `/webrtc/biblio/whep` | Négociation WebRTC (servi par MediaMTX) | +| `GET` | `/hls/biblio/index.m3u8` | Repli HLS (servi par MediaMTX) | Documentation OpenAPI : `/swagger-ui.html` @@ -310,6 +413,7 @@ services. | Messagerie | Apache Kafka 3.9 (KRaft) | | Persistance | PostgreSQL 16 · Spring Data JPA · Flyway | | Temps réel | WebSocket / STOMP / SockJS | +| Vidéo | MediaMTX · SRT / RTMP en entrée · WebRTC (WHEP) / HLS en sortie | | Build | Maven · Docker multi-stage | | Tests | JUnit 5 · AssertJ · **ArchUnit** · Testcontainers | | Observabilité | Actuator · Micrometer / Prometheus | diff --git a/deploy/Caddyfile.snippet b/deploy/Caddyfile.snippet index 75d5e1c..ddf784a 100644 --- a/deploy/Caddyfile.snippet +++ b/deploy/Caddyfile.snippet @@ -7,22 +7,19 @@ # les services existants. Caddy obtient et renouvelle les certificats # Let's Encrypt tout seul - il n'y a pas de Certbot a installer. # -# Les deux orthographes sont servies : "seurveillance" est celle du sous-domaine -# demande, "surveillance" couvre la faute de frappe dans les deux sens. -# Caddy demande un certificat pour chaque nom qui resout vers ce serveur. +# Les deux orthographes sont servies : "seurveillance" est le sous-domaine +# effectivement cree, "surveillance" couvre la faute de frappe dans les deux +# sens. Caddy demande un certificat pour chaque nom qui resout vers ce serveur. +# +# Trois destinations derriere un seul domaine : +# /webrtc/* -> MediaMTX, signalisation WebRTC (flux video temps reel) +# /hls/* -> MediaMTX, repli HLS +# le reste -> l'application Spring Boot (console, API, websocket STOMP) # # Le proxy WebSocket n'a rien a declarer : Caddy propage l'upgrade nativement. # --------------------------------------------------------------------------- video-seurveillance.belkacemi.me, video-surveillance.belkacemi.me { - reverse_proxy iot-alarm-app:8080 - - # L'actuator n'a aucune raison d'etre public : health et metriques - # renseignent un attaquant sur les versions et l'etat interne. - @actuator path /actuator/* - respond @actuator 404 - - encode gzip zstd header { Strict-Transport-Security "max-age=31536000; includeSubDomains" @@ -32,6 +29,43 @@ video-seurveillance.belkacemi.me, video-surveillance.belkacemi.me { -Server } + # L'actuator n'a aucune raison d'etre public : health et metriques + # renseignent un attaquant sur les versions et l'etat interne. + @actuator path /actuator/* + respond @actuator 404 + + # --- Flux video : WebRTC (latence < 1 s) -------------------------------- + # flush_interval -1 desactive la bufferisation de la reponse. Sans ca, la + # negociation WHEP et les segments basse latence seraient retenus par le + # proxy le temps de remplir un tampon - ce qui annule tout l'interet. + handle_path /webrtc/* { + reverse_proxy iot-alarm-mediamtx:8889 { + flush_interval -1 + } + } + + # --- Flux video : HLS (repli, ~6 s) ------------------------------------- + # Pas de compression ici : les segments video sont deja compresses, gziper + # par-dessus ne gagne rien et consomme du CPU sur un serveur a 2 vCPU. + handle_path /hls/* { + reverse_proxy iot-alarm-mediamtx:8888 { + flush_interval -1 + + # MediaMTX repond d'abord une redirection vers un chemin ABSOLU + # (/biblio/index.m3u8?cookieCheck=1), qui ignore le prefixe /hls + # retire par handle_path. Sans cette reecriture, le navigateur + # suit la redirection vers l'application Spring Boot au lieu du + # serveur media - et recoit du JSON la ou il attend une playlist. + header_down Location "^/(.*)$" "/hls/${1}" + } + } + + # --- Application Spring Boot (console, API, WebSocket STOMP) ------------ + handle { + encode gzip zstd + reverse_proxy iot-alarm-app:8080 + } + log { output file /var/log/caddy/iot-alarm.log format json diff --git a/deploy/deploy.sh b/deploy/deploy.sh index d01c58f..942bdf6 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -24,11 +24,25 @@ rsync -az --delete \ --exclude '.DS_Store' \ "${LOCAL_DIR}/" "${HOST}:${REMOTE_DIR}/" +echo "==> Rendu de la configuration MediaMTX" +# Le mot de passe de publication vit dans le .env du serveur et n'existe nulle +# part dans le depot. Le fichier rendu est gitignore. +ssh "${HOST}" "cd ${REMOTE_DIR} && \ + PASS=\$(grep '^STREAM_PUBLISH_PASSWORD=' .env | cut -d= -f2-) && \ + PUBLIC_IP=\$(curl -s --max-time 10 https://api.ipify.org) && \ + sed -e \"s|__PUBLISH_PASSWORD__|\${PASS}|\" \ + -e \"s|__PUBLIC_IP__|\${PUBLIC_IP}|\" \ + deploy/mediamtx/mediamtx.yml.template > deploy/mediamtx/mediamtx.yml && \ + chmod 600 deploy/mediamtx/mediamtx.yml" + echo "==> Build de l'image (tests unitaires + ArchUnit inclus)" ssh "${HOST}" "cd ${REMOTE_DIR} && docker compose build app" echo "==> Redemarrage" ssh "${HOST}" "cd ${REMOTE_DIR} && docker compose up -d" +# `up -d` ne recree pas un conteneur dont seule la configuration montee a +# change : MediaMTX garderait l'ancienne en memoire. +ssh "${HOST}" "cd ${REMOTE_DIR} && docker compose restart mediamtx" echo "==> Attente du healthcheck" for i in $(seq 1 30); do diff --git a/deploy/mediamtx/mediamtx.yml.template b/deploy/mediamtx/mediamtx.yml.template new file mode 100644 index 0000000..5b748c5 --- /dev/null +++ b/deploy/mediamtx/mediamtx.yml.template @@ -0,0 +1,106 @@ +############################################################################### +# MediaMTX - serveur media du systeme d'alarme. +# +# Role : recevoir le flux pousse par l'iPhone (SRT ou RTMP) et le re-publier +# dans des formats lisibles par un navigateur (WebRTC, HLS). +# +# Pourquoi un service separe et pas du code Java : transporter de la video est +# un probleme resolu, temps-reel et gourmand en I/O. Le faire transiter par la +# JVM n'apporterait rien et couterait cher. L'application Spring Boot garde le +# domaine d'alarme ; le media passe a cote. +# +# Pourquoi le telephone POUSSE au lieu d'etre interroge : en 4G comme derriere +# la box du studio, l'iPhone n'a pas d'adresse joignable depuis Internet +# (CGNAT operateur, NAT domestique, UPnP indisponible). Seule une connexion +# sortante depuis le telephone fonctionne dans les deux cas. +# +# CE FICHIER EST GENERE depuis mediamtx.yml.template - ne pas l'editer a la main. +############################################################################### + +logLevel: info +logDestinations: [stdout] + +readTimeout: 10s +writeTimeout: 10s + +# API interne, utilisee pour la sonde de sante. Non publiee sur l'hote. +api: yes +apiAddress: :9997 + +############################################################################### +# Authentification +# +# Lecture publique : choix assume, le flux est une vitrine. +# Publication authentifiee : sans ca, n'importe qui sur Internet pourrait +# pousser sa propre video sur "biblio" et remplacer l'image du studio. +############################################################################### +authMethod: internal +authInternalUsers: + - user: any + pass: + ips: [] + permissions: + - action: read + - action: playback + + - user: iphone + pass: __PUBLISH_PASSWORD__ + ips: [] + permissions: + - action: publish + path: biblio + +############################################################################### +# Entrees : ce que l'iPhone peut utiliser pour pousser +############################################################################### + +# SRT - a privilegier. UDP avec retransmission : encaisse la perte de paquets +# d'un lien mobile la ou RTMP (TCP) se met a bufferiser puis decroche. +srt: yes +srtAddress: :8890 + +# RTMP - repli. Plus universellement supporte par les applications mobiles. +rtmp: yes +rtmpAddress: :1935 +rtmpEncryption: "no" + +# MoQ (Media over QUIC) : experimental, aucun navigateur ne le consomme +# aujourd'hui et la console ne l'utilise pas. Desactive. +moq: no + +# RTSP desactive : l'iPhone n'est pas joignable en entrant, ce protocole +# n'aurait aucun usage ici et ouvrirait une surface d'attaque de plus. +rtsp: no + +############################################################################### +# Sorties : ce que le navigateur consomme +############################################################################### + +# WebRTC - chemin principal, latence inferieure a la seconde. +webrtc: yes +webrtcAddress: :8889 +webrtcAllowOrigins: ['*'] +webrtcLocalUDPAddress: :8189 +# Le conteneur ne voit qu'une IP privee ; sans cette declaration, les +# candidats ICE annonces seraient inutilisables depuis l'exterieur. +webrtcIPsFromInterfaces: no +webrtcAdditionalHosts: [__PUBLIC_IP__] + +# HLS - repli universel, ~6 s de latence. Couvre les reseaux d'entreprise +# qui filtrent l'UDP et donc WebRTC. +hls: yes +hlsAddress: :8888 +hlsAllowOrigins: ['*'] +hlsVariant: lowLatency +hlsSegmentCount: 7 +hlsSegmentDuration: 1s +hlsPartDuration: 200ms +hlsAlwaysRemux: no + +############################################################################### +# Flux +############################################################################### +paths: + # Le capteur du studio. Nom volontairement aligne sur le deviceId utilise + # par le domaine d'alarme (iphone-biblio). + biblio: diff --git a/docker-compose.yml b/docker-compose.yml index 0190a3b..c156c76 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -105,6 +105,33 @@ services: # "edge" pour etre joignable par le reverse proxy. networks: [default, edge] + # --------------------------------------------------------------------------- + # Serveur media - ingestion du flux iPhone, restitution navigateur + # + # L'iPhone POUSSE vers ce service (SRT ou RTMP). Il ne peut pas etre + # interroge : en 4G comme derriere la box du studio, il n'a pas d'adresse + # joignable depuis Internet. Seul un flux sortant traverse le CGNAT. + # + # Les ports d'ingestion sont publies sur l'hote, ce sont les seuls du projet + # a l'etre. Les sorties HTTP (HLS 8888, WebRTC 8889) restent internes et + # passent par Caddy, qui leur apporte le TLS du domaine. + # --------------------------------------------------------------------------- + mediamtx: + image: bluenviron/mediamtx:1.21.0 + container_name: iot-alarm-mediamtx + restart: unless-stopped + volumes: + - ./deploy/mediamtx/mediamtx.yml:/mediamtx.yml:ro + ports: + - "1935:1935" # RTMP (TCP) - ingestion, repli + - "8890:8890/udp" # SRT (UDP) - ingestion, a privilegier + - "8189:8189/udp" # WebRTC ICE - media vers les navigateurs + mem_limit: 256m + # Image sans shell (base scratch) : pas de healthcheck par commande. + # La disponibilite se lit sur /actuator du cote applicatif et dans les + # logs du conteneur. + networks: [default, edge] + # --------------------------------------------------------------------------- # Nginx - profil "standalone" uniquement # diff --git a/src/main/java/me/belkacemi/iotalarm/presentation/advice/GlobalExceptionHandler.java b/src/main/java/me/belkacemi/iotalarm/presentation/advice/GlobalExceptionHandler.java index 54a895e..a702221 100644 --- a/src/main/java/me/belkacemi/iotalarm/presentation/advice/GlobalExceptionHandler.java +++ b/src/main/java/me/belkacemi/iotalarm/presentation/advice/GlobalExceptionHandler.java @@ -5,6 +5,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.http.ProblemDetail; +import org.springframework.http.ResponseEntity; +import org.springframework.web.ErrorResponse; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -66,14 +68,26 @@ public class GlobalExceptionHandler { /** * Filet de securite. On journalise la stacktrace mais on ne la renvoie pas : * l'endpoint d'ingestion est expose publiquement. + * + *

Les exceptions que Spring sait deja traduire implementent + * {@link ErrorResponse} et portent leur propre statut : ressource absente + * (404), methode non supportee (405), media non supporte (415). Les avaler + * dans un 500 generique transformerait une URL inconnue en panne serveur - + * et, plus grave, noierait les vraies pannes au milieu de ces faux + * positifs dans les logs et les alertes.

*/ @ExceptionHandler(Exception.class) - public ProblemDetail onUnexpectedError(Exception exception) { + public ResponseEntity onUnexpectedError(Exception exception) { + if (exception instanceof ErrorResponse errorResponse) { + return ResponseEntity.status(errorResponse.getStatusCode()) + .body(errorResponse.getBody()); + } + log.error("Erreur inattendue", exception); ProblemDetail problem = ProblemDetail.forStatusAndDetail( HttpStatus.INTERNAL_SERVER_ERROR, "Une erreur interne est survenue."); problem.setTitle("Erreur interne"); - return problem; + return ResponseEntity.internalServerError().body(problem); } } diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html index 0928b53..dcc7d7e 100644 --- a/src/main/resources/static/index.html +++ b/src/main/resources/static/index.html @@ -47,6 +47,35 @@ + + + +
+
+ + +
+

Connexion au capteur…

+

Recherche du flux biblio

+
+ +
+ + EN DIRECT +
+ +

+
+ +
+
+

Capteur iphone-biblio

+

iPhone recycle, bibliotheque · flux pousse en SRT vers le serveur

+
+ +
+
+ @@ -98,6 +127,9 @@ + + + diff --git a/src/main/resources/static/stream.js b/src/main/resources/static/stream.js new file mode 100644 index 0000000..b4b5a40 --- /dev/null +++ b/src/main/resources/static/stream.js @@ -0,0 +1,243 @@ +/* --------------------------------------------------------------------------- + * Lecture du flux video pousse par l'iPhone. + * + * Deux transports, essayes dans cet ordre : + * + * 1. WebRTC (WHEP) - latence inferieure a la seconde. C'est ce qu'on veut + * pour de la surveillance : voir quelqu'un six secondes + * apres son passage n'a pas le meme interet. + * 2. HLS - repli. Plus de latence, mais passe partout, y compris + * sur les reseaux d'entreprise qui filtrent l'UDP et + * donc bloquent WebRTC. + * + * Le flux est absent la plupart du temps (l'iPhone ne pousse que lorsqu'il est + * en place et alimente). Ce n'est pas une erreur : c'est l'etat nominal, et + * l'interface doit le dire calmement plutot qu'afficher un echec rouge. + * --------------------------------------------------------------------------- */ + +(function () { + 'use strict'; + + const STREAM = 'biblio'; + const WHEP_URL = '/webrtc/' + STREAM + '/whep'; + const HLS_URL = '/hls/' + STREAM + '/index.m3u8'; + + const RETRY_DELAY_MS = 8000; + const ICE_TIMEOUT_MS = 3000; + + const el = { + panel: document.getElementById('video-panel'), + video: document.getElementById('video'), + title: document.getElementById('video-overlay-title'), + hint: document.getElementById('video-overlay-hint'), + transport: document.getElementById('video-transport'), + reload: document.getElementById('btn-video-reload') + }; + + let peerConnection = null; + let hlsPlayer = null; + let retryTimer = null; + let stopped = false; + + /* ------------------------------ Affichage ----------------------------- */ + + function setState(state, title, hint, transport) { + el.panel.dataset.state = state; + el.title.textContent = title; + el.hint.innerHTML = hint; + el.transport.textContent = transport || ''; + } + + /* ------------------------------- Nettoyage ---------------------------- */ + + function teardown() { + if (peerConnection) { + peerConnection.close(); + peerConnection = null; + } + if (hlsPlayer) { + hlsPlayer.destroy(); + hlsPlayer = null; + } + el.video.srcObject = null; + el.video.removeAttribute('src'); + el.video.load(); + } + + function scheduleRetry() { + if (stopped) { return; } + clearTimeout(retryTimer); + retryTimer = setTimeout(start, RETRY_DELAY_MS); + } + + /* -------------------------------- WebRTC ------------------------------ */ + + /** + * Attend la fin de la collecte ICE plutot que d'implementer le trickle. + * Le serveur et le navigateur sont tous deux sur Internet public, la + * collecte prend quelques dizaines de millisecondes - le timeout n'est + * qu'un garde-fou pour ne pas rester bloque indefiniment. + */ + function waitForIce(pc) { + if (pc.iceGatheringState === 'complete') { + return Promise.resolve(); + } + return new Promise(resolve => { + const done = () => { + if (pc.iceGatheringState === 'complete') { + pc.removeEventListener('icegatheringstatechange', done); + resolve(); + } + }; + pc.addEventListener('icegatheringstatechange', done); + setTimeout(resolve, ICE_TIMEOUT_MS); + }); + } + + async function tryWebRtc() { + const pc = new RTCPeerConnection({ iceServers: [] }); + peerConnection = pc; + + // recvonly : la console regarde, elle n'emet jamais. Sans cette + // precision, le navigateur demanderait l'acces a la webcam du visiteur. + pc.addTransceiver('video', { direction: 'recvonly' }); + pc.addTransceiver('audio', { direction: 'recvonly' }); + + pc.ontrack = event => { + el.video.srcObject = event.streams[0]; + setState('live', '', '', 'WebRTC · latence < 1 s'); + }; + + pc.onconnectionstatechange = () => { + if (['failed', 'disconnected', 'closed'].includes(pc.connectionState) + && peerConnection === pc) { + setState('offline', 'Flux interrompu', + 'Reconnexion automatique dans quelques secondes.', ''); + teardown(); + scheduleRetry(); + } + }; + + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + await waitForIce(pc); + + const response = await fetch(WHEP_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/sdp' }, + body: pc.localDescription.sdp + }); + + // 404 = aucun publieur sur ce flux. L'iPhone n'emet pas, c'est tout. + if (response.status === 404) { + const error = new Error('no-publisher'); + error.noPublisher = true; + throw error; + } + if (!response.ok) { + throw new Error('WHEP ' + response.status); + } + + await pc.setRemoteDescription({ + type: 'answer', + sdp: await response.text() + }); + } + + /* --------------------------------- HLS -------------------------------- */ + + function tryHls() { + return new Promise((resolve, reject) => { + // Safari lit le HLS nativement, sans bibliotheque. + if (el.video.canPlayType('application/vnd.apple.mpegurl')) { + el.video.src = HLS_URL; + el.video.addEventListener('loadedmetadata', () => { + setState('live', '', '', 'HLS natif · ~6 s de latence'); + resolve(); + }, { once: true }); + el.video.addEventListener('error', () => reject(new Error('hls-natif')), + { once: true }); + return; + } + + if (typeof Hls === 'undefined' || !Hls.isSupported()) { + reject(new Error('hls-indisponible')); + return; + } + + const hls = new Hls({ lowLatencyMode: true, backBufferLength: 10 }); + hlsPlayer = hls; + + hls.on(Hls.Events.MANIFEST_PARSED, () => { + setState('live', '', '', 'HLS · ~6 s de latence'); + resolve(); + }); + + hls.on(Hls.Events.ERROR, (_evt, data) => { + if (data.fatal) { + hls.destroy(); + hlsPlayer = null; + reject(new Error('hls-' + data.type)); + } + }); + + hls.loadSource(HLS_URL); + hls.attachMedia(el.video); + }); + } + + /* ------------------------------ Orchestration -------------------------- */ + + async function start() { + clearTimeout(retryTimer); + teardown(); + setState('connecting', 'Connexion au capteur...', + 'Recherche du flux ' + STREAM + '', ''); + + try { + await tryWebRtc(); + return; + } catch (error) { + teardown(); + + if (error.noPublisher) { + setState('offline', 'Capteur hors ligne', + 'L\'iPhone ne pousse aucun flux pour le moment. ' + + 'La detection d\'intrusion, elle, continue de fonctionner.', ''); + scheduleRetry(); + return; + } + console.warn('WebRTC indisponible, repli HLS :', error.message); + } + + try { + await tryHls(); + } catch (error) { + console.warn('HLS indisponible :', error.message); + teardown(); + setState('offline', 'Flux indisponible', + 'Ni WebRTC ni HLS n\'ont abouti. Nouvelle tentative automatique.', ''); + scheduleRetry(); + } + } + + /* -------------------------------- Demarrage ---------------------------- */ + + el.reload.addEventListener('click', start); + + // Un onglet en arriere-plan n'a pas besoin de decoder de la video : + // ca economise la batterie du visiteur et la bande passante du serveur. + document.addEventListener('visibilitychange', () => { + if (document.hidden) { + stopped = true; + clearTimeout(retryTimer); + teardown(); + setState('offline', 'Flux en pause', 'Onglet en arriere-plan.', ''); + } else { + stopped = false; + start(); + } + }); + + start(); +})(); diff --git a/src/main/resources/static/style.css b/src/main/resources/static/style.css index 05c27f3..8afcc84 100644 --- a/src/main/resources/static/style.css +++ b/src/main/resources/static/style.css @@ -421,3 +421,165 @@ footer a:hover { color: var(--blue); } .controls { width: 100%; } .btn { flex: 1; justify-content: center; } } + +/* --------------------------------------------------------------------------- + Flux video + --------------------------------------------------------------------------- */ + +.video-panel { + border: 1px solid var(--border); + border-radius: 14px; + background: var(--bg-panel); + overflow: hidden; +} + +.video-frame { + position: relative; + width: 100%; + aspect-ratio: 16 / 9; + background: #000; + /* Le fond noir doit rester visible meme si la video n'a pas ce ratio : + une camera de surveillance verticale ne doit pas etre deformee. */ + display: flex; + align-items: center; + justify-content: center; +} + +.video-frame video { + width: 100%; + height: 100%; + object-fit: contain; + display: block; + background: #000; +} + +/* --- Voile d'attente, masque des que l'image arrive --- */ + +.video-overlay { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + text-align: center; + padding: 24px; + background: linear-gradient(180deg, #0d1016, #05070a); + transition: opacity 400ms ease; +} + +.video-panel[data-state="live"] .video-overlay { + opacity: 0; + pointer-events: none; +} + +.video-overlay-title { + margin: 0; + font-family: var(--mono); + font-size: 14px; + color: var(--text-dim); + letter-spacing: 0.02em; +} + +.video-overlay-hint { + margin: 0; + font-size: 12.5px; + color: var(--text-faint); + max-width: 46ch; + line-height: 1.6; +} + +.video-overlay-hint code { + font-family: var(--mono); + color: var(--blue); + background: rgba(96, 165, 250, 0.1); + padding: 1px 5px; + border-radius: 4px; +} + +.video-panel[data-state="offline"] .video-overlay-title { color: var(--amber); } +.video-panel[data-state="error"] .video-overlay-title { color: var(--red); } + +/* --- Pastille "EN DIRECT" --- */ + +.video-badge { + position: absolute; + top: 14px; + left: 14px; + display: none; + align-items: center; + gap: 7px; + padding: 5px 11px; + border-radius: 999px; + background: rgba(10, 12, 16, 0.78); + backdrop-filter: blur(8px); + font-family: var(--mono); + font-size: 10.5px; + letter-spacing: 0.1em; + color: var(--text); +} + +.video-panel[data-state="live"] .video-badge { display: flex; } + +.video-badge-dot { + width: 7px; height: 7px; + border-radius: 50%; + background: var(--red); + box-shadow: 0 0 9px var(--red); + animation: pulse 1.6s ease-in-out infinite; +} + +/* --- Transport reellement utilise (WebRTC ou HLS) --- */ + +.video-transport { + position: absolute; + bottom: 12px; + right: 14px; + margin: 0; + padding: 4px 9px; + border-radius: 6px; + background: rgba(10, 12, 16, 0.7); + font-family: var(--mono); + font-size: 10.5px; + color: var(--text-faint); +} + +.video-transport:empty { display: none; } + +/* --- Legende sous l'image --- */ + +.video-caption { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; + padding: 14px 18px; + border-top: 1px solid var(--border); +} + +.video-caption-title { + margin: 0; + font-size: 13.5px; + font-weight: 600; +} + +.video-caption-title code { + font-family: var(--mono); + font-size: 12.5px; + color: var(--blue); +} + +.video-caption-sub { + margin: 3px 0 0; + font-size: 12px; + color: var(--text-faint); +} + +/* Quand une intrusion est en cours, le cadre video se souligne en rouge : + c'est l'image qu'on veut regarder a ce moment precis. */ +.status-panel[data-state="TRIGGERED"] ~ .video-panel { + border-color: var(--red); + box-shadow: 0 0 28px rgba(244, 63, 94, 0.18); +} diff --git a/src/test/java/me/belkacemi/iotalarm/presentation/advice/GlobalExceptionHandlerTest.java b/src/test/java/me/belkacemi/iotalarm/presentation/advice/GlobalExceptionHandlerTest.java new file mode 100644 index 0000000..e6b1822 --- /dev/null +++ b/src/test/java/me/belkacemi/iotalarm/presentation/advice/GlobalExceptionHandlerTest.java @@ -0,0 +1,68 @@ +package me.belkacemi.iotalarm.presentation.advice; + +import me.belkacemi.iotalarm.domain.exception.DomainException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.http.ResponseEntity; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Regression : le filet de securite {@code @ExceptionHandler(Exception.class)} + * avalait les exceptions que Spring sait deja traduire. + * + *

Consequence observee en production : toute URL inconnue renvoyait 500 + * "Erreur interne" au lieu de 404. Au-dela du code faux, ca noyait les vraies + * pannes au milieu des 500 provoques par les crawlers et les liens morts.

+ */ +@DisplayName("GlobalExceptionHandler") +class GlobalExceptionHandlerTest { + + private final GlobalExceptionHandler handler = new GlobalExceptionHandler(); + + @Test + @DisplayName("une ressource absente reste un 404, pas un 500") + void unknownResourceStaysNotFound() { + NoResourceFoundException exception = + new NoResourceFoundException(HttpMethod.GET, "/route-inconnue"); + + ResponseEntity response = handler.onUnexpectedError(exception); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + @DisplayName("une exception reellement inattendue donne un 500") + void unexpectedExceptionIsInternalServerError() { + ResponseEntity response = + handler.onUnexpectedError(new IllegalStateException("pool JDBC epuise")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + } + + @Test + @DisplayName("le message interne ne fuite jamais vers le client") + void internalMessageIsNeverLeaked() { + ResponseEntity response = handler.onUnexpectedError( + new IllegalStateException("jdbc:postgresql://postgres:5432 mot de passe refuse")); + + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getDetail()) + .isEqualTo("Une erreur interne est survenue.") + .doesNotContain("jdbc", "postgres", "mot de passe"); + } + + @Test + @DisplayName("une regle metier violee est un 409, pas un 400") + void domainRuleViolationIsConflict() { + ProblemDetail problem = handler.onDomainException( + new DomainException("L'alarme est deja armee")); + + assertThat(problem.getStatus()).isEqualTo(HttpStatus.CONFLICT.value()); + assertThat(problem.getDetail()).isEqualTo("L'alarme est deja armee"); + } +}