# Évaluation du plan : centraliser_base_url_axum_middleware ## Résumé de l'audit Le plan est **bien pensé et cohérent**. Il identifie correctement le problème et la solution. Cependant, j'ai identifié plusieurs points nécessitant des amendements. --- ## Points validés (conformes au code actuel) 1. **Problème bien identifié** : URLs hardcodées avec IP locale (`PMO_SERVER_URL`) retournées au frontend via reverse proxy. 2. **`get_request_base_url` existe déjà** à `pmoserver/src/lib.rs:199` — pas besoin de la recréer. 3. **`covers_route_for` existe déjà** dans `pmocache/src/lib.rs:149`. 4. **`covers_absolute_url_for` utilisée dans les contextes UPnP** : - `pmoupnp/src/cache_registry.rs:57` - `pmoradiofrance/src/metadata_cache.rs:263` - `pmoparadise/src/source.rs:216` - `pmoaudio-ext/src/sinks/streaming_icyflac_sink.rs:91` 5. **Route audio correcte** : `/audio/tracks/{pk}` (pas `/audio/flac/{pk}`). 6. **Architecture du Server** : Les routes sont construites dynamiquement via `Arc>`. Le layer devra être ajouté dans la construction du router, pas après. --- ## Points à amender ### 1. Ajout du layer dans le Server Le plan suggère d'ajouter le layer "dans `server.rs`" mais la structure du router est complexe : - Les routes sont dynamiques (`RwLock`) - Le router final est un fallback qui délègue **Correction** : Ajouter le layer directement lors de la création du `registry_route` initial (ligne 120-122) : ```rust let registry_route = Router::new() .route("/api/registry", get(get_api_registry)) .with_state(api_registry.clone()) .layer(base_url_layer()); // ← ici ``` ### 2. Comportement requis pour LAN vs WAN Le middleware doit supporter les deux cas d'usage : - **LAN (sans reverse proxy)** : Pas de headers `X-Forwarded-*` → utiliser l'adresse IP locale du serveur (`PMO_SERVER_URL`) - **WAN (via reverse proxy)** : Headers `X-Forwarded-*` présents → utiliser l'URL publique du reverse proxy **Important** : `get_request_base_url` dans `pmoserver/src/lib.rs:199` lit déjà ces headers. Le fallback doit être `PMO_SERVER_URL` qui est configuré au démarrage avec l'IP locale. ### 3. Chemin du middleware dans la pile Le plan dit d'appliquer le layer "avant" les autres. En réalité, Tower/Acorn applique les couches dans l'ordre où elles sont ajoutées — le premier layer ajouté est le plus extérieur (exécuté en premier). Le `base_url_layer` doit donc être ajouté en **premier** (le plus intérieur) pour voir les headers nettoyés. ### 3. Les handlers n'ont PAS besoin de BaseUrl Après analyse, **aucun handler** dans le codebase actuel n'appelle `covers_absolute_url_for()` directement pour le frontend. Les `album_art_uri` sont : - Soit **propagés** depuis les réponses UPnP des media servers (pas des URLs pmomusic) - Soit **construits en tâche de fond** dans les caches (RadioFrance, RadioParadise) **Correction** : Le plan surestime le nombre de handlers à modifier. La vraie question est : d'où viennent les URLs incorrectes ? ### 4. Source du problème à clarifier Les URLs incorrectes ne viennent pas des handlers REST classiques. Elles viennent probablement de : **a) Tâches de fond** (background tasks) qui stockent des URLs complètes : - `pmoradiofrance/src/metadata_cache.rs:263` — construit `covers_absolute_url_for()` dans le cache - `pmoparadise/src/source.rs:216` — même problème **b) API Qobuz** (`pmoqobuz/src/api_rest.rs:302`) — utilise `covers_route_for` (route relative, OK) **c) Playlist** (`pmoplaylist/src/handle/read.rs:204,271,355`) — utilise `covers_route_for` (OK) ### 5. Correction du fallback Le plan suggère `localhost:8080` ou `0.0.0.0:8080` comme fallback. Le port doit provenir de la configuration du serveur (`get_server_base_url()` existe déjà dans `pmoserver/src/lib.rs`). **Correction** : Le fallback utilise `get_server_base_url()` (disponible via `GLOBAL_SERVER`) : - En LAN : pas de `X-Forwarded-*` → `get_server_base_url()` → URLs en IP locale - En WAN : `X-Forwarded-*` présents → URLs en URL publique du reverse proxy ### 6. Fonction `audio_route_for` pas nécessaire maintenant Le plan propose d'ajouter `audio_route_for` dans `pmoaudiocache`. Mais : - Les fichiers audio sont servis par `pmoaudiocache` lui-même (routes internes) -Aucune URL audio n'est retournée au frontend via JSON **Supprimer** cette étape du plan. --- ## Plan amendé ### Étape 0 — Audit spécifique (à faire avant implémentation) ```bash # Trouver les constructions d'URLs dans les tâches de fond (caches, sources) grep -rn "covers_absolute_url_for\|PMO_SERVER_URL" --include="*.rs" | grep -v "pmocontrol\|pmoplaylist\|pmoqobuz" # Vérifier les URLs dans les réponses JSON des handlers grep -rn "album_art_uri" --include="*.rs" | grep -E "fn |->" ``` Identifier spécifiquement quels endpoints REST retournent des URLs au frontend. ### Étape 1 — `pmoserver/src/lib.rs` : Ajouter `BaseUrl` + middleware ```rust use axum::{extract::Request, middleware::Next, response::Response}; #[derive(Debug, Clone)] pub struct BaseUrl(pub String); impl BaseUrl { pub fn url_for(&self, route: &str) -> String { debug_assert!(route.starts_with('/'), "route must start with '/'"); format!("{}{}", self.0.trim_end_matches('/'), route) } } pub async fn base_url_middleware(mut request: Request, next: Next) -> Response { // Priorité : 1) X-Forwarded-* (reverse proxy), 2) get_server_base_url() (adresse configurée) let base = get_request_base_url(request.headers()) .or_else(|| get_server_base_url()) .unwrap_or_else(|| { panic!( "BaseUrl: impossible de déterminer l'URL de base.\n\ Configurer PMO_SERVER_URL ou démarrer le serveur avant les handlers HTTP." ); }); tracing::debug!("BaseUrl calculée : {}", base); request.extensions_mut().insert(BaseUrl(base)); next.run(request).await } pub fn base_url_layer() -> axum::middleware::FromFnLayer { axum::middleware::from_fn(base_url_middleware) } ``` **Comportement** : - Accès LAN (pas de proxy) : `get_server_base_url()` → URLs en IP locale configurée - Accès WAN (reverse proxy) : `X-Forwarded-*` → URLs en URL publique **Note** : Si ni les headers ni le serveur ne sont disponibles, le middleware panic (fail-fast) car c'est une erreur de configuration. ### Étape 2 — `pmoserver/src/server.rs` : Appliquer le layer Dans `Server::new()`, ligne ~120-122 : ```rust let registry_route = Router::new() .route("/api/registry", get(get_api_registry)) .with_state(api_registry.clone()) .layer(base_url_layer()); // ← Ajouter ici (couche la plus intérieure) ``` ### Étape 3 — `pmocache/src/lib.rs` : Renommer sans déprecation ```rust // Rename direct - pas de déprecation (soft en cours de dev, pas une library) pub fn covers_absolute_url_for_upnp(pk: &str, param: Option<&str>) -> String { // PMO_SERVER_URL contient l'IP locale (LAN) - utilisé uniquement pour UPnP // Fallback sur get_server_base_url() si dispo, sinon erreur let base = std::env::var("PMO_SERVER_URL") .or_else(|_| pmoserver::get_server_base_url().ok_or("PMO_SERVER_URL not set")) .unwrap_or_else(|e| { tracing::error!("covers_absolute_url_for_upnp: {}", e); panic!("BaseUrl non disponible pour UPnP"); }); format!("{}{}", base.trim_end_matches('/'), covers_route_for(pk, param)) } ``` ### Étape 4 — Mettre à jour les appels UPnP ```bash grep -rn "covers_absolute_url_for" --include="*.rs" ``` Modifier `pmoupnp/src/cache_registry.rs:57` → `covers_absolute_url_for_upnp` ### Étape 5 — Tâches de fond : stocker la route, pas l'URL **pmoradiofrance/src/metadata_cache.rs:263** : ```rust // Avant : let public_url = pmocache::covers_absolute_url_for(&pk, None); // Après : stocker la route relative let album_art_route = pmocache::covers_route_for(&pk, None); ``` Le handler REST qui retourne ces métadonnées devra extraire `Extension` et appliquer `base_url.url_for()`. **pmoparadise/src/source.rs:216** : Même traitement. ### Étape 6 — Vérification et tests ```bash # Plus d'appels à covers_absolute_url_for dans les contextes HTTP grep -rn "covers_absolute_url_for" --include="*.rs" | grep -v "pmocache\|pmoupnp" # Tests du middleware cargo test base_url ``` --- ## Questions en suspens 1. **Fallback avec panic** : Si ni les headers ni le serveur ne sont disponibles, le middleware panic au démarrage avec un message clair (ex: "BaseUrl: configurer PMO_SERVER_URL ou démarrer le serveur avant les handlers HTTP"). → **Décision utilisateur** : OK, panic avec message clair. 2. **Reverse proxy avec Authelia** : NPM ajoutera les headers `X-Forwarded-*`. Authelia gère l'authentification separately. Pas de vérification de header supplémentaire nécessaire pour le middleware BaseUrl. → **Décision** : Pas de vérification supplémentaire. --- ## Problème complémentaire : URLs de covers des media servers externes ### Contexte Quand le control point accède à un media server externe sur le LAN (autre que pmomusic), les URLs d'articles (`album_art_uri`) retournées par ce media server externe contiennent des IPs locales du LAN externe (ex: `http://192.168.1.100:8080/covers/...`). Ces URLs ne passent pas par notre système de caching et ne peuvent pas être rewritées par le middleware `BaseUrl` car elles sont : 1. Recues depuis le réseau UPnP (pas via HTTP) 2. Propagées directement dans les réponses REST/SSE sans transformation ### Solution proposée : Proxy de covers avec cache Créer un nouveau endpoint HTTP qui agit comme un proxy transparent : 1. **Détection** : Si l'URL demandée est une URL LAN externe (pas une URL locale de pmomusic) 2. **Caching** : Utiliser `cache.add_from_url()` qui gère déjà la déduplication (pas de double-cache) 3. **Rewriting** : Retourner l'URL locale du cache (`/covers/image/{pk}`) **Note importante** : `pmocache::add_from_url()` gère déjà : - La vérification si l'URL est déjà en cache (ligne 673-683) - Le calcul du pk basé sur le contenu (pas sur l'URL) - La déduplication automatique pour les mêmes contenus ### Implémentation **Nouvel endpoint dans `pmocovers/src/lib.rs` ou nouveau fichier `pmocovers/src/proxy.rs`** : ```rust #[derive(Debug, Deserialize)] struct CoverProxyParams { url: String, } #[derive(Debug, Serialize)] struct CoverProxyResponse { cached_url: String, pk: String, } /// GET /covers/proxy?url= /// Proxy transparent qui : /// 1. Détecte si l'URL est une URL LAN externe (pas déjà locale) /// 2. Ajoute à cache via add_from_url (déduplication automatique) /// 3. Retourne l'URL locale du cache pub async fn cover_proxy_handler( Query(params): Query, State(cache): State, Extension(base_url): Extension, ) -> Result { let external_url = ¶ms.url; // Ignorer si déjà une URL locale (ne pas se cacher soi-même) if is_local_cover_url(external_url, &base_url) { return Err((StatusCode::BAD_REQUEST, "URL is already a local cover")); } // Vérifier si c'est une URL LAN à proxyfier if !should_proxy_url(external_url) { return Err((StatusCode::BAD_REQUEST, "URL is not a LAN URL requiring proxy")); } // Ajouter au cache (add_from_url gère la déduplication) let pk = cache.add_from_url(external_url, Some("external-covers")) .await .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?; // Retourner l'URL locale let local_url = base_url.url_for(&pmocache::covers_route_for(&pk, None)); Ok(Json(CoverProxyResponse { cached_url: local_url, pk })) } /// Vérifie si l'URL est déjà une cover locale de NOTRE instance pmomusic /// Note: Les covers d'autres instances pmomusic sur le LAN DEVRAIENT être proxyfiées /// et mises en cache localement - c'est le comportement desired! fn is_local_cover_url(url: &str, base_url: &pmoserver::BaseUrl) -> bool { // Only skip if it's OUR instance's base URL // Covers from other pmomusic instances on LAN should be proxied and cached url.starts_with(&base_url.0) } /// Vérifie si l'URL doit être proxyfiée (URL LAN externe) fn should_proxy_url(url: &str) -> bool { if let Ok(parsed) = url::Url::parse(url) { if let Some(host) = parsed.host_str() { // Proxy uniquement les URLs LAN (pas les URLs publiques) if let Ok(ip) = host.parse::() { return ip.is_private() || ip.is_loopback(); } // aussi les .local return host.ends_with(".local") || host == "localhost"; } } false } ``` **Points importants** : - Utiliser `add_from_url()` pour bénéficier de la déduplication automatique - Vérifier `is_local_cover_url()` avec uniquement la comparaison de base_url pour éviter que notre instance ne se cache elle-même - Les covers d'autres instances pmomusic sur le LAN DEVRAIENT être proxyfiées (comportement souhaité!) - Le TTL sera celui par défaut du cache (configurable) **Mise à jour des handlers REST** : Dans `pmocontrol/src/pmoserver_ext.rs` et `pmocontrol/src/sse.rs`, transformer les `album_art_uri` LAN : ```rust fn transform_external_cover_url(url: &str) -> String { if is_lan_url(url) { // Remplacer par l'URL du proxy let encoded = urlencoding::encode(url); return format!("/covers/proxy?url={}", encoded); } url.to_string() } ``` **Appels dans les handlers** : - `pmocontrol/src/pmoserver_ext.rs:2173` : `browse_container` → transformer `album_art_uri` - `pmocontrol/src/pmoserver_ext.rs:2424` : autre endpoint → même transformation - `pmocontrol/src/sse.rs:213` : `MetadataChanged` events → même transformation ### TTL - Le TTL sera celui par défaut du cache `pmocovers` - C'est configurable via `pmoconfig` si besoin ### Sécurité - Limiter aux URLs LAN uniquement (`192.168.x.x`, `10.x.x.x`, `172.16-31.x.x`, `localhost`) - Vérifier que l'URL n'est pas déjà une cover locale de pmomusic (éviter le cacheception) - Ajouter un rate limiting pour éviter le flood de téléchargement - Timeout de téléchargement : 10 secondes max ### Résumé des fichiers à modifier 1. **Nouveau** : `pmocovers/src/proxy.rs` - Endpoint de proxy 2. **Modifier** : `pmocontrol/src/pmoserver_ext.rs` - Transformer les album_art_uri 3. **Modifier** : `pmocontrol/src/sse.rs` - Transformer les album_art_uri dans les événements --- ## Plan: Passer le SSE en mode Async ### Contexte Le SSE de PMO Control est **déjà async** (fonctions `pub async fn`), mais le traitement des événements utilise des fonctions **synchrones** (`fn renderer_event_to_payload` → `transform_cover_url_sync`). Cela nécessite des workarounds (threads avec runtime tokio séparés). ### Problèmes actuels 1. **Nested runtime**: `std::thread::spawn` avec `tokio::runtime::Runtime::new()` dans chaque appel 2. **Performance dégradée**: Création d'un thread par URL de cover 3. **Code complexe**: Workarounds pour exécuter de l'async dans du sync ### Solution Rendre le traitement des événements **entièrement async** : 1. **Modifier** `renderer_event_to_payload` → `async fn renderer_event_to_payload` 2. **Modifier** `transform_cover_url_sync` → `transform_cover_url` (async) avec `.await` direct 3. **Supprimer** le workaround `proxy_cover_url_sync` dans `pmocovers` (quand les .await fonctionnent) ### Avantages attendus 1. **Fluidité accrue**: Pas de thread par cover,真正的 async/await 2. **Meilleure réactivité**: Pas de blocking sur les événements SSE 3. **Code plus propre**: Plus de workarounds, plus de runtime imbriqué 4. **Meilleure scalabilité**: Plus de création de thread ### Fichiers à modifier 1. `pmocontrol/src/sse.rs`: - `renderer_event_to_payload` → `async fn` - `media_server_event_to_payload` → `async fn` - `all_events_sse`: utiliser les versions async 2. `pmocontrol/src/pmoserver_ext.rs`: - Utiliser `transform_cover_url` (async) avec `.await` au lieu de `transform_cover_url_sync` 3. `pmocontrol/src/control_point.rs` (si nécessaire): - Adapter les appels aux fonctions async