use std::path::{Path, PathBuf};
use std::sync::Arc;
use axum::Router;
use axum::body::Body;
use axum::extract::Request;
use axum::http::{HeaderName, HeaderValue, Method, header};
use axum::response::{IntoResponse, Response};
use tower::ServiceExt;
use tower_http::services::ServeFile;
use tower_http::set_header::SetResponseHeaderLayer;
use crate::state::AppState;
pub const PANEL_POR_DEFECTO: &str = "ui/dist";
const DOCUMENTO: &str = "index.html";
const CACHE_DEL_ASSET: &str = "public, max-age=31536000, immutable";
const CACHE_DEL_DOCUMENTO: &str = "no-cache";
pub fn app(state: AppState, panel: impl Into<PathBuf>) -> Router {
let panel = Arc::new(panel.into());
let router = crate::api::api_router(state).fallback(move |peticion: Request| {
let panel = Arc::clone(&panel);
async move { atender(&panel, peticion).await }
});
con_cabeceras_de_seguridad(router)
}
fn con_cabeceras_de_seguridad(router: Router) -> Router {
router
.layer(cabecera(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
))
.layer(cabecera(
header::CONTENT_SECURITY_POLICY,
HeaderValue::from_static("frame-ancestors 'none'"),
))
.layer(cabecera(
header::X_FRAME_OPTIONS,
HeaderValue::from_static("DENY"),
))
.layer(cabecera(
header::REFERRER_POLICY,
HeaderValue::from_static("no-referrer"),
))
}
fn cabecera(nombre: HeaderName, valor: HeaderValue) -> SetResponseHeaderLayer<HeaderValue> {
SetResponseHeaderLayer::overriding(nombre, valor)
}
async fn atender(panel: &Path, peticion: Request) -> Response {
let camino = peticion.uri().path().trim_start_matches('/');
if es_de_la_api(camino) {
return crate::api::not_found().await.into_response();
}
if peticion.method() != Method::GET && peticion.method() != Method::HEAD {
return crate::api::metodo_no_permitido().await.into_response();
}
let Some(relativa) = camino_seguro(camino) else {
return crate::api::not_found().await.into_response();
};
let fichero = panel.join(&relativa);
if !relativa.as_os_str().is_empty() && es_fichero(&fichero).await {
return servir(&fichero, peticion, cache_de(&fichero)).await;
}
let documento = panel.join(DOCUMENTO);
if es_fichero(&documento).await {
return servir(&documento, peticion, CACHE_DEL_DOCUMENTO).await;
}
crate::api::not_found().await.into_response()
}
fn es_de_la_api(camino: &str) -> bool {
camino == "api" || camino.starts_with("api/")
}
fn camino_seguro(camino: &str) -> Option<PathBuf> {
let mut relativa = PathBuf::new();
for segmento in camino.split('/').filter(|segmento| !segmento.is_empty()) {
if segmento.starts_with('.') || segmento.contains('\\') {
return None;
}
relativa.push(segmento);
}
Some(relativa)
}
async fn es_fichero(ruta: &Path) -> bool {
tokio::fs::metadata(ruta)
.await
.map(|metadatos| metadatos.is_file())
.unwrap_or(false)
}
fn cache_de(ruta: &Path) -> &'static str {
let con_hash = ruta
.file_stem()
.and_then(|nombre| nombre.to_str())
.and_then(|nombre| nombre.rsplit_once('-'))
.is_some_and(|(_, hash)| {
hash.len() >= 6 && hash.chars().all(|c| c.is_ascii_alphanumeric())
});
if con_hash {
CACHE_DEL_ASSET
} else {
CACHE_DEL_DOCUMENTO
}
}
async fn servir(ruta: &Path, peticion: Request, cache: &'static str) -> Response {
let respuesta = match ServeFile::new(ruta).oneshot(peticion).await {
Ok(respuesta) => respuesta,
Err(nunca) => match nunca {},
};
let mut respuesta = respuesta.map(Body::new);
respuesta
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static(cache));
respuesta
}
#[cfg(test)]
mod tests {
use axum::http::{StatusCode, header};
use super::{PANEL_POR_DEFECTO, app};
use crate::test_support::{
DirectorioDePrueba, body_json, body_text, directorio_de_prueba, estado_desarrollo,
estado_oidc, get, header_text, post,
};
const DOCUMENTO: &str = "<!doctype html><html><body><div id=\"root\">panel</div></body></html>";
const ASSET: &str = "console.log(\"panel\");\n";
fn panel_de_prueba() -> DirectorioDePrueba {
let directorio = directorio_de_prueba();
std::fs::create_dir_all(directorio.ruta().join("assets"))
.expect("el directorio de assets de la prueba se crea");
std::fs::write(directorio.ruta().join("index.html"), DOCUMENTO)
.expect("el documento de la prueba se escribe");
std::fs::write(directorio.ruta().join("assets/app-abc123.js"), ASSET)
.expect("el asset de la prueba se escribe");
directorio
}
#[test]
fn la_ruta_por_defecto_es_la_del_panel_compilado() {
assert_eq!(PANEL_POR_DEFECTO, "ui/dist");
}
#[tokio::test]
async fn la_raiz_sirve_el_documento() {
let panel = panel_de_prueba();
let router = app(estado_desarrollo().await, panel.ruta());
let respuesta = get(router, "/").await;
assert_eq!(respuesta.status(), StatusCode::OK);
let tipo = header_text(&respuesta, header::CONTENT_TYPE).unwrap_or_default();
assert!(
tipo.starts_with("text/html"),
"la raíz tiene que contestar HTML: {tipo}"
);
assert_eq!(body_text(respuesta).await, DOCUMENTO);
}
#[tokio::test]
async fn una_ruta_del_cliente_se_resuelve_con_el_documento() {
let panel = panel_de_prueba();
let router = app(estado_desarrollo().await, panel.ruta());
let respuesta = get(router, "/tareas").await;
assert_eq!(
respuesta.status(),
StatusCode::OK,
"una ruta del cliente no puede contestar 404"
);
let tipo = header_text(&respuesta, header::CONTENT_TYPE).unwrap_or_default();
assert!(
tipo.starts_with("text/html"),
"el fallback tiene que contestar HTML: {tipo}"
);
assert_eq!(body_text(respuesta).await, DOCUMENTO);
}
#[tokio::test]
async fn un_asset_existente_se_sirve_con_su_tipo() {
let panel = panel_de_prueba();
let router = app(estado_desarrollo().await, panel.ruta());
let respuesta = get(router, "/assets/app-abc123.js").await;
assert_eq!(respuesta.status(), StatusCode::OK);
let tipo = header_text(&respuesta, header::CONTENT_TYPE).unwrap_or_default();
assert!(
tipo.contains("javascript"),
"el asset tiene que servirse como JavaScript: {tipo}"
);
assert_eq!(body_text(respuesta).await, ASSET);
}
#[tokio::test]
async fn el_asset_con_hash_se_cachea_como_inmutable_y_el_documento_no() {
let panel = panel_de_prueba();
let router = app(estado_desarrollo().await, panel.ruta());
let asset = get(router.clone(), "/assets/app-abc123.js").await;
let documento = get(router, "/").await;
let cache_del_asset = header_text(&asset, header::CACHE_CONTROL).unwrap_or_default();
let cache_del_documento =
header_text(&documento, header::CACHE_CONTROL).unwrap_or_default();
assert!(
cache_del_asset.contains("immutable") && cache_del_asset.contains("max-age=31536000"),
"el asset con hash tiene que cachearse como inmutable: {cache_del_asset:?}"
);
assert!(
cache_del_documento.contains("no-cache"),
"el documento tiene que exigir revalidación: {cache_del_documento:?}"
);
assert_ne!(
cache_del_asset, cache_del_documento,
"el asset y el documento no pueden compartir política de caché"
);
}
#[tokio::test]
async fn las_respuestas_del_servidor_llevan_las_cabeceras_de_seguridad() {
let panel = panel_de_prueba();
let router = app(estado_desarrollo().await, panel.ruta());
for ruta in ["/", "/assets/app-abc123.js", "/api/v1/health"] {
let respuesta = get(router.clone(), ruta).await;
assert_eq!(respuesta.status(), StatusCode::OK, "la ruta {ruta}");
for (nombre, esperada) in [
(header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
(header::X_FRAME_OPTIONS, "DENY"),
(header::REFERRER_POLICY, "no-referrer"),
] {
assert_eq!(
header_text(&respuesta, nombre.clone()).as_deref(),
Some(esperada),
"la ruta {ruta} no lleva la cabecera {} con el valor {esperada}",
nombre.as_str()
);
}
let csp = header_text(&respuesta, header::CONTENT_SECURITY_POLICY).unwrap_or_default();
assert!(
csp.contains("frame-ancestors 'none'"),
"la ruta {ruta} no prohíbe enmarcarse: {csp:?}"
);
}
assert_eq!(body_text(get(router.clone(), "/").await).await, DOCUMENTO);
assert_eq!(
body_text(get(router.clone(), "/assets/app-abc123.js").await).await,
ASSET
);
let salud = get(router, "/api/v1/health").await;
assert_eq!(salud.status(), StatusCode::OK);
assert_eq!(body_json(salud).await["data"]["auth_mode"], "disabled");
}
#[tokio::test]
async fn una_ruta_de_la_api_no_cae_en_el_fallback() {
let panel = panel_de_prueba();
let router = app(estado_desarrollo().await, panel.ruta());
let respuesta = get(router, "/api/v1/no-existe").await;
assert_eq!(respuesta.status(), StatusCode::NOT_FOUND);
let texto = body_text(respuesta).await;
assert!(
!texto.contains("id=\"root\""),
"la ruta de la API sirvió el documento del panel: {texto}"
);
let cuerpo: serde_json::Value =
serde_json::from_str(&texto).expect("el 404 de la API debe traer JSON en el cuerpo");
assert_eq!(cuerpo["status"], 404);
assert!(
cuerpo["message"]
.as_str()
.is_some_and(|mensaje| !mensaje.is_empty()),
"el 404 debe traer un mensaje: {texto}"
);
}
#[tokio::test]
async fn una_ruta_de_la_api_sin_sesion_no_se_sirve_por_el_panel() {
let panel = panel_de_prueba();
let router = app(estado_oidc().await, panel.ruta());
let respuesta = get(router, "/api/v1/jobs").await;
assert_eq!(respuesta.status(), StatusCode::UNAUTHORIZED);
let texto = body_text(respuesta).await;
assert!(
!texto.contains("id=\"root\""),
"el panel sirvió el documento en vez de exigir sesión: {texto}"
);
let cuerpo: serde_json::Value =
serde_json::from_str(&texto).expect("el 401 debe traer JSON en el cuerpo");
assert_eq!(cuerpo["status"], 401);
}
#[tokio::test]
async fn no_se_sale_del_directorio_del_panel() {
let panel = panel_de_prueba();
let router = app(estado_desarrollo().await, panel.ruta());
for ruta in ["/../Cargo.toml", "/assets/../../Cargo.toml", "/.env"] {
let respuesta = get(router.clone(), ruta).await;
assert!(
respuesta.status() == StatusCode::NOT_FOUND
|| respuesta.status() == StatusCode::BAD_REQUEST,
"la ruta {ruta} contestó {} y tenía que rechazarse",
respuesta.status()
);
let texto = body_text(respuesta).await;
assert!(
!texto.contains("[package]") && !texto.contains("id=\"root\""),
"la ruta {ruta} entregó un fichero de fuera del panel: {texto}"
);
}
}
#[tokio::test]
async fn las_formas_codificadas_del_traversal_se_quedan_dentro_del_panel() {
let panel = panel_de_prueba();
let router = app(estado_desarrollo().await, panel.ruta());
for ruta in ["/%2e%2e%2fCargo.toml", "/%2fCargo.toml"] {
let respuesta = get(router.clone(), ruta).await;
assert_eq!(
respuesta.status(),
StatusCode::OK,
"la ruta {ruta} no se decodifica: es una ruta del cliente"
);
assert_eq!(
body_text(respuesta).await,
DOCUMENTO,
"la ruta {ruta} entregó algo que no es el documento del panel"
);
}
let rechazada = get(router, "/..%2fCargo.toml").await;
assert_eq!(
rechazada.status(),
StatusCode::NOT_FOUND,
"una ruta que empieza por punto no puede pasar del candado"
);
assert!(
!body_text(rechazada).await.contains("[package]"),
"la ruta con el separador codificado entregó un fichero de fuera del panel"
);
}
#[tokio::test]
async fn el_404_del_panel_es_el_de_la_api() {
let panel = panel_de_prueba();
let router = app(estado_desarrollo().await, panel.ruta());
let de_la_api = body_json(get(router.clone(), "/api/v1/no-existe").await).await;
let del_panel = body_json(get(router, "/../Cargo.toml").await).await;
assert_eq!(
de_la_api["message"], del_panel["message"],
"el `404` del panel y el de la API tienen que compartir mensaje"
);
assert_eq!(de_la_api["status"], del_panel["status"]);
assert_eq!(del_panel["status"], 404);
}
#[tokio::test]
async fn sin_panel_el_servidor_sigue_en_pie() {
let directorio = directorio_de_prueba();
let inexistente = directorio.ruta().join("no-hay-dist");
let router = app(estado_desarrollo().await, inexistente);
let respuesta = get(router, "/").await;
assert_eq!(respuesta.status(), StatusCode::NOT_FOUND);
let texto = body_text(respuesta).await;
let cuerpo: serde_json::Value =
serde_json::from_str(&texto).expect("el 404 debe traer JSON en el cuerpo");
assert_eq!(cuerpo["status"], 404);
assert!(
cuerpo["message"]
.as_str()
.is_some_and(|mensaje| !mensaje.is_empty()),
"el 404 debe traer un mensaje: {texto}"
);
}
#[tokio::test]
async fn los_metodos_del_fallback_se_limitan_a_lectura() {
let panel = panel_de_prueba();
let router = app(estado_desarrollo().await, panel.ruta());
let respuesta = post(router, "/tareas").await;
assert_eq!(respuesta.status(), StatusCode::METHOD_NOT_ALLOWED);
let texto = body_text(respuesta).await;
assert!(
!texto.contains("id=\"root\""),
"el 405 sirvió el documento del panel: {texto}"
);
let cuerpo: serde_json::Value =
serde_json::from_str(&texto).expect("el 405 debe traer JSON en el cuerpo");
assert_eq!(cuerpo["status"], 405);
}
#[tokio::test]
async fn la_salud_sigue_contestando_la_envoltura() {
let panel = panel_de_prueba();
let router = app(estado_desarrollo().await, panel.ruta());
let respuesta = get(router, "/api/v1/health").await;
assert_eq!(respuesta.status(), StatusCode::OK);
let cuerpo = body_json(respuesta).await;
assert_eq!(cuerpo["data"]["auth_mode"], "disabled");
}
}