From 1a42d5807552d0d74e863bb5d59e0ede47b3d2c5 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Sat, 1 Aug 2026 14:39:34 +0200 Subject: [PATCH] refactor: embed generated frontend asset directory --- .../trx-frontend/trx-frontend-http/build.rs | 46 +++ .../trx-frontend-http/src/api/assets.rs | 264 ++++-------------- .../trx-frontend-http/src/api/mod.rs | 26 +- .../trx-frontend-http/src/status.rs | 47 ++-- 4 files changed, 125 insertions(+), 258 deletions(-) diff --git a/src/trx-client/trx-frontend/trx-frontend-http/build.rs b/src/trx-client/trx-frontend/trx-frontend-http/build.rs index debef5d7..8158eaf5 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/build.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/build.rs @@ -2,8 +2,53 @@ // // SPDX-License-Identifier: GPL-2.0-or-later +use std::fmt::Write as _; +use std::fs; +use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; +fn generate_asset_manifest() { + let manifest_dir = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").expect("manifest dir")); + let generated_dir = manifest_dir.join("assets/web/generated"); + println!("cargo:rerun-if-changed={}", generated_dir.display()); + + let mut filenames = fs::read_dir(&generated_dir) + .expect("read generated frontend assets") + .map(|entry| entry.expect("read generated asset entry")) + .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_file())) + .map(|entry| { + entry + .file_name() + .into_string() + .expect("UTF-8 asset filename") + }) + .collect::>(); + filenames.sort(); + + let mut output = String::from( + "// Generated by build.rs from committed frontend output.\n\ + pub const GENERATED_ASSETS: &[(&str, &[u8])] = &[\n", + ); + for filename in filenames { + assert!( + filename + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte)), + "unsupported generated asset filename: {filename}" + ); + writeln!( + output, + " ({filename:?}, include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/assets/web/generated/\", {filename:?})))," + ) + .expect("write asset manifest"); + } + output.push_str("];\n"); + + let output_path = + PathBuf::from(std::env::var_os("OUT_DIR").expect("out dir")).join("generated_assets.rs"); + fs::write(output_path, output).expect("write generated asset manifest"); +} + fn utc_ymd_from_unix_secs(secs: i64) -> (i32, u32, u32) { let days = secs.div_euclid(86_400); let z = days + 719_468; @@ -20,6 +65,7 @@ fn utc_ymd_from_unix_secs(secs: i64) -> (i32, u32, u32) { } fn main() { + generate_asset_manifest(); let secs = match SystemTime::now().duration_since(UNIX_EPOCH) { Ok(d) => d.as_secs() as i64, Err(_) => 0, diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api/assets.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api/assets.rs index 63eb391f..7f5c1c7a 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/api/assets.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api/assets.rs @@ -7,6 +7,7 @@ use actix_web::http::header; use actix_web::web; use actix_web::{get, HttpRequest, HttpResponse, Responder}; +use std::collections::HashMap; use std::sync::OnceLock; use super::{gz_cache_entry, static_asset_response, GzCacheEntry, FAVICON_BYTES, LOGO_BYTES}; @@ -28,61 +29,18 @@ macro_rules! define_gz_cache { define_gz_cache!(gz_index_html, status::index_html(), "index.html"); define_gz_cache!(gz_style_css, status::STYLE_CSS, "style.css"); define_gz_cache!(gz_themes_css, status::THEMES_CSS, "themes.css"); -define_gz_cache!(gz_app_js, status::APP_JS, "app.js"); -define_gz_cache!(gz_ui_core_js, status::UI_CORE_JS, "ui-core.js"); -define_gz_cache!(gz_map_core_js, status::MAP_CORE_JS, "map-core.js"); -define_gz_cache!( - gz_plugin_loader_js, - status::PLUGIN_LOADER_JS, - "plugin-loader.js" -); -define_gz_cache!( - gz_plugin_runtime_js, - status::PLUGIN_RUNTIME_JS, - "plugin-runtime.js" -); -define_gz_cache!(gz_screenshot_js, status::SCREENSHOT_JS, "screenshot.js"); -define_gz_cache!( - gz_decode_history_worker_js, - status::DECODE_HISTORY_WORKER_JS, - "decode-history-worker.js" -); -define_gz_cache!( - gz_webgl_renderer_js, - status::WEBGL_RENDERER_JS, - "webgl-renderer.js" -); -define_gz_cache!( - gz_leaflet_ais_tracksymbol_js, - status::LEAFLET_AIS_TRACKSYMBOL_JS, - "leaflet-ais-tracksymbol.js" -); -define_gz_cache!(gz_ais_js, status::AIS_JS, "ais.js"); -define_gz_cache!(gz_vdes_js, status::VDES_JS, "vdes.js"); -define_gz_cache!(gz_aprs_js, status::APRS_JS, "aprs.js"); -define_gz_cache!(gz_hf_aprs_js, status::HF_APRS_JS, "hf-aprs.js"); -define_gz_cache!(gz_ft8_js, status::FT8_JS, "ft8.js"); -define_gz_cache!(gz_ft4_js, status::FT4_JS, "ft4.js"); -define_gz_cache!(gz_ft2_js, status::FT2_JS, "ft2.js"); -define_gz_cache!(gz_wspr_js, status::WSPR_JS, "wspr.js"); -define_gz_cache!(gz_cw_js, status::CW_JS, "cw.js"); -define_gz_cache!(gz_sat_js, status::SAT_JS, "sat.js"); -define_gz_cache!(gz_wefax_js, status::WEFAX_JS, "wefax.js"); -define_gz_cache!(gz_bookmarks_js, status::BOOKMARKS_JS, "bookmarks.js"); -define_gz_cache!(gz_scheduler_js, status::SCHEDULER_JS, "scheduler.js"); -define_gz_cache!( - gz_sat_scheduler_js, - status::SAT_SCHEDULER_JS, - "sat-scheduler.js" -); -define_gz_cache!( - gz_background_decode_js, - status::BACKGROUND_DECODE_JS, - "background-decode.js" -); -define_gz_cache!(gz_vchan_js, status::VCHAN_JS, "vchan.js"); define_gz_cache!(gz_bandplan_json, status::BANDPLAN_JSON, "bandplan.json"); +fn generated_asset_cache() -> &'static HashMap<&'static str, GzCacheEntry> { + static CACHE: OnceLock> = OnceLock::new(); + CACHE.get_or_init(|| { + status::GENERATED_ASSETS + .iter() + .map(|(name, bytes)| (*name, gz_cache_entry(bytes, name))) + .collect() + }) +} + // Vendored DSEG14 Classic font // (binary woff2 — served directly, not through gz_cache) @@ -181,128 +139,28 @@ pub(crate) async fn themes_css(req: HttpRequest) -> impl Responder { static_asset_response(&req, "text/css; charset=utf-8", c) } -// --------------------------------------------------------------------------- -// JavaScript assets -// --------------------------------------------------------------------------- - -#[get("/app.js")] -pub(crate) async fn app_js(req: HttpRequest) -> impl Responder { - let c = gz_app_js(); - static_asset_response(&req, "application/javascript; charset=utf-8", c) +// Generated filenames are supplied only by build.rs and resolved through this +// allowlist. Unknown names and unsupported MIME types never reach the file system. +fn generated_content_type(filename: &str) -> Option<&'static str> { + match filename.rsplit_once('.').map(|(_, extension)| extension) { + Some("js") => Some("application/javascript; charset=utf-8"), + Some("css") => Some("text/css; charset=utf-8"), + Some("json" | "map") => Some("application/json; charset=utf-8"), + Some("wasm") => Some("application/wasm"), + _ => None, + } } -#[get("/ui-core.js")] -pub(crate) async fn ui_core_js(req: HttpRequest) -> impl Responder { - let c = gz_ui_core_js(); - static_asset_response(&req, "application/javascript; charset=utf-8", c) -} - -#[get("/map-core.js")] -pub(crate) async fn map_core_js(req: HttpRequest) -> impl Responder { - let c = gz_map_core_js(); - static_asset_response(&req, "application/javascript; charset=utf-8", c) -} - -#[get("/plugin-loader.js")] -pub(crate) async fn plugin_loader_js(req: HttpRequest) -> impl Responder { - let c = gz_plugin_loader_js(); - static_asset_response(&req, "application/javascript; charset=utf-8", c) -} - -#[get("/plugin-runtime.js")] -pub(crate) async fn plugin_runtime_js(req: HttpRequest) -> impl Responder { - let c = gz_plugin_runtime_js(); - static_asset_response(&req, "application/javascript; charset=utf-8", c) -} - -#[get("/screenshot.js")] -pub(crate) async fn screenshot_js(req: HttpRequest) -> impl Responder { - let c = gz_screenshot_js(); - static_asset_response(&req, "application/javascript; charset=utf-8", c) -} - -#[get("/decode-history-worker.js")] -pub(crate) async fn decode_history_worker_js(req: HttpRequest) -> impl Responder { - let c = gz_decode_history_worker_js(); - static_asset_response(&req, "application/javascript; charset=utf-8", c) -} - -#[get("/webgl-renderer.js")] -pub(crate) async fn webgl_renderer_js(req: HttpRequest) -> impl Responder { - let c = gz_webgl_renderer_js(); - static_asset_response(&req, "application/javascript; charset=utf-8", c) -} - -#[get("/leaflet-ais-tracksymbol.js")] -pub(crate) async fn leaflet_ais_tracksymbol_js(req: HttpRequest) -> impl Responder { - let c = gz_leaflet_ais_tracksymbol_js(); - static_asset_response(&req, "application/javascript; charset=utf-8", c) -} - -#[get("/aprs.js")] -pub(crate) async fn aprs_js(req: HttpRequest) -> impl Responder { - let c = gz_aprs_js(); - static_asset_response(&req, "application/javascript; charset=utf-8", c) -} - -#[get("/hf-aprs.js")] -pub(crate) async fn hf_aprs_js(req: HttpRequest) -> impl Responder { - let c = gz_hf_aprs_js(); - static_asset_response(&req, "application/javascript; charset=utf-8", c) -} - -#[get("/ais.js")] -pub(crate) async fn ais_js(req: HttpRequest) -> impl Responder { - let c = gz_ais_js(); - static_asset_response(&req, "application/javascript; charset=utf-8", c) -} - -#[get("/vdes.js")] -pub(crate) async fn vdes_js(req: HttpRequest) -> impl Responder { - let c = gz_vdes_js(); - static_asset_response(&req, "application/javascript; charset=utf-8", c) -} - -#[get("/ft8.js")] -pub(crate) async fn ft8_js(req: HttpRequest) -> impl Responder { - let c = gz_ft8_js(); - static_asset_response(&req, "application/javascript; charset=utf-8", c) -} - -#[get("/ft4.js")] -pub(crate) async fn ft4_js(req: HttpRequest) -> impl Responder { - let c = gz_ft4_js(); - static_asset_response(&req, "application/javascript; charset=utf-8", c) -} - -#[get("/ft2.js")] -pub(crate) async fn ft2_js(req: HttpRequest) -> impl Responder { - let c = gz_ft2_js(); - static_asset_response(&req, "application/javascript; charset=utf-8", c) -} - -#[get("/wspr.js")] -pub(crate) async fn wspr_js(req: HttpRequest) -> impl Responder { - let c = gz_wspr_js(); - static_asset_response(&req, "application/javascript; charset=utf-8", c) -} - -#[get("/cw.js")] -pub(crate) async fn cw_js(req: HttpRequest) -> impl Responder { - let c = gz_cw_js(); - static_asset_response(&req, "application/javascript; charset=utf-8", c) -} - -#[get("/sat.js")] -pub(crate) async fn sat_js(req: HttpRequest) -> impl Responder { - let c = gz_sat_js(); - static_asset_response(&req, "application/javascript; charset=utf-8", c) -} - -#[get("/wefax.js")] -pub(crate) async fn wefax_js(req: HttpRequest) -> impl Responder { - let c = gz_wefax_js(); - static_asset_response(&req, "application/javascript; charset=utf-8", c) +#[get("/{filename}")] +pub(crate) async fn generated_asset(req: HttpRequest, path: web::Path) -> impl Responder { + let filename = path.into_inner(); + let Some(content_type) = generated_content_type(&filename) else { + return HttpResponse::NotFound().finish(); + }; + let Some(entry) = generated_asset_cache().get(filename.as_str()) else { + return HttpResponse::NotFound().finish(); + }; + static_asset_response(&req, content_type, entry) } #[get("/images/{filename}")] @@ -329,36 +187,6 @@ pub(crate) async fn wefax_image(path: web::Path) -> impl Responder { } } -#[get("/bookmarks.js")] -pub(crate) async fn bookmarks_js(req: HttpRequest) -> impl Responder { - let c = gz_bookmarks_js(); - static_asset_response(&req, "application/javascript; charset=utf-8", c) -} - -#[get("/scheduler.js")] -pub(crate) async fn scheduler_js(req: HttpRequest) -> impl Responder { - let c = gz_scheduler_js(); - static_asset_response(&req, "application/javascript; charset=utf-8", c) -} - -#[get("/sat-scheduler.js")] -pub(crate) async fn sat_scheduler_js(req: HttpRequest) -> impl Responder { - let c = gz_sat_scheduler_js(); - static_asset_response(&req, "application/javascript; charset=utf-8", c) -} - -#[get("/background-decode.js")] -pub(crate) async fn background_decode_js(req: HttpRequest) -> impl Responder { - let c = gz_background_decode_js(); - static_asset_response(&req, "application/javascript; charset=utf-8", c) -} - -#[get("/vchan.js")] -pub(crate) async fn vchan_js(req: HttpRequest) -> impl Responder { - let c = gz_vchan_js(); - static_asset_response(&req, "application/javascript; charset=utf-8", c) -} - #[get("/bandplan.json")] pub(crate) async fn bandplan_json(req: HttpRequest) -> impl Responder { let c = gz_bandplan_json(); @@ -438,3 +266,33 @@ pub(crate) async fn leaflet_layers_2x() -> impl Responder { .insert_header((header::CACHE_CONTROL, "public, max-age=604800, immutable")) .body(status::LEAFLET_LAYERS_2X) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generated_asset_manifest_is_an_explicit_safe_allowlist() { + assert!(!status::GENERATED_ASSETS.is_empty()); + for (name, bytes) in status::GENERATED_ASSETS { + assert!(!bytes.is_empty(), "generated asset {name} is empty"); + assert!(!name.contains('/') && !name.contains('\\') && !name.contains("..")); + assert!(generated_content_type(name).is_some()); + } + assert!(generated_asset_cache().contains_key("app.js")); + assert!(!generated_asset_cache().contains_key("../app.js")); + } + + #[test] + fn generated_asset_mime_types_are_restricted() { + assert_eq!( + generated_content_type("chunk.js"), + Some("application/javascript; charset=utf-8") + ); + assert_eq!( + generated_content_type("chunk.wasm"), + Some("application/wasm") + ); + assert_eq!(generated_content_type("secret.txt"), None); + } +} diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs index 033a2f0f..512719eb 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs @@ -642,32 +642,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) { .service(assets::logo) .service(assets::style_css) .service(assets::themes_css) - .service(assets::app_js) - .service(assets::ui_core_js) - .service(assets::map_core_js) - .service(assets::plugin_loader_js) - .service(assets::plugin_runtime_js) - .service(assets::screenshot_js) - .service(assets::decode_history_worker_js) - .service(assets::webgl_renderer_js) - .service(assets::leaflet_ais_tracksymbol_js) - .service(assets::ais_js) - .service(assets::vdes_js) - .service(assets::aprs_js) - .service(assets::hf_aprs_js) - .service(assets::ft8_js) - .service(assets::ft4_js) - .service(assets::ft2_js) - .service(assets::wspr_js) - .service(assets::cw_js) - .service(assets::sat_js) - .service(assets::wefax_js) .service(assets::wefax_image) - .service(assets::bookmarks_js) - .service(assets::scheduler_js) - .service(assets::sat_scheduler_js) - .service(assets::background_decode_js) - .service(assets::vchan_js) .service(assets::bandplan_json) // Vendored DSEG14 Classic font .service(assets::dseg14_classic_woff2) @@ -681,6 +656,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) { .service(assets::leaflet_marker_shadow) .service(assets::leaflet_layers) .service(assets::leaflet_layers_2x) + .service(assets::generated_asset) // Virtual channels .service(vchan::list_channels) .service(vchan::allocate_channel) diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/status.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/status.rs index ac53300d..4a61148d 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/status.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/status.rs @@ -11,35 +11,16 @@ const CLIENT_BUILD_DATE: &str = env!("TRX_CLIENT_BUILD_DATE"); const INDEX_HTML: &str = include_str!("../assets/web/index.html"); pub const STYLE_CSS: &str = include_str!("../assets/web/style.css"); pub const THEMES_CSS: &str = include_str!("../assets/web/themes.css"); -pub const APP_JS: &str = include_str!("../assets/web/generated/app.js"); -pub const UI_CORE_JS: &str = include_str!("../assets/web/generated/ui-core.js"); -pub const MAP_CORE_JS: &str = include_str!("../assets/web/generated/map-core.js"); -pub const PLUGIN_LOADER_JS: &str = include_str!("../assets/web/generated/plugin-loader.js"); -pub const PLUGIN_RUNTIME_JS: &str = include_str!("../assets/web/generated/plugin-runtime.js"); -pub const SCREENSHOT_JS: &str = include_str!("../assets/web/generated/screenshot.js"); -pub const DECODE_HISTORY_WORKER_JS: &str = - include_str!("../assets/web/generated/decode-history-worker.js"); -pub const WEBGL_RENDERER_JS: &str = include_str!("../assets/web/generated/webgl-renderer.js"); -pub const LEAFLET_AIS_TRACKSYMBOL_JS: &str = - include_str!("../assets/web/generated/leaflet-ais-tracksymbol.js"); -pub const AIS_JS: &str = include_str!("../assets/web/generated/ais.js"); -pub const VDES_JS: &str = include_str!("../assets/web/generated/vdes.js"); -pub const APRS_JS: &str = include_str!("../assets/web/generated/aprs.js"); -pub const HF_APRS_JS: &str = include_str!("../assets/web/generated/hf-aprs.js"); -pub const FT8_JS: &str = include_str!("../assets/web/generated/ft8.js"); -pub const FT4_JS: &str = include_str!("../assets/web/generated/ft4.js"); -pub const FT2_JS: &str = include_str!("../assets/web/generated/ft2.js"); -pub const WSPR_JS: &str = include_str!("../assets/web/generated/wspr.js"); -pub const CW_JS: &str = include_str!("../assets/web/generated/cw.js"); -pub const SAT_JS: &str = include_str!("../assets/web/generated/sat.js"); -pub const WEFAX_JS: &str = include_str!("../assets/web/generated/wefax.js"); -pub const BOOKMARKS_JS: &str = include_str!("../assets/web/generated/bookmarks.js"); -pub const SCHEDULER_JS: &str = include_str!("../assets/web/generated/scheduler.js"); -pub const SAT_SCHEDULER_JS: &str = include_str!("../assets/web/generated/sat-scheduler.js"); -pub const BACKGROUND_DECODE_JS: &str = include_str!("../assets/web/generated/background-decode.js"); -pub const VCHAN_JS: &str = include_str!("../assets/web/generated/vchan.js"); pub const BANDPLAN_JSON: &str = include_str!("../assets/web/bandplan.json"); +include!(concat!(env!("OUT_DIR"), "/generated_assets.rs")); + +pub fn generated_asset(name: &str) -> Option<&'static [u8]> { + GENERATED_ASSETS + .iter() + .find_map(|(candidate, bytes)| (*candidate == name).then_some(*bytes)) +} + // Vendored DSEG14 Classic font pub const DSEG14_CLASSIC_WOFF2: &[u8] = include_bytes!("../assets/web/vendor/dseg14-classic-latin-400-normal.woff2"); @@ -90,7 +71,9 @@ mod tests { ui_core < app, "UI primitives must load before application code" ); - assert!(UI_CORE_JS.contains("trxUi")); + assert!(generated_asset("ui-core.js").is_some_and(|asset| { + std::str::from_utf8(asset).is_ok_and(|script| script.contains("trxUi")) + })); let plugin_loader = html .find("/plugin-loader.js") .expect("typed plugin loader is loaded"); @@ -102,8 +85,12 @@ mod tests { plugin_runtime < plugin_loader, "plugin runtime must load before the lazy loader" ); - assert!(PLUGIN_RUNTIME_JS.contains("registerDecoder")); - assert!(PLUGIN_LOADER_JS.contains("import(path)")); + assert!(generated_asset("plugin-runtime.js").is_some_and(|asset| { + std::str::from_utf8(asset).is_ok_and(|script| script.contains("registerDecoder")) + })); + assert!(generated_asset("plugin-loader.js").is_some_and(|asset| { + std::str::from_utf8(asset).is_ok_and(|script| script.contains("import(path)")) + })); assert!(html.contains("Scheduler controls")); assert!(html.contains("Audio controls")); }