From 782e4c545e39175ff700a7c63bc4b071b025f0bf Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Sun, 3 May 2026 17:59:11 +0200 Subject: [PATCH] build(admin): vendor Tailwind + HTMX, drop CDN dependencies The dashboard pages (index.html, login.html) were fetching tailwindcss and htmx.org from cdn.tailwindcss.com and unpkg.com at runtime. That leaks browser request metadata to third parties, makes the dashboard inoperable on air-gapped deployments, and ties dashboard availability to two SaaS CDNs the operator doesn't control. Both files are now embedded in the hbbs binary (include_bytes!) and served from /admin/assets/{tailwindcss.js,htmx.min.js}. Versions pinned in source: Tailwind 3.4.16 (Play CDN JIT, the same JS the - + + diff --git a/src/api/admin/mod.rs b/src/api/admin/mod.rs index f0ec965..b0a0650 100644 --- a/src/api/admin/mod.rs +++ b/src/api/admin/mod.rs @@ -20,7 +20,7 @@ pub mod me; pub mod oidc_login; pub mod pages; -use axum::http::header; +use axum::http::{header, HeaderValue, StatusCode}; use axum::response::{Html, IntoResponse, Response}; use axum::routing::{get, post}; use axum::Router; @@ -31,6 +31,12 @@ use std::sync::Arc; const INDEX_HTML: &str = include_str!("../../../admin_ui/index.html"); const LOGIN_HTML: &str = include_str!("../../../admin_ui/login.html"); +/// Third-party JS dependencies vendored under `admin_ui/assets/` so the +/// dashboard doesn't fetch from cdn.tailwindcss.com / unpkg.com at runtime. +/// See docs/CONFIGURATION.md "Web client" for the upgrade procedure. +const TAILWIND_JS: &[u8] = include_bytes!("../../../admin_ui/assets/tailwindcss.js"); +const HTMX_JS: &[u8] = include_bytes!("../../../admin_ui/assets/htmx.min.js"); + pub fn build(state: Arc) -> Option { if state.cfg.admin_ui_dir.is_empty() { // Operator opted out by setting the flag to empty. @@ -42,6 +48,10 @@ pub fn build(state: Arc) -> Option { .route("/admin/", get(serve_index)) .route("/admin/index.html", get(serve_index)) .route("/admin/login.html", get(serve_login)) + // Vendored third-party JS — versions pinned in source, so we can + // cache aggressively (immutable + 1-year max-age). + .route("/admin/assets/tailwindcss.js", get(serve_tailwind)) + .route("/admin/assets/htmx.min.js", get(serve_htmx)) // Dynamic dashboard endpoints. .route("/admin/login", post(auth::login)) .route("/admin/logout", post(auth::logout)) @@ -183,7 +193,33 @@ fn html_response(body: &'static str) -> Response { let mut resp = Html(body).into_response(); resp.headers_mut().insert( header::CACHE_CONTROL, - axum::http::HeaderValue::from_static("no-cache"), + HeaderValue::from_static("no-cache"), + ); + resp +} + +async fn serve_tailwind() -> Response { + js_response(TAILWIND_JS) +} + +async fn serve_htmx() -> Response { + js_response(HTMX_JS) +} + +fn js_response(body: &'static [u8]) -> Response { + let mut resp = (StatusCode::OK, body).into_response(); + let h = resp.headers_mut(); + h.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/javascript; charset=utf-8"), + ); + // Vendored at a pinned version — safe to cache for a year. If we + // ever bump the version we should also bump the asset path so + // browsers don't keep stale copies; for now the path-pinned version + // is implicit in the binary build. + h.insert( + header::CACHE_CONTROL, + HeaderValue::from_static("public, max-age=31536000, immutable"), ); resp }