Files
pmomusic/.kilo/plans/1775285337131-neon-mountain.md
Eric Coissac bdaee820ad Add cover proxy for external LAN media servers
- Implement new /covers/proxy endpoint to cache and rewrite external LAN cover URLs
- Add url/urlencoding dependencies for URL parsing/escaping in pmocontrol and pmocovers Cargo.toml
- Transform album_art_uri fields to use proxy endpoint for LAN URLs in REST and SSE handlers (browse_container, MetadataChanged)
- Add URL validation logic to detect LAN vs public URLs and avoid self-caching
- Update Cargo.lock with new dependencies
2026-04-04 10:42:46 +02:00

14 KiB

É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<RwLock<Router>>. 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<Router>)
  • 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) :

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)

# 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

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 :

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

// 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

grep -rn "covers_absolute_url_for" --include="*.rs"

Modifier pmoupnp/src/cache_registry.rs:57covers_absolute_url_for_upnp

Étape 5 — Tâches de fond : stocker la route, pas l'URL

pmoradiofrance/src/metadata_cache.rs:263 :

// 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<BaseUrl> et appliquer base_url.url_for().

pmoparadise/src/source.rs:216 : Même traitement.

Étape 6 — Vérification et tests

# 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 :

#[derive(Debug, Deserialize)]
struct CoverProxyParams {
    url: String,
}

#[derive(Debug, Serialize)]
struct CoverProxyResponse {
    cached_url: String,
    pk: String,
}

/// GET /covers/proxy?url=<encoded_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<CoverProxyParams>,
    State(cache): State<CoverCache>,
    Extension(base_url): Extension<pmoserver::BaseUrl>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
    let external_url = &params.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::<std::net::IpAddr>() {
                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 :

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