From d559da5775031cafcde03266beffda71869eaf71 Mon Sep 17 00:00:00 2001 From: Dave Kempe Date: Thu, 12 Mar 2026 21:10:30 +1100 Subject: [PATCH] Fix theme flash: server-side branding in HTML (#59) Replace default "rustguac" site title, h1 headings, and logo URL in HTML at startup so the first paint already shows configured branding. --- src/main.rs | 127 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 124 insertions(+), 3 deletions(-) diff --git a/src/main.rs b/src/main.rs index 8fba4e7..a179bc5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -605,6 +605,34 @@ async fn run_server(config: Config, database: Db) { }; let trusted_proxies = auth::TrustedProxies(config.trusted_proxies.clone()); + // Pre-process HTML pages with branding (site title, logo) for flash-free rendering + let branded_pages = { + let logo = config.theme.as_ref().and_then(|t| t.logo_url.as_deref()); + let title = &config.site_title; + let mut pages = std::collections::HashMap::new(); + // Embedded client.html + pages.insert( + "client.html".to_string(), + rewrite_branding(include_str!("../static/client.html"), title, logo), + ); + // Disk-served HTML pages + for name in &[ + "index.html", + "addressbook.html", + "sessions.html", + "recordings.html", + "admin.html", + "tokens.html", + "docs.html", + ] { + let path = std::path::Path::new(&static_path).join(name); + if let Ok(html) = std::fs::read_to_string(&path) { + pages.insert(name.to_string(), rewrite_branding(&html, title, logo)); + } + } + Arc::new(pages) + }; + // Periodically clean up expired auth sessions from the database let cleanup_db = database.clone(); tokio::spawn(async move { @@ -824,13 +852,25 @@ async fn run_server(config: Config, database: Db) { .route("/client/{session_id}", get(serve_client_page)) .with_state(manager); + // Branded HTML page routes (served from memory with site_title/logo baked in) + let html_routes = Router::new() + .route("/", get(serve_branded_page)) + .route("/index.html", get(serve_branded_page)) + .route("/addressbook.html", get(serve_branded_page)) + .route("/sessions.html", get(serve_branded_page)) + .route("/recordings.html", get(serve_branded_page)) + .route("/admin.html", get(serve_branded_page)) + .route("/tokens.html", get(serve_branded_page)) + .route("/docs.html", get(serve_branded_page)); + // Build full router (all Router<()> at this point) let mut app: Router<()> = Router::new() .route("/api/auth/status", get(api::auth_status)) .merge(api_routes) .merge(ws_route) .merge(connect_route) - .merge(unauth_routes); + .merge(unauth_routes) + .merge(html_routes); // Add OIDC routes if configured if let Some(ref oidc_st) = oidc_state { @@ -868,6 +908,7 @@ async fn run_server(config: Config, database: Db) { .layer(Extension(site_title)) .layer(Extension(theme_data)) .layer(Extension(trusted_proxies)) + .layer(Extension(branded_pages)) .fallback_service(ServeDir::new(&static_path)); let scheme = if server_tls.is_some() { @@ -948,8 +989,88 @@ fn build_guacd_tls(config: &Config) -> Option { Some(tokio_rustls::TlsConnector::from(Arc::new(tls_config))) } +/// Serve a branded HTML page from the pre-processed in-memory map. +async fn serve_branded_page( + Extension(pages): Extension>>, + request: axum::extract::Request, +) -> axum::response::Response { + use axum::http::StatusCode; + use axum::response::IntoResponse; + let path = request.uri().path().trim_start_matches('/'); + let key = if path.is_empty() { "index.html" } else { path }; + if let Some(html) = pages.get(key) { + Html(html.clone()).into_response() + } else { + StatusCode::NOT_FOUND.into_response() + } +} + /// Serve the client HTML page for SSH sessions. /// The session_id is extracted by the JS on the page, not by this handler. -async fn serve_client_page() -> Html<&'static str> { - Html(include_str!("../static/client.html")) +async fn serve_client_page( + Extension(pages): Extension>>, +) -> axum::response::Response { + use axum::response::IntoResponse; + if let Some(html) = pages.get("client.html") { + Html(html.clone()).into_response() + } else { + Html(include_str!("../static/client.html")).into_response() + } +} + +/// Rewrite branding in HTML: replace default "rustguac" site title and logo. +fn rewrite_branding(html: &str, site_title: &str, logo_url: Option<&str>) -> String { + let mut out = html.to_string(); + if site_title != "rustguac" { + // rustguac and rustguac - Sessions etc. + out = out.replace( + "rustguac", + &format!("{}", site_title), + ); + out = out.replace("rustguac - ", &format!("<title>{} - ", site_title)); + // <h1>rustguac</h1> (with or without inline style) + out = out.replace(">rustguac</h1>", &format!(">{}</h1>", site_title)); + } + if let Some(url) = logo_url { + out = out.replace("src=\"/logo.svg\"", &format!("src=\"{}\"", url)); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_rewrite_branding_title() { + let html = "<title>rustguac - Sessions"; + assert_eq!( + rewrite_branding(html, "MyGateway", None), + "MyGateway - Sessions" + ); + } + + #[test] + fn test_rewrite_branding_h1() { + let html = r#"

rustguac

"#; + assert_eq!( + rewrite_branding(html, "MyGateway", None), + r#"

MyGateway

"# + ); + } + + #[test] + fn test_rewrite_branding_logo() { + let html = r#""#; + assert_eq!( + rewrite_branding(html, "rustguac", Some("https://example.com/logo.png")), + r#""# + ); + } + + #[test] + fn test_rewrite_branding_noop() { + let html = "rustguac

rustguac

"; + assert_eq!(rewrite_branding(html, "rustguac", None), html); + } }