From faba35892539e43019959dcde15d894ed8d9bde5 Mon Sep 17 00:00:00 2001 From: Scott Lamb Date: Fri, 4 Aug 2023 12:52:05 -0500 Subject: [PATCH] bundle UI files into the binary This is optional but now enabled for release builds. Why? * It shrinks the release docker images a bit, as the binary includes only the gzipped version of files and uncompressed into RAM at startup (which should be fast). * It's a step toward #160. --- .github/workflows/ci.yml | 10 +- CHANGELOG.md | 3 +- docker/Dockerfile | 2 +- docker/build-server.bash | 4 +- server/Cargo.lock | 30 +++++ server/Cargo.toml | 15 ++- server/build.rs | 149 +++++++++++++++++++++++++ server/src/bundled_ui.rs | 202 ++++++++++++++++++++++++++++++++++ server/src/cmds/run/config.rs | 36 +++++- server/src/cmds/run/mod.rs | 2 +- server/src/main.rs | 3 + server/src/web/mod.rs | 22 +--- server/src/web/static_file.rs | 117 +++++++++++++++----- 13 files changed, 534 insertions(+), 61 deletions(-) create mode 100644 server/build.rs create mode 100644 server/src/bundled_ui.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a605043..6a6e8ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,9 @@ jobs: # The retry here is to work around "Unable to connect to azure.archive.ubuntu.com" errors. # https://github.com/actions/runner-images/issues/6894 run: sudo apt-get --option=APT::Acquire::Retries=3 update && sudo apt-get --option=APT::Acquire::Retries=3 install libncurses-dev libsqlite3-dev pkgconf + - uses: actions/setup-node@v2 + with: + node-version: 18 - name: Install Rust uses: actions-rs/toolchain@v1 with: @@ -43,8 +46,13 @@ jobs: toolchain: ${{ matrix.rust }} override: true components: ${{ matrix.extra_components }} + - run: cd ui && npm ci + - run: cd ui && npm run build - name: Test - run: cd server && cargo test ${{ matrix.extra_args }} --all + run: | + cd server + cargo test ${{ matrix.extra_args }} --all + cargo test --features=bundled-ui ${{ matrix.extra_args }} --all continue-on-error: ${{ matrix.rust == 'nightly' }} - name: Check formatting if: matrix.rust == 'stable' diff --git a/CHANGELOG.md b/CHANGELOG.md index a61d830..38f15d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,8 @@ even on minor releases, e.g. `0.7.5` -> `0.7.6`. * fix [#289](https://github.com/scottlamb/moonfire-nvr/issues/289): crash on pressing the `Add` button in the sample file directory dialog * log to `stderr` again, fixing a regression with the `tracing` change in 0.7.6. - +* experimental (off by default) support for bundling UI files into the + executable. ## 0.7.6 (2023-07-08) diff --git a/docker/Dockerfile b/docker/Dockerfile index a2ab7d7..dc1aacd 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -48,6 +48,7 @@ FROM --platform=$BUILDPLATFORM dev AS build-server LABEL maintainer="slamb@slamb.org" ARG INVALIDATE_CACHE_BUILD_SERVER= COPY docker/build-server.bash / +COPY --from=build-ui /var/lib/moonfire-nvr/src/ui/build /var/lib/moonfire-nvr/src/ui/build RUN --mount=type=cache,id=target,target=/var/lib/moonfire-nvr/target,sharing=locked,mode=777 \ --mount=type=cache,id=cargo,target=/cargo-cache,sharing=locked,mode=777 \ --mount=type=bind,source=server,target=/var/lib/moonfire-nvr/src/server,readonly \ @@ -66,7 +67,6 @@ COPY --from=dev /docker-build-debug/dev/ /docker-build-debug/dev/ COPY --from=build-server /docker-build-debug/build-server/ /docker-build-debug/build-server/ COPY --from=build-server /usr/local/bin/moonfire-nvr /usr/local/bin/moonfire-nvr COPY --from=build-ui /docker-build-debug/build-ui /docker-build-debug/build-ui -COPY --from=build-ui /var/lib/moonfire-nvr/src/ui/build /usr/local/lib/moonfire-nvr/ui # The install instructions say to use --user in the docker run commandline. # Specify a non-root user just in case someone forgets. diff --git a/docker/build-server.bash b/docker/build-server.bash index 0a18136..0081c15 100755 --- a/docker/build-server.bash +++ b/docker/build-server.bash @@ -27,8 +27,8 @@ ln -s /cargo-cache/{git,registry} ~/.cargo build_profile=release-lto cd src/server -time cargo test -time cargo build --profile=$build_profile +time cargo test --features=bundled-ui +time cargo build --features=bundled-ui --profile=$build_profile find /cargo-cache -ls > /docker-build-debug/build-server/cargo-cache-after find ~/target -ls > /docker-build-debug/build-server/target-after sudo install -m 755 \ diff --git a/server/Cargo.lock b/server/Cargo.lock index 032eaa9..4832f8a 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -1101,6 +1101,7 @@ dependencies = [ "bytes", "chrono", "cursive", + "flate2", "fnv", "futures", "h264-reader", @@ -1143,6 +1144,7 @@ dependencies = [ "ulid", "url", "uuid", + "walkdir", ] [[package]] @@ -1736,6 +1738,15 @@ dependencies = [ "cipher", ] +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scrypt" version = "0.10.0" @@ -2372,6 +2383,16 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "walkdir" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36df944cda56c7d8d8b7496af378e6b16de9284591917d307c9b4d313c44e698" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.0" @@ -2497,6 +2518,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/server/Cargo.toml b/server/Cargo.toml index 6682dd9..cb639a9 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -9,14 +9,15 @@ rust-version = "1.70" publish = false [features] - # The nightly feature is used within moonfire-nvr itself to gate the # benchmarks. Also pass it along to crates that can benefit from it. nightly = ["db/nightly"] -# The bundled feature includes bundled (aka statically linked) versions of -# native libraries where possible. -bundled = ["rusqlite/bundled"] +# The bundled feature aims to make a single executable file that is deployable, +# including statically linked libraries and embedded UI files. +bundled = ["rusqlite/bundled", "bundled-ui"] + +bundled-ui = [] [workspace] members = ["base", "db"] @@ -71,6 +72,12 @@ tracing-log = "0.1.3" ulid = "1.0.0" url = "2.1.1" uuid = { version = "1.1.2", features = ["serde", "std", "v4"] } +flate2 = "1.0.26" + +[build-dependencies] +blake3 = "1.0.0" +fnv = "1.0" +walkdir = "2.3.3" [dev-dependencies] mp4 = { git = "https://github.com/scottlamb/mp4-rust", branch = "moonfire" } diff --git a/server/build.rs b/server/build.rs new file mode 100644 index 0000000..9073d77 --- /dev/null +++ b/server/build.rs @@ -0,0 +1,149 @@ +// This file is part of Moonfire NVR, a security camera network video recorder. +// Copyright (C) 2023 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. +// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception + +//! Build script to bundle UI files if `bundled-ui` Cargo feature is selected. + +use std::fmt::Write; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +const UI_DIR: &str = "../ui/build"; + +fn ensure_link(original: &Path, link: &Path) { + match std::fs::read_link(link) { + Ok(dst) if dst == original => return, + Err(e) if e.kind() != std::io::ErrorKind::NotFound => { + panic!("couldn't create link {link:?} to original path {original:?}: {e}") + } + _ => {} + } + std::os::unix::fs::symlink(original, link).expect("symlink creation should succeed"); +} + +struct File { + /// Path with `ui_files/` prefix and the encoding suffix; suitable for + /// passing to `include_bytes!` in the expanded code. + /// + /// E.g. `ui_files/index.html.gz`. + include_path: String, + encoding: FileEncoding, + etag: blake3::Hash, +} + +#[derive(Copy, Clone)] +enum FileEncoding { + Uncompressed, + Gzipped, +} + +impl FileEncoding { + fn to_str(self) -> &'static str { + match self { + Self::Uncompressed => "FileEncoding::Uncompressed", + Self::Gzipped => "FileEncoding::Gzipped", + } + } +} + +/// Map of "bare path" to the best representation. +/// +/// A "bare path" has no prefix for the root and no suffix for encoding, e.g. +/// `favicons/blah.ico` rather than `../../ui/build/favicons/blah.ico.gz`. +/// +/// The best representation is gzipped if available, uncompressed otherwise. +type FileMap = fnv::FnvHashMap; + +fn stringify_files(files: &FileMap) -> Result { + let mut buf = String::new(); + write!(buf, "const FILES: [BuildFile; {}] = [\n", files.len())?; + for (bare_path, file) in files { + let include_path = &file.include_path; + let etag = file.etag.to_hex(); + let encoding = file.encoding.to_str(); + write!(buf, " BuildFile {{ bare_path: {bare_path:?}, data: include_bytes!({include_path:?}), etag: {etag:?}, encoding: {encoding} }},\n")?; + } + write!(buf, "];\n")?; + Ok(buf) +} + +fn main() -> ExitCode { + // Explicitly declare dependencies, so this doesn't re-run if other source files change. + println!("cargo:rerun-if-changed=build.rs"); + + // Nothing to do if the feature is off. cargo will re-run if features change. + if !cfg!(feature = "bundled-ui") { + return ExitCode::SUCCESS; + } + + // If the feature is on, also re-run if the actual UI files change. + println!("cargo:rerun-if-changed={UI_DIR}"); + + let out_dir: PathBuf = std::env::var_os("OUT_DIR") + .expect("cargo should set OUT_DIR") + .into(); + + let abs_ui_dir = std::fs::canonicalize(UI_DIR) + .expect("ui dir should be accessible. Did you run `npm run build` first?"); + + let mut files = FileMap::default(); + for entry in walkdir::WalkDir::new(&abs_ui_dir) { + let entry = match entry { + Ok(e) => e, + Err(e) => { + eprintln!( + "walkdir failed. Did you run `npm run build` first?\n\n\ + caused by:\n{e}" + ); + return ExitCode::FAILURE; + } + }; + if !entry.file_type().is_file() { + continue; + } + + let path = entry + .path() + .strip_prefix(&abs_ui_dir) + .expect("walkdir should return root-prefixed entries"); + let path = path.to_str().expect("ui file paths should be valid UTF-8"); + let (bare_path, encoding); + match path.strip_suffix(".gz") { + Some(p) => { + bare_path = p; + encoding = FileEncoding::Gzipped; + } + None => { + bare_path = path; + encoding = FileEncoding::Uncompressed; + if files.get(bare_path).is_some() { + continue; // don't replace with suboptimal encoding. + } + } + } + + let contents = std::fs::read(entry.path()).expect("ui files should be readable"); + let etag = blake3::hash(&contents); + let include_path = format!("ui_files/{path}"); + files.insert( + bare_path.to_owned(), + File { + include_path, + encoding, + etag, + }, + ); + } + + let files = stringify_files(&files).expect("write to String should succeed"); + let mut out_rs_path = std::path::PathBuf::new(); + out_rs_path.push(&out_dir); + out_rs_path.push("ui_files.rs"); + std::fs::write(&out_rs_path, files).expect("writing ui_files.rs should succeed"); + + let mut out_link_path = std::path::PathBuf::new(); + out_link_path.push(&out_dir); + out_link_path.push("ui_files"); + ensure_link(&abs_ui_dir, &out_link_path); + return ExitCode::SUCCESS; +} diff --git a/server/src/bundled_ui.rs b/server/src/bundled_ui.rs new file mode 100644 index 0000000..7697fde --- /dev/null +++ b/server/src/bundled_ui.rs @@ -0,0 +1,202 @@ +// This file is part of Moonfire NVR, a security camera network video recorder. +// Copyright (C) 2023 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. +// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception. + +//! UI bundled (compiled/linked) into the executable for single-file deployment. + +use fnv::FnvHashMap; +use http::{header, HeaderMap, HeaderValue}; +use std::io::Read; +use std::sync::OnceLock; + +use crate::body::{BoxedError, Chunk}; + +pub struct Ui(FnvHashMap<&'static str, FileSet>); + +/// A file as passed in from `build.rs`. +struct BuildFile { + /// Path without any prefix (even `/`) for the root or any encoding suffix (`.gz`). + bare_path: &'static str, + data: &'static [u8], + etag: &'static str, + encoding: FileEncoding, +} + +#[derive(Copy, Clone)] +enum FileEncoding { + Uncompressed, + Gzipped, +} + +// `build.rs` fills in: `static FILES: [BuildFile; _] = [ ... ];` +include!(concat!(env!("OUT_DIR"), "/ui_files.rs")); + +/// A file, ready to serve. +struct File { + data: &'static [u8], + etag: &'static str, +} + +struct FileSet { + uncompressed: File, + gzipped: Option, +} + +impl Ui { + pub fn get() -> &'static Self { + UI.get_or_init(Self::init) + } + + #[tracing::instrument] + fn init() -> Self { + Ui(FILES + .iter() + .map(|f| { + let set = if matches!(f.encoding, FileEncoding::Gzipped) { + let mut uncompressed = Vec::new(); + let mut d = flate2::read::GzDecoder::new(f.data); + d.read_to_end(&mut uncompressed) + .expect("bundled gzip files should be valid"); + + // TODO: use String::leak in rust 1.72+. + let etag = format!("{}.ungzipped", f.etag); + let etag = etag.into_bytes().leak(); + let etag = + std::str::from_utf8(etag).expect("just-formatted str is valid utf-8"); + + FileSet { + uncompressed: File { + data: uncompressed.leak(), + etag, + }, + gzipped: Some(File { + data: f.data, + etag: f.etag, + }), + } + } else { + FileSet { + uncompressed: File { + data: f.data, + etag: f.etag, + }, + gzipped: None, + } + }; + (f.bare_path, set) + }) + .collect()) + } + + pub fn lookup( + &'static self, + path: &str, + hdrs: &HeaderMap, + cache_control: &'static str, + content_type: &'static str, + ) -> Option { + let Some(set) = self.0.get(path) else { + return None; + }; + let auto_gzip; + if let Some(ref gzipped) = set.gzipped { + auto_gzip = true; + if http_serve::should_gzip(hdrs) { + return Some(Entity { + file: &gzipped, + auto_gzip, + is_gzipped: true, + cache_control, + content_type, + }); + } + } else { + auto_gzip = false + }; + Some(Entity { + file: &set.uncompressed, + auto_gzip, + is_gzipped: false, + cache_control, + content_type, + }) + } +} + +static UI: OnceLock = OnceLock::new(); + +#[derive(Copy, Clone)] +pub struct Entity { + file: &'static File, + auto_gzip: bool, + is_gzipped: bool, + cache_control: &'static str, + content_type: &'static str, +} + +impl http_serve::Entity for Entity { + type Data = Chunk; + type Error = BoxedError; + + fn len(&self) -> u64 { + self.file + .data + .len() + .try_into() + .expect("usize should be convertible to u64") + } + + fn get_range( + &self, + range: std::ops::Range, + ) -> Box> + Send + Sync> { + let file = self.file; + Box::new(futures::stream::once(async move { + let r = usize::try_from(range.start)?..usize::try_from(range.end)?; + let Some(data) = file.data.get(r) else { + let len = file.data.len(); + return Err(format!("static file range {range:?} invalid (len {len:?})").into()); + }; + Ok(data.into()) + })) + } + + fn add_headers(&self, hdrs: &mut http::HeaderMap) { + if self.auto_gzip { + hdrs.insert(header::VARY, HeaderValue::from_static("accept-encoding")); + } + if self.is_gzipped { + hdrs.insert(header::CONTENT_ENCODING, HeaderValue::from_static("gzip")); + } + hdrs.insert( + header::CACHE_CONTROL, + HeaderValue::from_static(self.cache_control), + ); + hdrs.insert( + header::CONTENT_TYPE, + HeaderValue::from_static(self.content_type), + ); + } + + fn etag(&self) -> Option { + Some(http::HeaderValue::from_static(self.file.etag)) + } + + fn last_modified(&self) -> Option { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn index_html_uncompressed() { + let ui = Ui::get(); + let e = ui + .lookup("index.html", &HeaderMap::new(), "public", "text/html") + .unwrap(); + assert!(e.file.data.starts_with(b" PathBuf { crate::DEFAULT_DB_DIR.into() } -fn default_ui_dir() -> PathBuf { - "/usr/local/lib/moonfire-nvr/ui".into() -} /// Top-level configuration file object. #[derive(Debug, Deserialize)] @@ -32,8 +29,9 @@ pub struct ConfigFile { pub db_dir: PathBuf, /// Directory holding user interface files (`.html`, `.js`, etc). - #[serde(default = "default_ui_dir")] - pub ui_dir: PathBuf, + #[cfg_attr(not(feature = "bundled-ui"), serde(default))] + #[cfg_attr(feature = "bundled-ui", serde(default))] + pub ui_dir: UiDir, /// The number of worker threads used by the asynchronous runtime. /// @@ -42,6 +40,34 @@ pub struct ConfigFile { pub worker_threads: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", untagged)] +pub enum UiDir { + FromFilesystem(PathBuf), + Bundled(BundledUi), +} + +impl Default for UiDir { + #[cfg(feature = "bundled-ui")] + fn default() -> Self { + UiDir::Bundled(BundledUi { bundled: true }) + } + + #[cfg(not(feature = "bundled-ui"))] + fn default() -> Self { + UiDir::FromFilesystem("/usr/local/lib/moonfire-nvr/ui".into()) + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +#[serde(rename_all = "camelCase")] +pub struct BundledUi { + /// Just a marker to select this variant. + #[allow(unused)] + bundled: bool, +} + /// Per-bind configuration. #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] diff --git a/server/src/cmds/run/mod.rs b/server/src/cmds/run/mod.rs index 0b3cec5..f79a2f0 100644 --- a/server/src/cmds/run/mod.rs +++ b/server/src/cmds/run/mod.rs @@ -24,7 +24,7 @@ use tracing::{info, warn}; use self::config::ConfigFile; -mod config; +pub mod config; /// Runs the server, saving recordings and allowing web access. #[derive(Bpaf, Debug)] diff --git a/server/src/main.rs b/server/src/main.rs index f9bef12..2e222df 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -20,6 +20,9 @@ mod stream; mod streamer; mod web; +#[cfg(feature = "bundled-ui")] +mod bundled_ui; + const DEFAULT_DB_DIR: &str = "/var/lib/moonfire-nvr/db"; /// Moonfire NVR: security camera network video recorder. diff --git a/server/src/web/mod.rs b/server/src/web/mod.rs index b3f313a..713912d 100644 --- a/server/src/web/mod.rs +++ b/server/src/web/mod.rs @@ -17,6 +17,7 @@ use self::path::Path; use crate::body::Body; use crate::json; use crate::mp4; +use crate::web::static_file::Ui; use base::err; use base::Error; use base::ResultExt; @@ -28,7 +29,6 @@ use db::{auth, recording}; use fnv::FnvHashMap; use http::header::{self, HeaderValue}; use http::{status::StatusCode, Request, Response}; -use http_serve::dir::FsDir; use hyper::body::Bytes; use std::net::IpAddr; use std::sync::Arc; @@ -162,7 +162,7 @@ fn require_csrf_if_session(caller: &Caller, csrf: Option<&str>) -> Result<(), ba pub struct Config<'a> { pub db: Arc, - pub ui_dir: Option<&'a std::path::Path>, + pub ui_dir: Option<&'a crate::cmds::run::config::UiDir>, pub trust_forward_hdrs: bool, pub time_zone_name: String, pub allow_unauthenticated_permissions: Option, @@ -171,7 +171,7 @@ pub struct Config<'a> { pub struct Service { db: Arc, - ui_dir: Option>, + ui: Ui, dirs_by_stream_id: Arc>>, time_zone_name: String, allow_unauthenticated_permissions: Option, @@ -195,19 +195,7 @@ enum CacheControl { impl Service { pub fn new(config: Config) -> Result { - let mut ui_dir = None; - if let Some(d) = config.ui_dir { - match FsDir::builder().for_path(d) { - Err(e) => { - warn!( - "Unable to load ui dir {}; will serve no static files: {}", - d.display(), - e - ); - } - Ok(d) => ui_dir = Some(d), - }; - } + let ui_dir = config.ui_dir.map(Ui::from).unwrap_or(Ui::None); let dirs_by_stream_id = { let l = config.db.lock(); let mut d = @@ -225,7 +213,7 @@ impl Service { Ok(Service { db: config.db, dirs_by_stream_id, - ui_dir, + ui: ui_dir, allow_unauthenticated_permissions: config.allow_unauthenticated_permissions, trust_forward_hdrs: config.trust_forward_hdrs, time_zone_name: config.time_zone_name, diff --git a/server/src/web/static_file.rs b/server/src/web/static_file.rs index f0fcf68..8e1d75c 100644 --- a/server/src/web/static_file.rs +++ b/server/src/web/static_file.rs @@ -4,45 +4,104 @@ //! Static file serving. -use base::{bail, err, Error, ErrorKind, ResultExt}; +use std::sync::Arc; + +use base::{bail, err, ErrorKind, ResultExt}; use http::{header, HeaderValue, Request}; +use http_serve::dir::FsDir; +use tracing::warn; + +use crate::cmds::run::config::UiDir; use super::{ResponseResult, Service}; +pub enum Ui { + None, + FromFilesystem(Arc), + #[cfg(feature = "bundled-ui")] + Bundled(&'static crate::bundled_ui::Ui), +} + +impl Ui { + pub fn from(cfg: &UiDir) -> Self { + match cfg { + UiDir::FromFilesystem(d) => match FsDir::builder().for_path(d) { + Err(err) => { + warn!( + %err, + "unable to load ui dir {}; will serve no static files", + d.display(), + ); + Self::None + } + Ok(d) => Self::FromFilesystem(d), + }, + #[cfg(feature = "bundled-ui")] + UiDir::Bundled(_) => Self::Bundled(crate::bundled_ui::Ui::get()), + #[cfg(not(feature = "bundled-ui"))] + UiDir::Bundled(_) => { + warn!("server compiled without bundled ui; will serve not static files"); + Self::None + } + } + } + + async fn serve( + &self, + path: &str, + req: &Request, + cache_control: &'static str, + content_type: &'static str, + ) -> ResponseResult { + match self { + Ui::None => bail!( + NotFound, + msg("ui not configured or missing; no static files available") + ), + Ui::FromFilesystem(d) => { + let node = d.clone().get(path, req.headers()).await.map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + err!(NotFound, msg("static file not found")) + } else { + err!(Internal, source(e)) + } + })?; + let mut hdrs = http::HeaderMap::new(); + node.add_encoding_headers(&mut hdrs); + hdrs.insert( + header::CACHE_CONTROL, + HeaderValue::from_static(cache_control), + ); + hdrs.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); + let e = node.into_file_entity(hdrs).err_kind(ErrorKind::Internal)?; + Ok(http_serve::serve(e, &req)) + } + #[cfg(feature = "bundled-ui")] + Ui::Bundled(ui) => { + let Some(e) = ui.lookup(path, req.headers(), cache_control, content_type) else { + bail!(NotFound, msg("static file not found")); + }; + Ok(http_serve::serve(e, &req)) + } + } + } +} + impl Service { /// Serves a static file if possible. pub(super) async fn static_file(&self, req: Request) -> ResponseResult { - let Some(dir) = self.ui_dir.clone() else { - bail!(NotFound, msg("ui dir not configured or missing; no static files available")) - }; let Some(static_req) = StaticFileRequest::parse(req.uri().path()) else { bail!(NotFound, msg("static file not found")); }; - let f = dir.get(static_req.path, req.headers()); - let node = f.await.map_err(|e| { - if e.kind() == std::io::ErrorKind::NotFound { - err!(NotFound, msg("no such static file")) - } else { - Error::wrap(ErrorKind::Internal, e) - } - })?; - let mut hdrs = http::HeaderMap::new(); - node.add_encoding_headers(&mut hdrs); - hdrs.insert( - header::CACHE_CONTROL, - HeaderValue::from_static(if static_req.immutable { - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#Caching_static_assets - "public, max-age=604800, immutable" - } else { - "public" - }), - ); - hdrs.insert( - header::CONTENT_TYPE, - HeaderValue::from_static(static_req.mime), - ); - let e = node.into_file_entity(hdrs).err_kind(ErrorKind::Internal)?; - Ok(http_serve::serve(e, &req)) + let cache_control = if static_req.immutable { + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#Caching_static_assets + "public, max-age=604800, immutable" + } else { + "public" + }; + self.ui + .serve(static_req.path, &req, cache_control, static_req.mime) + .await } }