use std::sync::Arc;
use axum::{
extract::{Extension, OriginalUri, Path, State},
http::{header, Method, StatusCode},
response::{IntoResponse, Redirect, Response},
Json,
};
use crate::{middleware::realm::ResolvedRealm, state::ServerState};
pub const ADMIN_CONSOLE_PREFIX: &str = "/admin/console";
#[cfg(webclient_present)]
mod embedded {
use include_dir::{include_dir, Dir};
pub static DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../../webclientsrc/dist");
}
#[cfg(not(webclient_present))]
mod embedded {
pub fn get_file(_: &str) -> Option<&'static [u8]> {
None
}
pub fn get_index_html() -> Option<&'static [u8]> {
None
}
}
#[cfg(webclient_present)]
fn get_file(path: &str) -> Option<&'static [u8]> {
let normalized = path.trim_start_matches('/');
embedded::DIR.get_file(normalized).map(|f| f.contents())
}
#[cfg(webclient_present)]
fn get_index_html() -> Option<&'static [u8]> {
embedded::DIR.get_file("index.html").map(|f| f.contents())
}
#[cfg(not(webclient_present))]
fn get_file(path: &str) -> Option<&'static [u8]> {
embedded::get_file(path)
}
#[cfg(not(webclient_present))]
fn get_index_html() -> Option<&'static [u8]> {
embedded::get_index_html()
}
pub async fn static_handler(
State(_state): State<Arc<ServerState>>,
path: Option<Path<String>>,
Extension(ResolvedRealm(_realm)): Extension<ResolvedRealm>,
) -> Response {
let path = path.map(|p| p.0).unwrap_or_default();
if path.starts_with("realms/") {
let segments: Vec<&str> = path.split('/').collect();
if segments.len() >= 3 && segments[2] == "account" {
if segments.get(3) == Some(&"api") {
return not_found_json();
}
if let Some(contents) = get_file("account.html") {
return ([(header::CONTENT_TYPE, "text/html")], contents).into_response();
}
}
}
if let Some(contents) = get_file(&path) {
let mime = mime_guess::from_path(&path).first_or_octet_stream();
return ([(header::CONTENT_TYPE, mime.as_ref())], contents).into_response();
}
not_found_json()
}
pub async fn admin_console_handler(method: Method, OriginalUri(uri): OriginalUri) -> Response {
let path = uri.path();
if path == "/admin" || path == "/admin/" {
if method == Method::GET {
return Redirect::temporary(ADMIN_CONSOLE_PREFIX).into_response();
}
return not_found_json();
}
let Some(rest) = path.strip_prefix(ADMIN_CONSOLE_PREFIX).and_then(|r| {
if r.is_empty() {
Some("")
} else {
r.strip_prefix('/')
}
}) else {
return not_found_json();
};
if method != Method::GET && method != Method::HEAD {
return not_found_json();
}
if !rest.is_empty() {
if let Some(contents) = get_file(rest) {
let mime = mime_guess::from_path(rest).first_or_octet_stream();
return ([(header::CONTENT_TYPE, mime.as_ref())], contents).into_response();
}
if std::path::Path::new(rest).extension().is_some() {
return not_found_json();
}
}
spa_fallback_response(get_index_html())
}
fn not_found_json() -> Response {
(StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "not found"}))).into_response()
}
fn spa_fallback_response(contents: Option<&'static [u8]>) -> Response {
match contents {
Some(contents) => ([(header::CONTENT_TYPE, "text/html")], contents).into_response(),
None => (StatusCode::NOT_FOUND, "Web UI not embedded. Build webclientsrc first.")
.into_response(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ServerConfig;
use axum::http::Uri;
#[tokio::test]
async fn missing_file_fallback() {
let cfg = ServerConfig::default();
let state = Arc::new(ServerState::from_config(&cfg).await.unwrap());
let response = static_handler(
State(state.clone()),
Some(Path("nonexistent".to_string())),
Extension(ResolvedRealm(None)),
)
.await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let response = static_handler(
State(state),
Some(Path("missing.css".to_string())),
Extension(ResolvedRealm(None)),
)
.await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn static_file_served() {
let cfg = ServerConfig::default();
let state = Arc::new(ServerState::from_config(&cfg).await.unwrap());
let response = static_handler(
State(state),
Some(Path("index.html".to_string())),
Extension(ResolvedRealm(None)),
)
.await;
#[cfg(webclient_present)]
{
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap().to_str().unwrap();
assert_eq!(content_type, "text/html");
}
#[cfg(not(webclient_present))]
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn embedded_fonts_served_same_origin() {
let cfg = ServerConfig::default();
let state = Arc::new(ServerState::from_config(&cfg).await.unwrap());
for (path, mime) in [
("fonts/fonts.css", "text/css"),
("fonts/inter-latin.woff2", "font/woff2"),
] {
let response = static_handler(
State(state.clone()),
Some(Path(path.to_string())),
Extension(ResolvedRealm(None)),
)
.await;
#[cfg(webclient_present)]
{
assert_eq!(response.status(), StatusCode::OK, "path: {path}");
let content_type =
response.headers().get("content-type").unwrap().to_str().unwrap();
assert_eq!(content_type, mime, "path: {path}");
}
#[cfg(not(webclient_present))]
{
let _ = mime;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
}
}
#[tokio::test]
async fn account_api_typo_is_404_not_spa_shell() {
let cfg = ServerConfig::default();
let state = Arc::new(ServerState::from_config(&cfg).await.unwrap());
let response = static_handler(
State(state),
Some(Path("realms/master/account/api/bogus".to_string())),
Extension(ResolvedRealm(None)),
)
.await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn account_console_route_serves_account_html() {
let cfg = ServerConfig::default();
let state = Arc::new(ServerState::from_config(&cfg).await.unwrap());
let response = static_handler(
State(state),
Some(Path("realms/master/account/security".to_string())),
Extension(ResolvedRealm(None)),
)
.await;
#[cfg(webclient_present)]
assert_eq!(response.status(), StatusCode::OK);
#[cfg(not(webclient_present))]
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn admin_console_routes_serve_spa_shell() {
for uri in [
"/admin/console",
"/admin/console/",
"/admin/console/users/abc",
] {
let response =
admin_console_handler(Method::GET, OriginalUri(Uri::try_from(uri).unwrap())).await;
#[cfg(webclient_present)]
assert_eq!(response.status(), StatusCode::OK, "uri: {uri}");
#[cfg(not(webclient_present))]
assert_eq!(response.status(), StatusCode::NOT_FOUND, "uri: {uri}");
}
}
#[tokio::test]
async fn admin_console_rejects_api_typos_and_missing_files() {
let response = admin_console_handler(
Method::GET,
OriginalUri(Uri::from_static("/admin/bogus-api-path")),
)
.await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let response = admin_console_handler(
Method::GET,
OriginalUri(Uri::from_static("/admin/console/missing.css")),
)
.await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let response = admin_console_handler(
Method::POST,
OriginalUri(Uri::from_static("/admin/console/users")),
)
.await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn bare_admin_redirects_to_console() {
let response =
admin_console_handler(Method::GET, OriginalUri(Uri::from_static("/admin"))).await;
assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT);
assert_eq!(
response.headers().get("location").unwrap().to_str().unwrap(),
ADMIN_CONSOLE_PREFIX
);
}
#[test]
fn spa_fallback_response_with_contents() {
let response = spa_fallback_response(Some(b"<html></html>"));
assert_eq!(response.status(), StatusCode::OK);
}
#[test]
fn spa_fallback_response_without_contents() {
let response = spa_fallback_response(None);
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
}