mirror of
				https://github.com/scottlamb/moonfire-nvr.git
				synced 2025-10-29 15:55:01 -04:00 
			
		
		
		
	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.
This commit is contained in:
		
							parent
							
								
									02ac1a5570
								
							
						
					
					
						commit
						faba358925
					
				
							
								
								
									
										10
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										10
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							| @ -36,6 +36,9 @@ jobs: | |||||||
|         # The retry here is to work around "Unable to connect to azure.archive.ubuntu.com" errors. |         # The retry here is to work around "Unable to connect to azure.archive.ubuntu.com" errors. | ||||||
|         # https://github.com/actions/runner-images/issues/6894 |         # 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 |         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 |       - name: Install Rust | ||||||
|         uses: actions-rs/toolchain@v1 |         uses: actions-rs/toolchain@v1 | ||||||
|         with: |         with: | ||||||
| @ -43,8 +46,13 @@ jobs: | |||||||
|           toolchain: ${{ matrix.rust }} |           toolchain: ${{ matrix.rust }} | ||||||
|           override: true |           override: true | ||||||
|           components: ${{ matrix.extra_components }} |           components: ${{ matrix.extra_components }} | ||||||
|  |       - run: cd ui && npm ci | ||||||
|  |       - run: cd ui && npm run build | ||||||
|       - name: Test |       - 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' }} |         continue-on-error: ${{ matrix.rust == 'nightly' }} | ||||||
|       - name: Check formatting |       - name: Check formatting | ||||||
|         if: matrix.rust == 'stable' |         if: matrix.rust == 'stable' | ||||||
|  | |||||||
| @ -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 | *  fix [#289](https://github.com/scottlamb/moonfire-nvr/issues/289): crash on | ||||||
|    pressing the `Add` button in the sample file directory dialog |    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. | *  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) | ## 0.7.6 (2023-07-08) | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -48,6 +48,7 @@ FROM --platform=$BUILDPLATFORM dev AS build-server | |||||||
| LABEL maintainer="slamb@slamb.org" | LABEL maintainer="slamb@slamb.org" | ||||||
| ARG INVALIDATE_CACHE_BUILD_SERVER= | ARG INVALIDATE_CACHE_BUILD_SERVER= | ||||||
| COPY docker/build-server.bash / | 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 \ | 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=cache,id=cargo,target=/cargo-cache,sharing=locked,mode=777 \ | ||||||
|     --mount=type=bind,source=server,target=/var/lib/moonfire-nvr/src/server,readonly \ |     --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 /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-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 /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. | # The install instructions say to use --user in the docker run commandline. | ||||||
| # Specify a non-root user just in case someone forgets. | # Specify a non-root user just in case someone forgets. | ||||||
|  | |||||||
| @ -27,8 +27,8 @@ ln -s /cargo-cache/{git,registry} ~/.cargo | |||||||
| 
 | 
 | ||||||
| build_profile=release-lto | build_profile=release-lto | ||||||
| cd src/server | cd src/server | ||||||
| time cargo test | time cargo test --features=bundled-ui | ||||||
| time cargo build --profile=$build_profile | time cargo build --features=bundled-ui --profile=$build_profile | ||||||
| find /cargo-cache -ls > /docker-build-debug/build-server/cargo-cache-after | find /cargo-cache -ls > /docker-build-debug/build-server/cargo-cache-after | ||||||
| find ~/target -ls > /docker-build-debug/build-server/target-after | find ~/target -ls > /docker-build-debug/build-server/target-after | ||||||
| sudo install -m 755 \ | sudo install -m 755 \ | ||||||
|  | |||||||
							
								
								
									
										30
									
								
								server/Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										30
									
								
								server/Cargo.lock
									
									
									
										generated
									
									
									
								
							| @ -1101,6 +1101,7 @@ dependencies = [ | |||||||
|  "bytes", |  "bytes", | ||||||
|  "chrono", |  "chrono", | ||||||
|  "cursive", |  "cursive", | ||||||
|  |  "flate2", | ||||||
|  "fnv", |  "fnv", | ||||||
|  "futures", |  "futures", | ||||||
|  "h264-reader", |  "h264-reader", | ||||||
| @ -1143,6 +1144,7 @@ dependencies = [ | |||||||
|  "ulid", |  "ulid", | ||||||
|  "url", |  "url", | ||||||
|  "uuid", |  "uuid", | ||||||
|  |  "walkdir", | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
| [[package]] | [[package]] | ||||||
| @ -1736,6 +1738,15 @@ dependencies = [ | |||||||
|  "cipher", |  "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]] | [[package]] | ||||||
| name = "scrypt" | name = "scrypt" | ||||||
| version = "0.10.0" | version = "0.10.0" | ||||||
| @ -2372,6 +2383,16 @@ version = "0.9.4" | |||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" | 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]] | [[package]] | ||||||
| name = "want" | name = "want" | ||||||
| version = "0.3.0" | version = "0.3.0" | ||||||
| @ -2497,6 +2518,15 @@ version = "0.4.0" | |||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" | 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]] | [[package]] | ||||||
| name = "winapi-x86_64-pc-windows-gnu" | name = "winapi-x86_64-pc-windows-gnu" | ||||||
| version = "0.4.0" | version = "0.4.0" | ||||||
|  | |||||||
| @ -9,14 +9,15 @@ rust-version = "1.70" | |||||||
| publish = false | publish = false | ||||||
| 
 | 
 | ||||||
| [features] | [features] | ||||||
| 
 |  | ||||||
| # The nightly feature is used within moonfire-nvr itself to gate the | # The nightly feature is used within moonfire-nvr itself to gate the | ||||||
| # benchmarks. Also pass it along to crates that can benefit from it. | # benchmarks. Also pass it along to crates that can benefit from it. | ||||||
| nightly = ["db/nightly"] | nightly = ["db/nightly"] | ||||||
| 
 | 
 | ||||||
| # The bundled feature includes bundled (aka statically linked) versions of | # The bundled feature aims to make a single executable file that is deployable, | ||||||
| # native libraries where possible. | # including statically linked libraries and embedded UI files. | ||||||
| bundled = ["rusqlite/bundled"] | bundled = ["rusqlite/bundled", "bundled-ui"] | ||||||
|  | 
 | ||||||
|  | bundled-ui = [] | ||||||
| 
 | 
 | ||||||
| [workspace] | [workspace] | ||||||
| members = ["base", "db"] | members = ["base", "db"] | ||||||
| @ -71,6 +72,12 @@ tracing-log = "0.1.3" | |||||||
| ulid = "1.0.0" | ulid = "1.0.0" | ||||||
| url = "2.1.1" | url = "2.1.1" | ||||||
| uuid = { version = "1.1.2", features = ["serde", "std", "v4"] } | 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] | [dev-dependencies] | ||||||
| mp4 = { git = "https://github.com/scottlamb/mp4-rust", branch = "moonfire" } | mp4 = { git = "https://github.com/scottlamb/mp4-rust", branch = "moonfire" } | ||||||
|  | |||||||
							
								
								
									
										149
									
								
								server/build.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										149
									
								
								server/build.rs
									
									
									
									
									
										Normal file
									
								
							| @ -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<String, File>; | ||||||
|  | 
 | ||||||
|  | fn stringify_files(files: &FileMap) -> Result<String, std::fmt::Error> { | ||||||
|  |     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; | ||||||
|  | } | ||||||
							
								
								
									
										202
									
								
								server/src/bundled_ui.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										202
									
								
								server/src/bundled_ui.rs
									
									
									
									
									
										Normal file
									
								
							| @ -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<File>, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | 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<HeaderValue>, | ||||||
|  |         cache_control: &'static str, | ||||||
|  |         content_type: &'static str, | ||||||
|  |     ) -> Option<Entity> { | ||||||
|  |         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<Ui> = 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<u64>, | ||||||
|  |     ) -> Box<dyn futures::Stream<Item = Result<Self::Data, Self::Error>> + 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<http::HeaderValue> { | ||||||
|  |         Some(http::HeaderValue::from_static(self.file.etag)) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     fn last_modified(&self) -> Option<std::time::SystemTime> { | ||||||
|  |         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"<!doctype html")); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @ -14,9 +14,6 @@ use crate::json::Permissions; | |||||||
| fn default_db_dir() -> PathBuf { | fn default_db_dir() -> PathBuf { | ||||||
|     crate::DEFAULT_DB_DIR.into() |     crate::DEFAULT_DB_DIR.into() | ||||||
| } | } | ||||||
| fn default_ui_dir() -> PathBuf { |  | ||||||
|     "/usr/local/lib/moonfire-nvr/ui".into() |  | ||||||
| } |  | ||||||
| 
 | 
 | ||||||
| /// Top-level configuration file object.
 | /// Top-level configuration file object.
 | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| @ -32,8 +29,9 @@ pub struct ConfigFile { | |||||||
|     pub db_dir: PathBuf, |     pub db_dir: PathBuf, | ||||||
| 
 | 
 | ||||||
|     /// Directory holding user interface files (`.html`, `.js`, etc).
 |     /// Directory holding user interface files (`.html`, `.js`, etc).
 | ||||||
|     #[serde(default = "default_ui_dir")] |     #[cfg_attr(not(feature = "bundled-ui"), serde(default))] | ||||||
|     pub ui_dir: PathBuf, |     #[cfg_attr(feature = "bundled-ui", serde(default))] | ||||||
|  |     pub ui_dir: UiDir, | ||||||
| 
 | 
 | ||||||
|     /// The number of worker threads used by the asynchronous runtime.
 |     /// The number of worker threads used by the asynchronous runtime.
 | ||||||
|     ///
 |     ///
 | ||||||
| @ -42,6 +40,34 @@ pub struct ConfigFile { | |||||||
|     pub worker_threads: Option<usize>, |     pub worker_threads: Option<usize>, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | #[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.
 | /// Per-bind configuration.
 | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| #[serde(deny_unknown_fields)] | #[serde(deny_unknown_fields)] | ||||||
|  | |||||||
| @ -24,7 +24,7 @@ use tracing::{info, warn}; | |||||||
| 
 | 
 | ||||||
| use self::config::ConfigFile; | use self::config::ConfigFile; | ||||||
| 
 | 
 | ||||||
| mod config; | pub mod config; | ||||||
| 
 | 
 | ||||||
| /// Runs the server, saving recordings and allowing web access.
 | /// Runs the server, saving recordings and allowing web access.
 | ||||||
| #[derive(Bpaf, Debug)] | #[derive(Bpaf, Debug)] | ||||||
|  | |||||||
| @ -20,6 +20,9 @@ mod stream; | |||||||
| mod streamer; | mod streamer; | ||||||
| mod web; | mod web; | ||||||
| 
 | 
 | ||||||
|  | #[cfg(feature = "bundled-ui")] | ||||||
|  | mod bundled_ui; | ||||||
|  | 
 | ||||||
| const DEFAULT_DB_DIR: &str = "/var/lib/moonfire-nvr/db"; | const DEFAULT_DB_DIR: &str = "/var/lib/moonfire-nvr/db"; | ||||||
| 
 | 
 | ||||||
| /// Moonfire NVR: security camera network video recorder.
 | /// Moonfire NVR: security camera network video recorder.
 | ||||||
|  | |||||||
| @ -17,6 +17,7 @@ use self::path::Path; | |||||||
| use crate::body::Body; | use crate::body::Body; | ||||||
| use crate::json; | use crate::json; | ||||||
| use crate::mp4; | use crate::mp4; | ||||||
|  | use crate::web::static_file::Ui; | ||||||
| use base::err; | use base::err; | ||||||
| use base::Error; | use base::Error; | ||||||
| use base::ResultExt; | use base::ResultExt; | ||||||
| @ -28,7 +29,6 @@ use db::{auth, recording}; | |||||||
| use fnv::FnvHashMap; | use fnv::FnvHashMap; | ||||||
| use http::header::{self, HeaderValue}; | use http::header::{self, HeaderValue}; | ||||||
| use http::{status::StatusCode, Request, Response}; | use http::{status::StatusCode, Request, Response}; | ||||||
| use http_serve::dir::FsDir; |  | ||||||
| use hyper::body::Bytes; | use hyper::body::Bytes; | ||||||
| use std::net::IpAddr; | use std::net::IpAddr; | ||||||
| use std::sync::Arc; | 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 struct Config<'a> { | ||||||
|     pub db: Arc<db::Database>, |     pub db: Arc<db::Database>, | ||||||
|     pub ui_dir: Option<&'a std::path::Path>, |     pub ui_dir: Option<&'a crate::cmds::run::config::UiDir>, | ||||||
|     pub trust_forward_hdrs: bool, |     pub trust_forward_hdrs: bool, | ||||||
|     pub time_zone_name: String, |     pub time_zone_name: String, | ||||||
|     pub allow_unauthenticated_permissions: Option<db::Permissions>, |     pub allow_unauthenticated_permissions: Option<db::Permissions>, | ||||||
| @ -171,7 +171,7 @@ pub struct Config<'a> { | |||||||
| 
 | 
 | ||||||
| pub struct Service { | pub struct Service { | ||||||
|     db: Arc<db::Database>, |     db: Arc<db::Database>, | ||||||
|     ui_dir: Option<Arc<FsDir>>, |     ui: Ui, | ||||||
|     dirs_by_stream_id: Arc<FnvHashMap<i32, Arc<SampleFileDir>>>, |     dirs_by_stream_id: Arc<FnvHashMap<i32, Arc<SampleFileDir>>>, | ||||||
|     time_zone_name: String, |     time_zone_name: String, | ||||||
|     allow_unauthenticated_permissions: Option<db::Permissions>, |     allow_unauthenticated_permissions: Option<db::Permissions>, | ||||||
| @ -195,19 +195,7 @@ enum CacheControl { | |||||||
| 
 | 
 | ||||||
| impl Service { | impl Service { | ||||||
|     pub fn new(config: Config) -> Result<Self, Error> { |     pub fn new(config: Config) -> Result<Self, Error> { | ||||||
|         let mut ui_dir = None; |         let ui_dir = config.ui_dir.map(Ui::from).unwrap_or(Ui::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 dirs_by_stream_id = { |         let dirs_by_stream_id = { | ||||||
|             let l = config.db.lock(); |             let l = config.db.lock(); | ||||||
|             let mut d = |             let mut d = | ||||||
| @ -225,7 +213,7 @@ impl Service { | |||||||
|         Ok(Service { |         Ok(Service { | ||||||
|             db: config.db, |             db: config.db, | ||||||
|             dirs_by_stream_id, |             dirs_by_stream_id, | ||||||
|             ui_dir, |             ui: ui_dir, | ||||||
|             allow_unauthenticated_permissions: config.allow_unauthenticated_permissions, |             allow_unauthenticated_permissions: config.allow_unauthenticated_permissions, | ||||||
|             trust_forward_hdrs: config.trust_forward_hdrs, |             trust_forward_hdrs: config.trust_forward_hdrs, | ||||||
|             time_zone_name: config.time_zone_name, |             time_zone_name: config.time_zone_name, | ||||||
|  | |||||||
| @ -4,45 +4,104 @@ | |||||||
| 
 | 
 | ||||||
| //! Static file serving.
 | //! 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::{header, HeaderValue, Request}; | ||||||
|  | use http_serve::dir::FsDir; | ||||||
|  | use tracing::warn; | ||||||
|  | 
 | ||||||
|  | use crate::cmds::run::config::UiDir; | ||||||
| 
 | 
 | ||||||
| use super::{ResponseResult, Service}; | use super::{ResponseResult, Service}; | ||||||
| 
 | 
 | ||||||
|  | pub enum Ui { | ||||||
|  |     None, | ||||||
|  |     FromFilesystem(Arc<FsDir>), | ||||||
|  |     #[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<hyper::Body>, | ||||||
|  |         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 { | impl Service { | ||||||
|     /// Serves a static file if possible.
 |     /// Serves a static file if possible.
 | ||||||
|     pub(super) async fn static_file(&self, req: Request<hyper::Body>) -> ResponseResult { |     pub(super) async fn static_file(&self, req: Request<hyper::Body>) -> 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 { |         let Some(static_req) = StaticFileRequest::parse(req.uri().path()) else { | ||||||
|             bail!(NotFound, msg("static file not found")); |             bail!(NotFound, msg("static file not found")); | ||||||
|         }; |         }; | ||||||
|         let f = dir.get(static_req.path, req.headers()); |         let cache_control = if static_req.immutable { | ||||||
|         let node = f.await.map_err(|e| { |             // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#Caching_static_assets
 | ||||||
|             if e.kind() == std::io::ErrorKind::NotFound { |             "public, max-age=604800, immutable" | ||||||
|                 err!(NotFound, msg("no such static file")) |         } else { | ||||||
|             } else { |             "public" | ||||||
|                 Error::wrap(ErrorKind::Internal, e) |         }; | ||||||
|             } |         self.ui | ||||||
|         })?; |             .serve(static_req.path, &req, cache_control, static_req.mime) | ||||||
|         let mut hdrs = http::HeaderMap::new(); |             .await | ||||||
|         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)) |  | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user