refactor: embed generated frontend asset directory

This commit is contained in:
sjg
2026-08-01 14:39:34 +02:00
parent 844de809a4
commit 1a42d58075
4 changed files with 125 additions and 258 deletions
@@ -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::<Vec<_>>();
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,
@@ -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<HashMap<&'static str, GzCacheEntry>> = 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<String>) -> 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<String>) -> 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);
}
}
@@ -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)
@@ -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("<summary>Scheduler controls</summary>"));
assert!(html.contains("<summary>Audio controls</summary>"));
}