mirror of
https://github.com/scottlamb/moonfire-nvr.git
synced 2025-02-03 09:55:59 -05:00
overhaul HTTP serving and caching
* use content-hashed paths for static resources (except the top-level request), with immutable Cache-Control headers. This should improve cache behavior in both directions: avoid preventable HTTP requests and cause immediate refresh when needed. I had some staleness when browsing with my phone. * set up the favicons properly while I'm at it (closes #50). I used the convenient favicons-webpack-plugin to build everything from a .svg. I've hit an error similar to lovell/sharp#1593 at least once though so I might change my mind about that part if it continues to be problematic. * use http-serve's new directory traversal code for static file serving. This removes the odd behavior where files that weren't present at server startup couldn't be served. (I wasn't comfortable switching to the content-hashed paths before doing this.) It also means the static files can be served compressed. JSON API responses were already served compressed, so this closes #25. * for a given API URL, decide if we want it to be cached or not server-side. Stop using jQuery's kludgy cache-defeating _=<timestamp> URL parameter. I might start setting etags on some of these things and could serve 304 Not Modified responses if it's genuinely unmodified.
This commit is contained in:
parent
88fe6e5135
commit
45abeb22de
81
Cargo.lock
generated
81
Cargo.lock
generated
@ -112,12 +112,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base-x"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b20b618342cf9891c292c4f5ac2cde7287cc5c87e87e9c769d617793607dec1"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.9.3"
|
||||
@ -477,12 +471,6 @@ dependencies = [
|
||||
"winapi 0.3.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "discard"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "212d0f5754cb6769937f4501cc0e67f4f4483c8d2c3e1e922ee9edbe4ab4c7c0"
|
||||
|
||||
[[package]]
|
||||
name = "dtoa"
|
||||
version = "0.4.5"
|
||||
@ -854,9 +842,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "http-serve"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9e9efe3a2525d9bde418d52082845d4641c9acc426c3268b38dc14c0f5d77e3"
|
||||
version = "0.2.2"
|
||||
source = "git+https://github.com/scottlamb/http-serve?branch=dir#be4a4039b0bf70c951ee56e2d08d63d48dd5dbb3"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"flate2",
|
||||
@ -864,6 +851,8 @@ dependencies = [
|
||||
"http",
|
||||
"http-body",
|
||||
"httpdate",
|
||||
"libc",
|
||||
"memchr",
|
||||
"mime",
|
||||
"smallvec",
|
||||
"time 0.2.10",
|
||||
@ -2187,12 +2176,6 @@ dependencies = [
|
||||
"opaque-debug",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2579985fda508104f7587689507983eadd6a6e84dd35d6d115361f530916fa0d"
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.8.1"
|
||||
@ -2233,9 +2216,9 @@ checksum = "c111b5bd5695e56cffe5129854aa230b39c93a305372fdbb2668ca2394eea9f8"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.3.0"
|
||||
version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05720e22615919e4734f6a99ceae50d00226c3c5aca406e102ebc33298214e0a"
|
||||
checksum = "c7cb5678e1615754284ec264d9bb5b4c27d2018577fd90ac0ceb578591ed5ee4"
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
@ -2261,55 +2244,6 @@ version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f3eb36b47e512f8f1c9e3d10c2c1965bc992bd9cdb024fa581e2194501c83d3"
|
||||
|
||||
[[package]]
|
||||
name = "stdweb"
|
||||
version = "0.4.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d022496b16281348b52d0e30ae99e01a73d737b2f45d38fed4edf79f9325a1d5"
|
||||
dependencies = [
|
||||
"discard",
|
||||
"rustc_version",
|
||||
"stdweb-derive",
|
||||
"stdweb-internal-macros",
|
||||
"stdweb-internal-runtime",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stdweb-derive"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c87a60a40fccc84bef0652345bbbbbe20a605bf5d0ce81719fc476f5c03b50ef"
|
||||
dependencies = [
|
||||
"proc-macro2 1.0.10",
|
||||
"quote 1.0.3",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"syn 1.0.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stdweb-internal-macros"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "58fa5ff6ad0d98d1ffa8cb115892b6e69d67799f6763e162a1c9db421dc22e11"
|
||||
dependencies = [
|
||||
"base-x",
|
||||
"proc-macro2 1.0.10",
|
||||
"quote 1.0.3",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"syn 1.0.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stdweb-internal-runtime"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "213701ba3370744dcd1a12960caa4843b3d68b4d1c0a5d575e0d65b2ee9d16c0"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.8.0"
|
||||
@ -2492,12 +2426,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cacbd5ebf7b211db6d9500b8b033c20b6e333a68368a9e8d3a1d073bb1f0a12a"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"rustversion",
|
||||
"standback",
|
||||
"stdweb",
|
||||
"time-macros",
|
||||
"winapi 0.3.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -32,7 +32,7 @@ ffmpeg = { package = "moonfire-ffmpeg", path = "ffmpeg" }
|
||||
futures = "0.3"
|
||||
fnv = "1.0"
|
||||
http = "0.2.0"
|
||||
http-serve = "0.2.0"
|
||||
http-serve = { git = "https://github.com/scottlamb/http-serve", branch = "dir", features = ["dir"] }
|
||||
hyper = "0.13.0"
|
||||
lazy_static = "1.0"
|
||||
libc = "0.2"
|
||||
|
@ -1,5 +1,9 @@
|
||||
{
|
||||
"author": "Scott Lamb <slamb@slamb.org>",
|
||||
"author": {
|
||||
"name": "Scott Lamb",
|
||||
"email": "slamb@slamb.org",
|
||||
"url": "https://www.slamb.org/"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/scottlamb/moonfire-nvr/issues"
|
||||
},
|
||||
@ -16,6 +20,7 @@
|
||||
"homepage": "https://github.com/scottlamb/moonfire-nvr",
|
||||
"license": "GPL-3.0",
|
||||
"name": "moonfire-nvr",
|
||||
"description": "security camera network video recorder",
|
||||
"repository": "scottlamb/moonfire-nvr",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
@ -28,6 +33,7 @@
|
||||
"css-loader": "^3.4.2",
|
||||
"eslint": "^7.0.0",
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"favicons-webpack-plugin": "^3.0.1",
|
||||
"file-loader": "^6.0.0",
|
||||
"html-loader": "^1.1.0",
|
||||
"html-webpack-plugin": "^4.3.0",
|
||||
|
242
src/web.rs
242
src/web.rs
@ -42,21 +42,20 @@ use db::dir::SampleFileDir;
|
||||
use failure::{Error, bail, format_err};
|
||||
use fnv::FnvHashMap;
|
||||
use futures::sink::SinkExt;
|
||||
use futures::future::{self, Future, TryFutureExt};
|
||||
use futures::future::{self, Either, Future, TryFutureExt, err};
|
||||
use futures::stream::StreamExt;
|
||||
use http::{Request, Response, status::StatusCode};
|
||||
use http::header::{self, HeaderValue};
|
||||
use http_serve::dir::FsDir;
|
||||
use log::{debug, info, warn};
|
||||
use memchr::memchr;
|
||||
use nom::IResult;
|
||||
use nom::bytes::complete::{take_while1, tag};
|
||||
use nom::combinator::{all_consuming, map, map_res, opt};
|
||||
use nom::sequence::{preceded, tuple};
|
||||
use std::collections::HashMap;
|
||||
use std::cmp;
|
||||
use std::fs;
|
||||
use std::net::IpAddr;
|
||||
use std::ops::Range;
|
||||
use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use tokio_tungstenite::tungstenite;
|
||||
@ -238,15 +237,6 @@ impl FromStr for Segments {
|
||||
}
|
||||
}
|
||||
|
||||
/// A user interface file (.html, .js, etc).
|
||||
/// The list of files is loaded into the server at startup; this makes path canonicalization easy.
|
||||
/// The files themselves are opened on every request so they can be changed during development.
|
||||
#[derive(Debug)]
|
||||
struct UiFile {
|
||||
mime: HeaderValue,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
struct Caller {
|
||||
permissions: db::Permissions,
|
||||
session: Option<json::Session>,
|
||||
@ -257,8 +247,8 @@ impl Caller {
|
||||
|
||||
struct ServiceInner {
|
||||
db: Arc<db::Database>,
|
||||
ui_dir: Option<Arc<FsDir>>,
|
||||
dirs_by_stream_id: Arc<FnvHashMap<i32, Arc<SampleFileDir>>>,
|
||||
ui_files: HashMap<String, UiFile>,
|
||||
time_zone_name: String,
|
||||
allow_unauthenticated_permissions: Option<db::Permissions>,
|
||||
trust_forward_hdrs: bool,
|
||||
@ -524,15 +514,39 @@ impl ServiceInner {
|
||||
Ok(http_serve::serve(mp4, req))
|
||||
}
|
||||
|
||||
fn static_file(&self, req: &Request<::hyper::Body>, path: &str) -> ResponseResult {
|
||||
let s = self.ui_files.get(path).ok_or_else(|| not_found("no such static file"))?;
|
||||
let f = tokio::task::block_in_place(move || {
|
||||
fs::File::open(&s.path).map_err(internal_server_err)
|
||||
fn static_file(&self, req: Request<hyper::Body>)
|
||||
-> impl Future<Output = ResponseResult> + 'static {
|
||||
let dir = match self.ui_dir.clone() {
|
||||
None => {
|
||||
return Either::Left(
|
||||
err(not_found("--ui-dir not configured; no static files available.")))
|
||||
},
|
||||
Some(d) => d,
|
||||
};
|
||||
Either::Right(async move {
|
||||
let static_req = match StaticFileRequest::parse(req.uri().path()) {
|
||||
None => return Err(not_found("static file not found")),
|
||||
Some(r) => r,
|
||||
};
|
||||
let f = dir.get(static_req.path, req.headers());
|
||||
let node = f.await
|
||||
.map_err(|e| if e.kind() == std::io::ErrorKind::NotFound {
|
||||
not_found("no such static file")
|
||||
} else {
|
||||
internal_server_err(e)
|
||||
})?;
|
||||
let mut hdrs = http::HeaderMap::new();
|
||||
hdrs.insert(header::CONTENT_TYPE, s.mime.clone());
|
||||
let e = http_serve::ChunkedReadFile::new(f, hdrs).map_err(internal_server_err)?;
|
||||
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).map_err(internal_server_err)?;
|
||||
Ok(http_serve::serve(e, &req))
|
||||
})
|
||||
}
|
||||
|
||||
fn authreq(&self, req: &Request<::hyper::Body>) -> auth::Request {
|
||||
@ -580,7 +594,7 @@ impl ServiceInner {
|
||||
let authreq = self.authreq(req);
|
||||
let host = req.headers().get(header::HOST).ok_or_else(|| bad_req("missing Host header!"))?;
|
||||
let host = host.as_bytes();
|
||||
let domain = match ::memchr::memchr(b':', host) {
|
||||
let domain = match memchr(b':', host) {
|
||||
Some(colon) => &host[0..colon],
|
||||
None => host,
|
||||
}.to_owned();
|
||||
@ -804,13 +818,32 @@ pub struct Config<'a> {
|
||||
#[derive(Clone)]
|
||||
pub struct Service(Arc<ServiceInner>);
|
||||
|
||||
/// Useful HTTP `Cache-Control` values to set on successful (HTTP 200) API responses.
|
||||
enum CacheControl {
|
||||
/// For endpoints which have private data that may change from request to request.
|
||||
PrivateDynamic,
|
||||
|
||||
/// For endpoints which rarely change for a given URL.
|
||||
/// E.g., a fixed segment of video. The underlying video logically never changes; there may
|
||||
/// rarely be some software change to the actual bytes (which would result in a new etag) so
|
||||
/// (unlike the content-hashed static content) it's not entirely immutable.
|
||||
PrivateStatic,
|
||||
|
||||
None,
|
||||
}
|
||||
|
||||
impl Service {
|
||||
pub fn new(config: Config) -> Result<Self, Error> {
|
||||
let mut ui_files = HashMap::new();
|
||||
let mut ui_dir = None;
|
||||
if let Some(d) = config.ui_dir {
|
||||
Service::fill_ui_files(d, &mut ui_files);
|
||||
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),
|
||||
};
|
||||
}
|
||||
debug!("UI files: {:#?}", ui_files);
|
||||
let dirs_by_stream_id = {
|
||||
let l = config.db.lock();
|
||||
let mut d =
|
||||
@ -831,54 +864,13 @@ impl Service {
|
||||
Ok(Service(Arc::new(ServiceInner {
|
||||
db: config.db,
|
||||
dirs_by_stream_id,
|
||||
ui_files,
|
||||
ui_dir,
|
||||
allow_unauthenticated_permissions: config.allow_unauthenticated_permissions,
|
||||
trust_forward_hdrs: config.trust_forward_hdrs,
|
||||
time_zone_name: config.time_zone_name,
|
||||
})))
|
||||
}
|
||||
|
||||
fn fill_ui_files(dir: &std::path::Path, files: &mut HashMap<String, UiFile>) {
|
||||
let r = match fs::read_dir(dir) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
warn!("Unable to search --ui-dir={}; will serve no static files. Error was: {}",
|
||||
dir.display(), e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
for e in r {
|
||||
let e = match e {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
warn!("Error searching UI directory; may be missing files. Error was: {}", e);
|
||||
continue;
|
||||
},
|
||||
};
|
||||
let (p, mime) = match e.file_name().to_str() {
|
||||
Some(n) if n == "index.html" => ("/".to_owned(), "text/html"),
|
||||
Some(n) if n.ends_with(".html") => (format!("/{}", n), "text/html"),
|
||||
Some(n) if n.ends_with(".ico") => (format!("/{}", n), "image/vnd.microsoft.icon"),
|
||||
Some(n) if n.ends_with(".js") => (format!("/{}", n), "text/javascript"),
|
||||
Some(n) if n.ends_with(".map") => (format!("/{}", n), "text/javascript"),
|
||||
Some(n) if n.ends_with(".png") => (format!("/{}", n), "image/png"),
|
||||
Some(n) => {
|
||||
warn!("UI directory file {:?} has unknown extension; skipping", n);
|
||||
continue;
|
||||
},
|
||||
None => {
|
||||
warn!("UI directory file {:?} is not a valid UTF-8 string; skipping",
|
||||
e.file_name());
|
||||
continue;
|
||||
},
|
||||
};
|
||||
files.insert(p, UiFile {
|
||||
mime: HeaderValue::from_static(mime),
|
||||
path: e.path(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn stream_live_m4s(&self, req: Request<::hyper::Body>, caller: Caller, uuid: Uuid,
|
||||
stream_type: db::StreamType) -> ResponseResult {
|
||||
if !caller.permissions.view_video {
|
||||
@ -972,9 +964,9 @@ impl Service {
|
||||
let start = start.unwrap();
|
||||
use http_serve::Entity;
|
||||
let mp4 = builder.build(self.0.db.clone(), self.0.dirs_by_stream_id.clone())?;
|
||||
let mut hdrs = http::header::HeaderMap::new();
|
||||
let mut hdrs = header::HeaderMap::new();
|
||||
mp4.add_headers(&mut hdrs);
|
||||
let mime_type = hdrs.get(http::header::CONTENT_TYPE).unwrap();
|
||||
let mime_type = hdrs.get(header::CONTENT_TYPE).unwrap();
|
||||
let hdr = format!(
|
||||
"Content-Type: {}\r\n\
|
||||
X-Recording-Start: {}\r\n\
|
||||
@ -1011,19 +1003,27 @@ impl Service {
|
||||
}
|
||||
|
||||
pub fn serve(&mut self, req: Request<::hyper::Body>) -> BoxedFuture {
|
||||
fn wrap<R>(is_private: bool, r: R) -> BoxedFuture
|
||||
fn wrap<R>(cache_hdr: CacheControl, r: R) -> BoxedFuture
|
||||
where R: Future<Output = Result<Response<Body>, Response<Body>>> + Send + Sync + 'static {
|
||||
return Box::new(r.or_else(|e| futures::future::ok(e)).map_ok(move |mut r| {
|
||||
if is_private {
|
||||
r.headers_mut().insert("Cache-Control", HeaderValue::from_static("private"));
|
||||
match cache_hdr {
|
||||
CacheControl::PrivateStatic => {
|
||||
r.headers_mut().insert(header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("private, max-age=3600"));
|
||||
},
|
||||
CacheControl::PrivateDynamic => {
|
||||
r.headers_mut().insert(header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("private, no-cache"));
|
||||
},
|
||||
CacheControl::None => {},
|
||||
}
|
||||
r
|
||||
}))
|
||||
}
|
||||
|
||||
fn wrap_r(is_private: bool, r: ResponseResult)
|
||||
fn wrap_r(cache_hdr: CacheControl, r: ResponseResult)
|
||||
-> Box<dyn Future<Output = Result<Response<Body>, BoxedError>> + Send + Sync + 'static> {
|
||||
return wrap(is_private, future::ready(r))
|
||||
return wrap(cache_hdr, future::ready(r))
|
||||
}
|
||||
|
||||
let p = Path::decode(req.uri().path());
|
||||
@ -1037,39 +1037,85 @@ impl Service {
|
||||
Err(e) => return Box::new(future::ok(from_base_error(e))),
|
||||
};
|
||||
match p {
|
||||
Path::InitSegment(sha1, debug) => wrap_r(true, self.0.init_segment(sha1, debug, &req)),
|
||||
Path::TopLevel => wrap_r(true, self.0.top_level(&req, caller)),
|
||||
Path::Request => wrap_r(true, self.0.request(&req)),
|
||||
Path::Camera(uuid) => wrap_r(true, self.0.camera(&req, uuid)),
|
||||
Path::InitSegment(sha1, debug) => {
|
||||
wrap_r(CacheControl::PrivateStatic, self.0.init_segment(sha1, debug, &req))
|
||||
},
|
||||
Path::TopLevel => wrap_r(CacheControl::PrivateDynamic, self.0.top_level(&req, caller)),
|
||||
Path::Request => wrap_r(CacheControl::PrivateDynamic, self.0.request(&req)),
|
||||
Path::Camera(uuid) => wrap_r(CacheControl::PrivateDynamic, self.0.camera(&req, uuid)),
|
||||
Path::StreamRecordings(uuid, type_) => {
|
||||
wrap_r(true, self.0.stream_recordings(&req, uuid, type_))
|
||||
wrap_r(CacheControl::PrivateDynamic, self.0.stream_recordings(&req, uuid, type_))
|
||||
},
|
||||
Path::StreamViewMp4(uuid, type_, debug) => {
|
||||
wrap_r(true, self.0.stream_view_mp4(&req, caller, uuid, type_, mp4::Type::Normal,
|
||||
debug))
|
||||
wrap_r(CacheControl::PrivateStatic,
|
||||
self.0.stream_view_mp4(&req, caller, uuid, type_, mp4::Type::Normal, debug))
|
||||
},
|
||||
Path::StreamViewMp4Segment(uuid, type_, debug) => {
|
||||
wrap_r(true, self.0.stream_view_mp4(&req, caller, uuid, type_,
|
||||
mp4::Type::MediaSegment, debug))
|
||||
wrap_r(CacheControl::PrivateStatic,
|
||||
self.0.stream_view_mp4(&req, caller, uuid, type_, mp4::Type::MediaSegment,
|
||||
debug))
|
||||
},
|
||||
Path::StreamLiveMp4Segments(uuid, type_) => {
|
||||
wrap_r(true, self.stream_live_m4s(req, caller, uuid, type_))
|
||||
wrap_r(CacheControl::PrivateDynamic, self.stream_live_m4s(req, caller, uuid, type_))
|
||||
},
|
||||
Path::NotFound => wrap(true, future::err(not_found("path not understood"))),
|
||||
Path::Login => wrap(true, with_json_body(req).and_then({
|
||||
Path::NotFound => wrap(CacheControl::PrivateDynamic,
|
||||
future::err(not_found("path not understood"))),
|
||||
Path::Login => wrap(CacheControl::PrivateDynamic, with_json_body(req).and_then({
|
||||
let s = self.clone();
|
||||
move |(req, b)| future::ready(s.0.login(&req, b))
|
||||
})),
|
||||
Path::Logout => wrap(true, with_json_body(req).and_then({
|
||||
Path::Logout => wrap(CacheControl::PrivateDynamic, with_json_body(req).and_then({
|
||||
let s = self.clone();
|
||||
move |(req, b)| future::ready(s.0.logout(&req, b))
|
||||
})),
|
||||
Path::Signals => wrap(true, Pin::from(self.signals(req, caller))),
|
||||
Path::Static => wrap_r(false, self.0.static_file(&req, req.uri().path())),
|
||||
Path::Signals => wrap(CacheControl::PrivateDynamic,
|
||||
Pin::from(self.signals(req, caller))),
|
||||
Path::Static => wrap(CacheControl::None, self.0.static_file(req))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
struct StaticFileRequest<'a> {
|
||||
path: &'a str,
|
||||
immutable: bool,
|
||||
mime: &'static str,
|
||||
}
|
||||
|
||||
impl<'a> StaticFileRequest<'a> {
|
||||
fn parse(path: &'a str) -> Option<Self> {
|
||||
if !path.starts_with("/") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (path, immutable) = match &path[1..] {
|
||||
"" => ("index.html", false),
|
||||
p => (p, true),
|
||||
};
|
||||
|
||||
let last_dot = match path.rfind('.') {
|
||||
None => return None,
|
||||
Some(d) => d,
|
||||
};
|
||||
let ext = &path[last_dot+1..];
|
||||
let mime = match ext {
|
||||
"html" => "text/html",
|
||||
"ico" => "image/x-icon",
|
||||
"js" | "map" => "text/javascript",
|
||||
"json" => "application/json",
|
||||
"png" => "image/png",
|
||||
"webapp" => "application/x-web-app-manifest+json",
|
||||
_ => return None
|
||||
};
|
||||
|
||||
Some(StaticFileRequest {
|
||||
path,
|
||||
immutable,
|
||||
mime,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use db::testutil::{self, TestDb};
|
||||
@ -1077,7 +1123,7 @@ mod tests {
|
||||
use log::info;
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
use super::Segments;
|
||||
use super::{Segments, StaticFileRequest};
|
||||
|
||||
struct Server {
|
||||
db: TestDb<base::clock::RealClocks>,
|
||||
@ -1228,6 +1274,24 @@ mod tests {
|
||||
assert_eq!(Path::decode("/api/junk"), Path::NotFound);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_file() {
|
||||
testutil::init();
|
||||
let r = StaticFileRequest::parse("/jquery-ui.b6d3d46c828800e78499.js").unwrap();
|
||||
assert_eq!(r, StaticFileRequest {
|
||||
path: "jquery-ui.b6d3d46c828800e78499.js",
|
||||
mime: "text/javascript",
|
||||
immutable: true,
|
||||
});
|
||||
|
||||
let r = StaticFileRequest::parse("/").unwrap();
|
||||
assert_eq!(r, StaticFileRequest {
|
||||
path: "index.html",
|
||||
mime: "text/html",
|
||||
immutable: false,
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_segments() {
|
||||
testutil::init();
|
||||
|
@ -46,7 +46,7 @@ import 'jquery-ui/themes/base/tooltip.css';
|
||||
import 'jquery-ui/themes/base/theme.css';
|
||||
|
||||
// This causes our custom css to be loaded after the above!
|
||||
import './assets/index.css';
|
||||
import './index.css';
|
||||
|
||||
// Get ui widgets themselves
|
||||
import 'jquery-ui/ui/widgets/tooltip';
|
||||
|
@ -1,106 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<!-- vim: set et: -->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Moonfire NVR</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
<body>
|
||||
<div id="top">
|
||||
<a id="toggle-nav">☰</a>
|
||||
<span id="session"></div>
|
||||
</div>
|
||||
<div id="nav">
|
||||
<form action="#">
|
||||
<fieldset>
|
||||
<legend>Streams</legend>
|
||||
<table id="streams"></table>
|
||||
</fieldset>
|
||||
<fieldset id="datetime">
|
||||
<legend>Date & Time Range</legend>
|
||||
<div id="from">
|
||||
<div id="start-date"></div>
|
||||
<label for="start-time">Time:</label>
|
||||
<input id="start-time" name="start-time" type="text"
|
||||
max-length="20"
|
||||
title="Starting time within the day. Blank for the beginning of
|
||||
the day. Otherwise HH:mm[:ss[:FFFFF]][+-HH:mm], where F is
|
||||
90,000ths of a second. Timezone is normally left out; it's useful
|
||||
once a year during the ambiguous times of the "fall
|
||||
back" hour.">
|
||||
</div>
|
||||
<div id="range">Range:
|
||||
<input type="radio" name="end-date-type" id="end-date-same" checked>
|
||||
<label for="end-date-same">Single Day</label>
|
||||
<input type="radio" name="end-date-type" id="end-date-other">
|
||||
<label for="end-date-other">Multi Day</label><br/>
|
||||
</div>
|
||||
<div id="to">
|
||||
<div id="end-date"></div>
|
||||
<label for="end-time">Time:</label>
|
||||
<input id="end-time" name="end-time" type="text" max-length="20"
|
||||
title="Ending time within the day. Blank for the end of the day.
|
||||
Otherwise HH:mm[:ss[:FFFFF]][+-HH:mm], where F is 90,000ths of a
|
||||
second. Timezone is normally left out; it's useful once a year
|
||||
during the ambiguous times of the "fall back" hour.">
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Recordings Display</legend>
|
||||
<label for="split">Max Video Duration:</label>
|
||||
<select name="split" id="split">
|
||||
<option value="324000000">1 hour</option>
|
||||
<option value="1296000000">4 hours</option>
|
||||
<option value="7776000000">24 hours</option>
|
||||
<option value="infinite">infinite</option>
|
||||
</select><br>
|
||||
<input type="checkbox" checked id="trim" name="trim">
|
||||
<label for="trim">Trim Segment Start & End</label><br>
|
||||
<input type="checkbox" checked id="ts" name="ts">
|
||||
<label for="ts">Timestamp Track</label><br>
|
||||
<label for="timefmt">Time Format:</label>
|
||||
<select name="timefmt" id="timefmt">
|
||||
<option value="MM/DD/YY hh:mm A">US-short</option>
|
||||
<option value="MM/DD/YYYY hh:mm:ss A">US</option>
|
||||
<option value="MM/DD/YY HH:mm" selected>Military-short</option>
|
||||
<option value="MM/DD/YYYY HH:mm:ss">Military</option>
|
||||
<option value="DD.MM.YY HH:mm">EU-short</option>
|
||||
<option value="DD-MM-YYYY HH:mm:ss">EU</option>
|
||||
<option value="YY-MM-DD hh:mm A">ISO-short (12h)</option>
|
||||
<option value="YY-MM-DD HH:mm">ISO-short (24h)</option>
|
||||
<option value="YYYY-MM-DD hh:mm:ss A">ISO (12h)</option>
|
||||
<option value="YYYY-MM-DD HH:mm:ss">ISO (24h)</option>
|
||||
<option value="YYYY-MM-DD HH:mm:ss">ISO 8601-like (No TZ)</option>
|
||||
<option value="YYYY-MM-DDTHH:mm:ss">ISO 8601 (No TZ)</option>
|
||||
<option value="YYYY-MM-DDTHH:mm:ssZ">ISO 8601</option>
|
||||
<option value="YYYY-MM-DDTHH:mm:ss:FFFFFZ">Internal</option>
|
||||
</select>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
<table id="videos"></table>
|
||||
<div id="login">
|
||||
<form>
|
||||
<fieldset>
|
||||
<table>
|
||||
<tr>
|
||||
<td><label for="login-username">Username:</label></td>
|
||||
<td><input type="text" id="login-username" name="username"
|
||||
autocomplete="username"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="login-password">Password:</label></td>
|
||||
<td><input type="password" id="login-password" name="password"
|
||||
autocomplete="current-password"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td><input type="submit" tabindex="-1" style="position:absolute; top:-1000px"></td>
|
||||
</tr>
|
||||
</table>
|
||||
<p id="login-error"></p>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
Binary file not shown.
Before Width: | Height: | Size: 15 KiB |
28
ui-src/favicon.svg
Normal file
28
ui-src/favicon.svg
Normal file
@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 15.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="512px"
|
||||
height="512px" viewBox="0.5 536.5 512 512" enable-background="new 0.5 536.5 512 512" xml:space="preserve">
|
||||
<g id="Layer_2">
|
||||
<circle fill="#E04E1B" cx="256.5" cy="792.5" r="256"/>
|
||||
</g>
|
||||
<g id="Layer_1">
|
||||
<g>
|
||||
<defs>
|
||||
<circle id="SVGID_1_" cx="256.5" cy="792.5" r="256"/>
|
||||
</defs>
|
||||
<clipPath id="SVGID_2_">
|
||||
<use xlink:href="#SVGID_1_" overflow="visible"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#SVGID_2_)">
|
||||
<path d="M422.611,604.539c-1.387-2.881-3.834-5.157-6.836-6.246c-3.026-1.092-6.367-0.97-9.244,0.436L11.497,782.899
|
||||
c-4.261,1.985-6.973,6.248-6.997,10.944c-0.024,4.697,2.688,8.961,6.949,10.994l33.682,15.835L437.98,637.521L422.611,604.539z"
|
||||
/>
|
||||
<path d="M484.004,736.265l-35.806-76.807L73.655,834.082l108.938,51.293c1.624,0.725,3.391,1.136,5.158,1.136
|
||||
c1.744,0,3.511-0.403,5.109-1.136l285.313-133.034C484.229,749.509,486.845,742.316,484.004,736.265z"/>
|
||||
<path d="M378.002,880.649v-54.908l-72.642,33.876v77.453c0,4.843,2.929,9.275,7.439,11.164l230.034,96.854
|
||||
c1.526,0.612,3.121,0.944,4.667,0.944v-94.096L378.002,880.649L378.002,880.649z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.4 KiB |
110
ui-src/index.html
Normal file
110
ui-src/index.html
Normal file
@ -0,0 +1,110 @@
|
||||
<!DOCTYPE html>
|
||||
<head>
|
||||
<title>Moonfire NVR</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
|
||||
<div id="top">
|
||||
<a id="toggle-nav">☰</a>
|
||||
<span id="session"></div>
|
||||
</div>
|
||||
<div id="nav">
|
||||
<form action="#">
|
||||
<fieldset>
|
||||
<legend>Streams</legend>
|
||||
<table id="streams"></table>
|
||||
</fieldset>
|
||||
<fieldset id="datetime">
|
||||
<legend>Date & Time Range</legend>
|
||||
<div id="from">
|
||||
<legend>From</legend>
|
||||
<div id="start-date"></div>
|
||||
<label for="start-time">Time:</label>
|
||||
<input id="start-time" name="start-time" type="text"
|
||||
max-length="20"
|
||||
title="Starting time within the day. Blank for the beginning of
|
||||
the day. Otherwise HH:mm[:ss[:FFFFF]][+-HH:mm], where F is
|
||||
90,000ths of a second. Timezone is normally left out; it's useful
|
||||
once a year during the ambiguous times of the "fall
|
||||
back" hour.">
|
||||
</div>
|
||||
<div id="to">
|
||||
<legend>To</legend>
|
||||
<div id="range">
|
||||
<input type="radio" name="end-date-type" id="end-date-same" checked>
|
||||
<label for="end-date-same">Same Day</label>
|
||||
<input type="radio" name="end-date-type" id="end-date-other">
|
||||
<label for="end-date-other">Other Day</label><br/>
|
||||
</div>
|
||||
<div id="end-date"></div>
|
||||
<label for="end-time">Time:</label>
|
||||
<input id="end-time" name="end-time" type="text" max-length="20"
|
||||
title="Ending time within the day. Blank for the end of the day.
|
||||
Otherwise HH:mm[:ss[:FFFFF]][+-HH:mm], where F is 90,000ths of a
|
||||
second. Timezone is normally left out; it's useful once a year
|
||||
during the ambiguous times of the "fall back" hour.">
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Recordings Display</legend>
|
||||
<label for="split">Max Video Duration:</label>
|
||||
<select name="split" id="split">
|
||||
<option value="324000000">1 hour</option>
|
||||
<option value="1296000000">4 hours</option>
|
||||
<option value="7776000000">24 hours</option>
|
||||
<option value="infinite">infinite</option>
|
||||
</select><br>
|
||||
<input type="checkbox" checked id="trim" name="trim">
|
||||
<label for="trim" title="Trim each segment of video so that it is fully
|
||||
contained within the select time range. When this is not selected,
|
||||
all segments will overlap with the selected time range but may start
|
||||
and/or end outside it.">Trim Segment Start & End</label><br>
|
||||
<input type="checkbox" checked id="ts" name="ts">
|
||||
<label for="ts" title="Include a text track in each .mp4 with the
|
||||
timestamp at which the video was recorded.">Timestamp Track</label><br>
|
||||
<label for="timefmt" title="The time format to use when displaying start
|
||||
and end times in the video segment list. Note this currently doesn't
|
||||
apply to the start/entry inputs.">Time Format:</label>
|
||||
<select name="timefmt" id="timefmt">
|
||||
<option value="MM/DD/YY hh:mm A">US-short</option>
|
||||
<option value="MM/DD/YYYY hh:mm:ss A">US</option>
|
||||
<option value="MM/DD/YY HH:mm" selected>Military-short</option>
|
||||
<option value="MM/DD/YYYY HH:mm:ss">Military</option>
|
||||
<option value="DD.MM.YY HH:mm">EU-short</option>
|
||||
<option value="DD-MM-YYYY HH:mm:ss">EU</option>
|
||||
<option value="YY-MM-DD hh:mm A">ISO-short (12h)</option>
|
||||
<option value="YY-MM-DD HH:mm">ISO-short (24h)</option>
|
||||
<option value="YYYY-MM-DD hh:mm:ss A">ISO (12h)</option>
|
||||
<option value="YYYY-MM-DD HH:mm:ss">ISO (24h)</option>
|
||||
<option value="YYYY-MM-DD HH:mm:ss">ISO 8601-like (No TZ)</option>
|
||||
<option value="YYYY-MM-DDTHH:mm:ss">ISO 8601 (No TZ)</option>
|
||||
<option value="YYYY-MM-DDTHH:mm:ssZ">ISO 8601</option>
|
||||
<option value="YYYY-MM-DDTHH:mm:ss:FFFFFZ">Internal</option>
|
||||
</select>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
<table id="videos"></table>
|
||||
<div id="login">
|
||||
<form>
|
||||
<fieldset>
|
||||
<table>
|
||||
<tr>
|
||||
<td><label for="login-username">Username:</label></td>
|
||||
<td><input type="text" id="login-username" name="username"
|
||||
autocomplete="username"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="login-password">Password:</label></td>
|
||||
<td><input type="password" id="login-password" name="password"
|
||||
autocomplete="current-password"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td><input type="submit" tabindex="-1" style="position:absolute; top:-1000px"></td>
|
||||
</tr>
|
||||
</table>
|
||||
<p id="login-error"></p>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
@ -33,7 +33,6 @@
|
||||
import NVRApplication from './NVRApplication';
|
||||
|
||||
import $ from 'jquery';
|
||||
import './favicon.ico';
|
||||
|
||||
// On document load, start application
|
||||
$(function() {
|
||||
|
@ -146,16 +146,14 @@ export default class MoonfireAPI {
|
||||
* Start a new AJAX request with the specified URL.
|
||||
*
|
||||
* @param {String} url URL to use
|
||||
* @param {String} cacheOk True if cached results are OK
|
||||
* @return {Request} jQuery request type
|
||||
*/
|
||||
request(url, cacheOk = false) {
|
||||
request(url) {
|
||||
return $.ajax(url, {
|
||||
dataType: 'json',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
cache: cacheOk,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
// vim: set et sw=2 ts=2:
|
||||
//
|
||||
// This file is part of Moonfire NVR, a security camera network video recorder.
|
||||
// Copyright (C) 2018 The Moonfire NVR Authors
|
||||
// Copyright (C) 2018-2020 The Moonfire NVR Authors
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
@ -32,6 +32,7 @@
|
||||
|
||||
const path = require('path');
|
||||
const webpack = require('webpack');
|
||||
const FaviconsWebpackPlugin = require('favicons-webpack-plugin')
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
|
||||
module.exports = {
|
||||
@ -39,7 +40,7 @@ module.exports = {
|
||||
nvr: './ui-src/index.js',
|
||||
},
|
||||
output: {
|
||||
filename: '[name].bundle.js',
|
||||
filename: '[name].[chunkhash].js',
|
||||
path: path.resolve('./ui-dist/'),
|
||||
publicPath: '/',
|
||||
},
|
||||
@ -63,15 +64,11 @@ module.exports = {
|
||||
},
|
||||
{
|
||||
test: /\.png$/,
|
||||
use: ['file-loader'],
|
||||
},
|
||||
{
|
||||
test: /\.ico$/,
|
||||
use: [
|
||||
{
|
||||
loader: 'file-loader',
|
||||
options: {
|
||||
name: '[name].[ext]',
|
||||
name: '[name].[contenthash].[ext]',
|
||||
},
|
||||
},
|
||||
],
|
||||
@ -86,9 +83,7 @@ module.exports = {
|
||||
plugins: [
|
||||
new webpack.IgnorePlugin(/\.\/locale$/),
|
||||
new HtmlWebpackPlugin({
|
||||
title: 'Moonfire NVR',
|
||||
filename: 'index.html',
|
||||
template: './ui-src/assets/index.html',
|
||||
template: './ui-src/index.html',
|
||||
}),
|
||||
new webpack.NormalModuleReplacementPlugin(
|
||||
/node_modules\/moment\/moment\.js$/,
|
||||
@ -98,5 +93,16 @@ module.exports = {
|
||||
/node_modules\/moment-timezone\/index\.js$/,
|
||||
'./builds/moment-timezone-with-data-2012-2022.min.js'
|
||||
),
|
||||
new FaviconsWebpackPlugin({
|
||||
logo: './ui-src/favicon.svg',
|
||||
mode: 'webapp',
|
||||
devMode: 'light',
|
||||
prefix: 'favicons-[hash]/',
|
||||
favicons: {
|
||||
coast: false,
|
||||
windows: false,
|
||||
yandex: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
@ -44,6 +44,9 @@ module.exports = merge(baseConfig, {
|
||||
minimize: false,
|
||||
namedChunks: true,
|
||||
},
|
||||
output: {
|
||||
filename: '[name].[hash].js',
|
||||
},
|
||||
devServer: {
|
||||
inline: true,
|
||||
port: process.env.MOONFIRE_DEV_PORT || 3000,
|
||||
|
Loading…
x
Reference in New Issue
Block a user