mirror of
https://github.com/scottlamb/moonfire-nvr.git
synced 2025-02-06 03:08:07 -05:00
faba358925
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.
203 lines
5.6 KiB
Rust
203 lines
5.6 KiB
Rust
// 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"));
|
|
}
|
|
}
|