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:
Scott Lamb 2020-05-29 21:20:14 -07:00
parent 88fe6e5135
commit 45abeb22de
15 changed files with 1768 additions and 314 deletions

81
Cargo.lock generated
View File

@ -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]]

View File

@ -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"

View File

@ -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",

View File

@ -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)
})?;
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)?;
Ok(http_serve::serve(e, &req))
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();
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();

View File

@ -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';

View File

@ -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">&#x2630;</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 &amp; 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 &quot;fall
back&quot; 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 &quot;fall back&quot; 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 &amp; 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
View 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
View 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">&#x2630;</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 &amp; 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 &quot;fall
back&quot; 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 &quot;fall back&quot; 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 &amp; 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>

View File

@ -33,7 +33,6 @@
import NVRApplication from './NVRApplication';
import $ from 'jquery';
import './favicon.ico';
// On document load, start application
$(function() {

View File

@ -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,
});
}

View File

@ -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,
},
}),
],
};

View File

@ -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,

1463
yarn.lock

File diff suppressed because it is too large Load Diff