mirror of
https://github.com/scottlamb/moonfire-nvr.git
synced 2025-02-04 10:26:01 -05: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.
|
||||
# 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'
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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.
|
||||
|
@ -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 \
|
||||
|
30
server/Cargo.lock
generated
30
server/Cargo.lock
generated
@ -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"
|
||||
|
@ -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" }
|
||||
|
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 {
|
||||
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<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.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
|
@ -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)]
|
||||
|
@ -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.
|
||||
|
@ -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<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 time_zone_name: String,
|
||||
pub allow_unauthenticated_permissions: Option<db::Permissions>,
|
||||
@ -171,7 +171,7 @@ pub struct Config<'a> {
|
||||
|
||||
pub struct Service {
|
||||
db: Arc<db::Database>,
|
||||
ui_dir: Option<Arc<FsDir>>,
|
||||
ui: Ui,
|
||||
dirs_by_stream_id: Arc<FnvHashMap<i32, Arc<SampleFileDir>>>,
|
||||
time_zone_name: String,
|
||||
allow_unauthenticated_permissions: Option<db::Permissions>,
|
||||
@ -195,19 +195,7 @@ enum CacheControl {
|
||||
|
||||
impl Service {
|
||||
pub fn new(config: Config) -> Result<Self, Error> {
|
||||
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,
|
||||
|
@ -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<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 {
|
||||
/// Serves a static file if possible.
|
||||
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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user