Merge branch 'master' into new-schema

This commit is contained in:
Scott Lamb 2020-06-02 22:58:11 -07:00
commit 6187aa64cf
20 changed files with 2979 additions and 1480 deletions

View File

@ -31,13 +31,13 @@ matrix:
script:
- yarn
- yarn build
- node_modules/eslint/bin/eslint.js ui-src
- yarn lint
- language: node_js
node_js: "lts/*"
script:
- yarn
- yarn build
- node_modules/eslint/bin/eslint.js ui-src
- yarn lint
allow_failures:
- rust: nightly
cache:

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"
@ -501,12 +495,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"
@ -887,9 +875,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#efde86035aedf6c623c11d1125aa256a3e99e6a2"
dependencies = [
"bytes",
"flate2",
@ -897,6 +884,8 @@ dependencies = [
"http",
"http-body",
"httpdate",
"libc",
"memchr",
"mime",
"smallvec",
"time 0.2.10",
@ -2235,12 +2224,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"
@ -2281,9 +2264,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"
@ -2309,55 +2292,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"
@ -2540,12 +2474,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

@ -34,7 +34,7 @@ futures = "0.3"
fnv = "1.0"
h264-reader = { git = "https://github.com/dholroyd/h264-reader" }
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

@ -44,7 +44,7 @@ The configuration understands these environment variables:
| :------------------ | :------------------------------------------ | :----------------------- |
| `MOONFIRE_URL` | base URL of the backing Moonfire NVR server | `http://localhost:8080/` |
| `MOONFIRE_DEV_PORT` | port to listen on | 3000 |
| `MOONFIRE_DEV_HOST` | base URL of the backing Moonfire NVR server | `localhost` (1) |
| `MOONFIRE_DEV_HOST` | host/IP to listen on (or `0.0.0.0`) | `localhost` (1) |
(1) Moonfire NVR's `webpack/dev.config.js` has no default value for
`MOONFIRE_DEV_HOST`. `webpack-dev-server` itself has a default of `localhost`,

View File

@ -1,11 +1,16 @@
{
"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"
},
"scripts": {
"start": "webpack-dev-server --mode development --config webpack/dev.config.js --progress",
"build": "webpack --mode production --config webpack/prod.config.js"
"build": "webpack --mode production --config webpack/prod.config.js",
"lint": "eslint ui-src"
},
"dependencies": {
"jquery": "^3.2.1",
@ -15,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": {
@ -25,12 +31,13 @@
"clean-webpack-plugin": "^3.0.0",
"compression-webpack-plugin": "^3.1.0",
"css-loader": "^3.4.2",
"eslint": "^6.8.0",
"eslint": "^7.0.0",
"eslint-config-google": "^0.14.0",
"file-loader": "^5.1.0",
"html-loader": "^0.5.5",
"html-webpack-plugin": "^3.2.0",
"prettier": "1.19.1",
"favicons-webpack-plugin": "^3.0.1",
"file-loader": "^6.0.0",
"html-loader": "^1.1.0",
"html-webpack-plugin": "^4.3.0",
"prettier": "2.0.5",
"style-loader": "^1.1.3",
"webpack": "^4.41.6",
"webpack-cli": "^3.3.11",

View File

@ -39,7 +39,6 @@ use futures::future::FutureExt;
use hyper::service::{make_service_fn, service_fn};
use log::{info, warn};
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
@ -199,13 +198,13 @@ pub async fn run(args: &Args) -> Result<(), Error> {
let time_zone_name = resolve_zone()?;
info!("Resolved timezone: {}", &time_zone_name);
let s = web::Service::new(web::Config {
let svc = Arc::new(web::Service::new(web::Config {
db: db.clone(),
ui_dir: Some(&args.ui_dir),
allow_unauthenticated_permissions: args.allow_unauthenticated_permissions.clone(),
trust_forward_hdrs: args.trust_forward_hdrs,
time_zone_name,
})?;
})?);
// Start a streamer for each stream.
let shutdown_streamers = Arc::new(AtomicBool::new(false));
@ -283,8 +282,8 @@ pub async fn run(args: &Args) -> Result<(), Error> {
// Start the web interface.
let make_svc = make_service_fn(move |_conn| {
futures::future::ok::<_, std::convert::Infallible>(service_fn({
let mut s = s.clone();
move |req| Pin::from(s.serve(req))
let svc = Arc::clone(&svc);
move |req| Arc::clone(&svc).serve(req)
}))
});
let server = ::hyper::server::Server::bind(&args.http_addr)

View File

@ -31,7 +31,7 @@
use base::clock::Clocks;
use base::{ErrorKind, bail_t};
use bytes::Bytes;
use crate::body::{Body, BoxedError};
use crate::body::Body;
use crate::json;
use crate::mp4;
use bytes::{BufMut, BytesMut};
@ -42,30 +42,24 @@ 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::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;
use url::form_urlencoded;
use uuid::Uuid;
type BoxedFuture = Box<dyn Future<Output = Result<Response<Body>, BoxedError>> +
Sync + Send + 'static>;
#[derive(Debug, Eq, PartialEq)]
enum Path {
TopLevel, // "/api/"
@ -240,32 +234,11 @@ 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>,
}
impl Caller {
}
struct ServiceInner {
db: Arc<db::Database>,
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,
}
type ResponseResult = Result<Response<Body>, Response<Body>>;
fn serve_json<T: serde::ser::Serialize>(req: &Request<hyper::Body>, out: &T) -> ResponseResult {
@ -278,7 +251,306 @@ fn serve_json<T: serde::ser::Serialize>(req: &Request<hyper::Body>, out: &T) ->
Ok(resp)
}
impl ServiceInner {
fn csrf_matches(csrf: &str, session: auth::SessionHash) -> bool {
let mut b64 = [0u8; 32];
session.encode_base64(&mut b64);
::ring::constant_time::verify_slices_are_equal(&b64[..], csrf.as_bytes()).is_ok()
}
/// Extracts `s` cookie from the HTTP request. Does not authenticate.
fn extract_sid(req: &Request<hyper::Body>) -> Option<auth::RawSessionId> {
let hdr = match req.headers().get(header::COOKIE) {
None => return None,
Some(c) => c,
};
for mut cookie in hdr.as_bytes().split(|&b| b == b';') {
if cookie.starts_with(b" ") {
cookie = &cookie[1..];
}
if cookie.starts_with(b"s=") {
let s = &cookie[2..];
if let Ok(s) = auth::RawSessionId::decode_base64(s) {
return Some(s);
}
}
}
None
}
/// Extracts an `application/json` POST body from a request.
///
/// This returns the request body as bytes rather than performing
/// deserialization. Keeping the bytes allows the caller to use a `Deserialize`
/// that borrows from the bytes.
async fn extract_json_body(req: &mut Request<hyper::Body>) -> Result<Bytes, Response<Body>> {
if *req.method() != http::method::Method::POST {
return Err(plain_response(StatusCode::METHOD_NOT_ALLOWED, "POST expected"));
}
let correct_mime_type = match req.headers().get(header::CONTENT_TYPE) {
Some(t) if t == "application/json" => true,
Some(t) if t == "application/json; charset=UTF-8" => true,
_ => false,
};
if !correct_mime_type {
return Err(bad_req("expected application/json request body"));
}
let b = ::std::mem::replace(req.body_mut(), hyper::Body::empty());
hyper::body::to_bytes(b).await
.map_err(|e| internal_server_err(format_err!("unable to read request body: {}", e)))
}
pub struct Config<'a> {
pub db: Arc<db::Database>,
pub ui_dir: Option<&'a std::path::Path>,
pub trust_forward_hdrs: bool,
pub time_zone_name: String,
pub allow_unauthenticated_permissions: Option<db::Permissions>,
}
pub struct Service {
db: Arc<db::Database>,
ui_dir: Option<Arc<FsDir>>,
dirs_by_stream_id: Arc<FnvHashMap<i32, Arc<SampleFileDir>>>,
time_zone_name: String,
allow_unauthenticated_permissions: Option<db::Permissions>,
trust_forward_hdrs: bool,
}
/// 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_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 dirs_by_stream_id = {
let l = config.db.lock();
let mut d =
FnvHashMap::with_capacity_and_hasher(l.streams_by_id().len(), Default::default());
for (&id, s) in l.streams_by_id().iter() {
let dir_id = match s.sample_file_dir_id {
Some(d) => d,
None => continue,
};
d.insert(id, l.sample_file_dirs_by_id()
.get(&dir_id)
.unwrap()
.get()?);
}
Arc::new(d)
};
Ok(Service {
db: config.db,
dirs_by_stream_id,
ui_dir,
allow_unauthenticated_permissions: config.allow_unauthenticated_permissions,
trust_forward_hdrs: config.trust_forward_hdrs,
time_zone_name: config.time_zone_name,
})
}
fn stream_live_m4s(self: Arc<Self>, req: Request<::hyper::Body>, caller: Caller, uuid: Uuid,
stream_type: db::StreamType) -> ResponseResult {
if !caller.permissions.view_video {
return Err(plain_response(StatusCode::UNAUTHORIZED, "view_video required"));
}
let stream_id;
let open_id;
let (sub_tx, sub_rx) = futures::channel::mpsc::unbounded();
{
let mut db = self.db.lock();
open_id = match db.open {
None => return Err(plain_response(
StatusCode::PRECONDITION_FAILED,
"database is read-only; there are no live streams")),
Some(o) => o.id,
};
let camera = db.get_camera(uuid)
.ok_or_else(|| plain_response(StatusCode::NOT_FOUND,
format!("no such camera {}", uuid)))?;
stream_id = camera.streams[stream_type.index()]
.ok_or_else(|| plain_response(StatusCode::NOT_FOUND,
format!("no such stream {}/{}", uuid,
stream_type)))?;
db.watch_live(stream_id, Box::new(move |l| sub_tx.unbounded_send(l).is_ok()))
.expect("stream_id refed by camera");
}
let (parts, body) = req.into_parts();
let req = Request::from_parts(parts, ());
let response = tungstenite::handshake::server::create_response(&req)
.map_err(|e| bad_req(e.to_string()))?;
let (parts, ()) = response.into_parts();
tokio::spawn(self.stream_live_m4s_ws(stream_id, open_id, body, sub_rx));
Ok(Response::from_parts(parts, Body::from("")))
}
async fn stream_live_m4s_ws(
self: Arc<Self>, stream_id: i32, open_id: u32, body: hyper::Body,
mut sub_rx: futures::channel::mpsc::UnboundedReceiver<db::LiveSegment>) {
let upgraded = match body.on_upgrade().await {
Ok(u) => u,
Err(e) => {
warn!("Unable to upgrade stream to websocket: {}", e);
return;
},
};
let mut ws = tokio_tungstenite::WebSocketStream::from_raw_socket(
upgraded,
tungstenite::protocol::Role::Server,
None,
).await;
loop {
let live = match sub_rx.next().await {
Some(l) => l,
None => return,
};
if let Err(e) = self.stream_live_m4s_chunk(open_id, stream_id, &mut ws, live).await {
info!("Dropping WebSocket after error: {}", e);
return;
}
}
}
async fn stream_live_m4s_chunk(
&self, open_id: u32, stream_id: i32,
ws: &mut tokio_tungstenite::WebSocketStream<hyper::upgrade::Upgraded>,
live: db::LiveSegment) -> Result<(), Error> {
let mut builder = mp4::FileBuilder::new(mp4::Type::MediaSegment);
let mut vse_id = None;
let mut start = None;
{
let db = self.db.lock();
let mut rows = 0;
db.list_recordings_by_id(stream_id, live.recording .. live.recording+1, &mut |r| {
rows += 1;
vse_id = Some(r.video_sample_entry_id);
start = Some(r.start);
builder.append(&db, r, live.off_90k.clone())?;
Ok(())
})?;
if rows != 1 {
bail_t!(Internal, "unable to find {:?}", live);
}
}
let vse_id = vse_id.unwrap();
let start = start.unwrap();
use http_serve::Entity;
let mp4 = builder.build(self.db.clone(), self.dirs_by_stream_id.clone())?;
let mut hdrs = header::HeaderMap::new();
mp4.add_headers(&mut hdrs);
let mime_type = hdrs.get(header::CONTENT_TYPE).unwrap();
let hdr = format!(
"Content-Type: {}\r\n\
X-Recording-Start: {}\r\n\
X-Recording-Id: {}.{}\r\n\
X-Time-Range: {}-{}\r\n\
X-Video-Sample-Entry-Id: {}\r\n\r\n",
mime_type.to_str().unwrap(),
start.0,
open_id,
live.recording,
live.off_90k.start,
live.off_90k.end,
&vse_id);
let mut v = /*Pin::from(*/hdr.into_bytes()/*)*/;
mp4.append_into_vec(&mut v).await?;
//let v = Pin::into_inner();
ws.send(tungstenite::Message::Binary(v)).await?;
Ok(())
}
async fn signals(&self, req: Request<hyper::Body>, caller: Caller) -> ResponseResult {
use http::method::Method;
match *req.method() {
Method::POST => self.post_signals(req, caller).await,
Method::GET | Method::HEAD => self.get_signals(&req),
_ => Err(plain_response(StatusCode::METHOD_NOT_ALLOWED,
"POST, GET, or HEAD expected")),
}
}
async fn serve_inner(self: Arc<Self>, req: Request<::hyper::Body>, p: Path, caller: Caller)
-> ResponseResult {
let (cache, mut response) = match p {
Path::InitSegment(sha1, debug) => {
(CacheControl::PrivateStatic, self.init_segment(sha1, debug, &req)?)
},
Path::TopLevel => (CacheControl::PrivateDynamic, self.top_level(&req, caller)?),
Path::Request => (CacheControl::PrivateDynamic, self.request(&req)?),
Path::Camera(uuid) => (CacheControl::PrivateDynamic, self.camera(&req, uuid)?),
Path::StreamRecordings(uuid, type_) => {
(CacheControl::PrivateDynamic, self.stream_recordings(&req, uuid, type_)?)
},
Path::StreamViewMp4(uuid, type_, debug) => {
(CacheControl::PrivateStatic,
self.stream_view_mp4(&req, caller, uuid, type_, mp4::Type::Normal, debug)?)
},
Path::StreamViewMp4Segment(uuid, type_, debug) => {
(CacheControl::PrivateStatic,
self.stream_view_mp4(&req, caller, uuid, type_, mp4::Type::MediaSegment, debug)?)
},
Path::StreamLiveMp4Segments(uuid, type_) => {
(CacheControl::PrivateDynamic, self.stream_live_m4s(req, caller, uuid, type_)?)
},
Path::NotFound => return Err(not_found("path not understood")),
Path::Login => (CacheControl::PrivateDynamic, self.login(req).await?),
Path::Logout => (CacheControl::PrivateDynamic, self.logout(req).await?),
Path::Signals => (CacheControl::PrivateDynamic, self.signals(req, caller).await?),
Path::Static => (CacheControl::None, self.static_file(req).await?)
};
match cache {
CacheControl::PrivateStatic => {
response.headers_mut().insert(header::CACHE_CONTROL,
HeaderValue::from_static("private, max-age=3600"));
},
CacheControl::PrivateDynamic => {
response.headers_mut().insert(header::CACHE_CONTROL,
HeaderValue::from_static("private, no-cache"));
},
CacheControl::None => {},
}
Ok(response)
}
pub async fn serve(self: Arc<Self>, req: Request<::hyper::Body>)
-> Result<Response<Body>, std::convert::Infallible> {
let p = Path::decode(req.uri().path());
let always_allow_unauthenticated = match p {
Path::NotFound | Path::Request | Path::Login | Path::Logout | Path::Static => true,
_ => false,
};
debug!("request on: {}: {:?}", req.uri(), p);
let caller = match self.authenticate(&req, always_allow_unauthenticated) {
Ok(c) => c,
Err(e) => return Ok(from_base_error(e)),
};
Ok(self.serve_inner(req, p, caller).await.unwrap_or_else(|e| e))
}
fn top_level(&self, req: &Request<::hyper::Body>, caller: Caller) -> ResponseResult {
let mut days = false;
let mut camera_configs = false;
@ -523,14 +795,30 @@ 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)
})?;
async fn static_file(&self, req: Request<hyper::Body>) -> ResponseResult {
let dir = self.ui_dir.clone()
.ok_or_else(|| not_found("--ui-dir not configured; no static files available."))?;
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))
}
@ -573,18 +861,19 @@ impl ServiceInner {
.unwrap_or(false)
}
fn login(&self, req: &Request<::hyper::Body>, body: Bytes) -> ResponseResult {
let r: json::LoginRequest = serde_json::from_slice(&body)
async fn login(&self, mut req: Request<::hyper::Body>) -> ResponseResult {
let r = extract_json_body(&mut req).await?;
let r: json::LoginRequest = serde_json::from_slice(&r)
.map_err(|e| bad_req(e.to_string()))?;
let authreq = self.authreq(req);
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();
let mut l = self.db.lock();
let is_secure = self.is_secure(req);
let is_secure = self.is_secure(&req);
let flags = (auth::SessionFlag::HttpOnly as i32) |
(auth::SessionFlag::SameSite as i32) |
(auth::SessionFlag::SameSiteStrict as i32) |
@ -610,13 +899,14 @@ impl ServiceInner {
.body(b""[..].into()).unwrap())
}
fn logout(&self, req: &Request<hyper::Body>, body: Bytes) -> ResponseResult {
let r: json::LogoutRequest = serde_json::from_slice(&body)
async fn logout(&self, mut req: Request<hyper::Body>) -> ResponseResult {
let r = extract_json_body(&mut req).await?;
let r: json::LogoutRequest = serde_json::from_slice(&r)
.map_err(|e| bad_req(e.to_string()))?;
let mut res = Response::new(b""[..].into());
if let Some(sid) = extract_sid(req) {
let authreq = self.authreq(req);
if let Some(sid) = extract_sid(&req) {
let authreq = self.authreq(&req);
let mut l = self.db.lock();
let hash = sid.hash();
let need_revoke = match l.authenticate_session(authreq.clone(), &hash) {
@ -651,12 +941,13 @@ impl ServiceInner {
Ok(res)
}
fn post_signals(&self, req: &Request<hyper::Body>, caller: Caller, body: Bytes)
-> ResponseResult {
async fn post_signals(&self, mut req: Request<hyper::Body>, caller: Caller)
-> ResponseResult {
if !caller.permissions.update_signals {
return Err(plain_response(StatusCode::UNAUTHORIZED, "update_signals required"));
}
let r: json::PostSignalsRequest = serde_json::from_slice(&body)
let r = extract_json_body(&mut req).await?;
let r: json::PostSignalsRequest = serde_json::from_slice(&r)
.map_err(|e| bad_req(e.to_string()))?;
let mut l = self.db.lock();
let now = recording::Time::new(self.db.clocks().realtime());
@ -669,7 +960,7 @@ impl ServiceInner {
},
};
l.update_signals(start .. end, &r.signal_ids, &r.states).map_err(from_base_error)?;
serve_json(req, &json::PostSignalsResponse {
serve_json(&req, &json::PostSignalsResponse {
time_90k: now.0,
})
}
@ -739,331 +1030,44 @@ impl ServiceInner {
}
}
fn csrf_matches(csrf: &str, session: auth::SessionHash) -> bool {
let mut b64 = [0u8; 32];
session.encode_base64(&mut b64);
::ring::constant_time::verify_slices_are_equal(&b64[..], csrf.as_bytes()).is_ok()
#[derive(Debug, Eq, PartialEq)]
struct StaticFileRequest<'a> {
path: &'a str,
immutable: bool,
mime: &'static str,
}
/// Extracts `s` cookie from the HTTP request. Does not authenticate.
fn extract_sid(req: &Request<hyper::Body>) -> Option<auth::RawSessionId> {
let hdr = match req.headers().get(header::COOKIE) {
None => return None,
Some(c) => c,
};
for mut cookie in hdr.as_bytes().split(|&b| b == b';') {
if cookie.starts_with(b" ") {
cookie = &cookie[1..];
impl<'a> StaticFileRequest<'a> {
fn parse(path: &'a str) -> Option<Self> {
if !path.starts_with("/") {
return None;
}
if cookie.starts_with(b"s=") {
let s = &cookie[2..];
if let Ok(s) = auth::RawSessionId::decode_base64(s) {
return Some(s);
}
}
}
None
}
/// Returns a future separating the request from its JSON body.
///
/// If this is not a `POST` or the body's `Content-Type` is not
/// `application/json`, returns an appropriate error response instead.
///
/// Use with `and_then` to chain logic which consumes the form body.
async fn with_json_body(mut req: Request<hyper::Body>)
-> Result<(Request<hyper::Body>, Bytes), Response<Body>> {
if *req.method() != http::method::Method::POST {
return Err(plain_response(StatusCode::METHOD_NOT_ALLOWED, "POST expected"));
}
let correct_mime_type = match req.headers().get(header::CONTENT_TYPE) {
Some(t) if t == "application/json" => true,
Some(t) if t == "application/json; charset=UTF-8" => true,
_ => false,
};
if !correct_mime_type {
return Err(bad_req("expected application/json request body"));
}
let b = ::std::mem::replace(req.body_mut(), hyper::Body::empty());
match hyper::body::to_bytes(b).await {
Ok(b) => Ok((req, b)),
Err(e) => Err(internal_server_err(format_err!("unable to read request body: {}", e))),
}
}
pub struct Config<'a> {
pub db: Arc<db::Database>,
pub ui_dir: Option<&'a std::path::Path>,
pub trust_forward_hdrs: bool,
pub time_zone_name: String,
pub allow_unauthenticated_permissions: Option<db::Permissions>,
}
#[derive(Clone)]
pub struct Service(Arc<ServiceInner>);
impl Service {
pub fn new(config: Config) -> Result<Self, Error> {
let mut ui_files = HashMap::new();
if let Some(d) = config.ui_dir {
Service::fill_ui_files(d, &mut ui_files);
}
debug!("UI files: {:#?}", ui_files);
let dirs_by_stream_id = {
let l = config.db.lock();
let mut d =
FnvHashMap::with_capacity_and_hasher(l.streams_by_id().len(), Default::default());
for (&id, s) in l.streams_by_id().iter() {
let dir_id = match s.sample_file_dir_id {
Some(d) => d,
None => continue,
};
d.insert(id, l.sample_file_dirs_by_id()
.get(&dir_id)
.unwrap()
.get()?);
}
Arc::new(d)
let (path, immutable) = match &path[1..] {
"" => ("index.html", false),
p => (p, true),
};
Ok(Service(Arc::new(ServiceInner {
db: config.db,
dirs_by_stream_id,
ui_files,
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;
}
let last_dot = match path.rfind('.') {
None => return None,
Some(d) => d,
};
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 {
return Err(plain_response(StatusCode::UNAUTHORIZED, "view_video required"));
}
let stream_id;
let open_id;
let (sub_tx, sub_rx) = futures::channel::mpsc::unbounded();
{
let mut db = self.0.db.lock();
open_id = match db.open {
None => return Err(plain_response(
StatusCode::PRECONDITION_FAILED,
"database is read-only; there are no live streams")),
Some(o) => o.id,
};
let camera = db.get_camera(uuid)
.ok_or_else(|| plain_response(StatusCode::NOT_FOUND,
format!("no such camera {}", uuid)))?;
stream_id = camera.streams[stream_type.index()]
.ok_or_else(|| plain_response(StatusCode::NOT_FOUND,
format!("no such stream {}/{}", uuid,
stream_type)))?;
db.watch_live(stream_id, Box::new(move |l| sub_tx.unbounded_send(l).is_ok()))
.expect("stream_id refed by camera");
}
let (parts, body) = req.into_parts();
let req = Request::from_parts(parts, ());
let response = tungstenite::handshake::server::create_response(&req)
.map_err(|e| bad_req(e.to_string()))?;
let (parts, ()) = response.into_parts();
tokio::spawn(self.clone().stream_live_m4s_ws(stream_id, open_id, body, sub_rx));
Ok(Response::from_parts(parts, Body::from("")))
}
async fn stream_live_m4s_ws(
self, stream_id: i32, open_id: u32, body: hyper::Body,
mut sub_rx: futures::channel::mpsc::UnboundedReceiver<db::LiveSegment>) {
let upgraded = match body.on_upgrade().await {
Ok(u) => u,
Err(e) => {
warn!("Unable to upgrade stream to websocket: {}", e);
return;
},
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
};
let mut ws = tokio_tungstenite::WebSocketStream::from_raw_socket(
upgraded,
tungstenite::protocol::Role::Server,
None,
).await;
loop {
let live = match sub_rx.next().await {
Some(l) => l,
None => return,
};
if let Err(e) = self.stream_live_m4s_chunk(open_id, stream_id, &mut ws, live).await {
info!("Dropping WebSocket after error: {}", e);
return;
}
}
}
async fn stream_live_m4s_chunk(
&self, open_id: u32, stream_id: i32,
ws: &mut tokio_tungstenite::WebSocketStream<hyper::upgrade::Upgraded>,
live: db::LiveSegment) -> Result<(), Error> {
let mut builder = mp4::FileBuilder::new(mp4::Type::MediaSegment);
let mut vse_id = None;
let mut start = None;
{
let db = self.0.db.lock();
let mut rows = 0;
db.list_recordings_by_id(stream_id, live.recording .. live.recording+1, &mut |r| {
rows += 1;
vse_id = Some(r.video_sample_entry_id);
start = Some(r.start);
builder.append(&db, r, live.off_90k.clone())?;
Ok(())
})?;
if rows != 1 {
bail_t!(Internal, "unable to find {:?}", live);
}
}
let vse_id = vse_id.unwrap();
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();
mp4.add_headers(&mut hdrs);
let mime_type = hdrs.get(http::header::CONTENT_TYPE).unwrap();
let hdr = format!(
"Content-Type: {}\r\n\
X-Recording-Start: {}\r\n\
X-Recording-Id: {}.{}\r\n\
X-Time-Range: {}-{}\r\n\
X-Video-Sample-Entry-Id: {}\r\n\r\n",
mime_type.to_str().unwrap(),
start.0,
open_id,
live.recording,
live.off_90k.start,
live.off_90k.end,
vse_id);
let mut v = /*Pin::from(*/hdr.into_bytes()/*)*/;
mp4.append_into_vec(&mut v).await?;
//let v = Pin::into_inner();
ws.send(tungstenite::Message::Binary(v)).await?;
Ok(())
}
fn signals(&self, req: Request<hyper::Body>, caller: Caller)
-> Box<dyn Future<Output = Result<Response<Body>, Response<Body>>> + Send + Sync + 'static> {
use http::method::Method;
match *req.method() {
Method::POST => Box::new(with_json_body(req)
.and_then({
let s = self.0.clone();
move |(req, b)| future::ready(s.post_signals(&req, caller, b))
})),
Method::GET | Method::HEAD => Box::new(future::ready(self.0.get_signals(&req))),
_ => Box::new(future::err(plain_response(StatusCode::METHOD_NOT_ALLOWED,
"POST, GET, or HEAD expected"))),
}
}
pub fn serve(&mut self, req: Request<::hyper::Body>) -> BoxedFuture {
fn wrap<R>(is_private: bool, 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"));
}
r
}))
}
fn wrap_r(is_private: bool, r: ResponseResult)
-> Box<dyn Future<Output = Result<Response<Body>, BoxedError>> + Send + Sync + 'static> {
return wrap(is_private, future::ready(r))
}
let p = Path::decode(req.uri().path());
let always_allow_unauthenticated = match p {
Path::NotFound | Path::Request | Path::Login | Path::Logout | Path::Static => true,
_ => false,
};
debug!("request on: {}: {:?}", req.uri(), p);
let caller = match self.0.authenticate(&req, always_allow_unauthenticated) {
Ok(c) => c,
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::StreamRecordings(uuid, type_) => {
wrap_r(true, 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))
},
Path::StreamViewMp4Segment(uuid, type_, debug) => {
wrap_r(true, 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_))
},
Path::NotFound => wrap(true, future::err(not_found("path not understood"))),
Path::Login => wrap(true, 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({
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())),
}
Some(StaticFileRequest {
path,
immutable,
mime,
})
}
}
@ -1074,7 +1078,8 @@ mod tests {
use log::info;
use std::collections::HashMap;
use std::str::FromStr;
use super::Segments;
use std::sync::Arc;
use super::{Segments, StaticFileRequest};
struct Server {
db: TestDb<base::clock::RealClocks>,
@ -1088,17 +1093,17 @@ mod tests {
fn new(allow_unauthenticated_permissions: Option<db::Permissions>) -> Server {
let db = TestDb::new(base::clock::RealClocks {});
let (shutdown_tx, shutdown_rx) = futures::channel::oneshot::channel::<()>();
let service = super::Service::new(super::Config {
let service = Arc::new(super::Service::new(super::Config {
db: db.db.clone(),
ui_dir: None,
allow_unauthenticated_permissions,
trust_forward_hdrs: true,
time_zone_name: "".to_owned(),
}).unwrap();
}).unwrap());
let make_svc = hyper::service::make_service_fn(move |_conn| {
futures::future::ok::<_, std::convert::Infallible>(hyper::service::service_fn({
let mut s = service.clone();
move |req| std::pin::Pin::from(s.serve(req))
let s = Arc::clone(&service);
move |req| Arc::clone(&s).serve(req)
}))
});
let (tx, rx) = std::sync::mpsc::channel();
@ -1216,6 +1221,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();
@ -1364,6 +1387,7 @@ mod bench {
use db::testutil::{self, TestDb};
use hyper;
use lazy_static::lazy_static;
use std::sync::Arc;
use uuid::Uuid;
struct Server {
@ -1376,17 +1400,17 @@ mod bench {
let db = TestDb::new(::base::clock::RealClocks {});
let test_camera_uuid = db.test_camera_uuid;
testutil::add_dummy_recordings_to_db(&db.db, 1440);
let service = super::Service::new(super::Config {
let service = Arc::new(super::Service::new(super::Config {
db: db.db.clone(),
ui_dir: None,
allow_unauthenticated_permissions: Some(db::Permissions::default()),
trust_forward_hdrs: false,
time_zone_name: "".to_owned(),
}).unwrap();
}).unwrap());
let make_svc = hyper::service::make_service_fn(move |_conn| {
futures::future::ok::<_, std::convert::Infallible>(hyper::service::service_fn({
let mut s = service.clone();
move |req| std::pin::Pin::from(s.serve(req))
let s = Arc::clone(&service);
move |req| Arc::clone(&s).serve(req)
}))
});
let mut rt = tokio::runtime::Runtime::new().unwrap();

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';
@ -384,7 +384,7 @@ export default class NVRApplication {
* Start the application.
*/
start() {
let nav = $('#nav');
const nav = $('#nav');
$('#toggle-nav').click(() => {
nav.toggle('slide');

View File

@ -1,104 +0,0 @@
<!DOCTYPE html>
<!-- vim: set et: -->
<html lang="en">
<head>
<title>Moonfire NVR</title>
</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>
<div id="st">
<label for="start-time">Time:</label>
<input id="start-time" name="start-time" type="text" 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>
<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="start-time">Time:</label>
<input id="end-time" name="end-time" type="text" 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

View File

@ -18,6 +18,7 @@ body {
}
#nav {
float: left;
margin: 0 0.5em 0.5ex 0;
}
#session {
float: right;
@ -32,17 +33,15 @@ body {
#videos {
display: inline-block;
padding-top: 0.5em;
padding-left: 1em;
padding-right: 1em;
}
#videos tbody:after {
content: "";
display: block;
height: 3ex;
}
table.videos {
table#videos {
border-collapse: collapse;
/*border-spacing: 0.5em 0.5ex;*/
}
tbody tr.name {
font-size: 110%;
@ -65,8 +64,8 @@ tr.r td {
tr.r th,
tr.r td {
border: 0;
padding: 0.5ex 1.5em;
text-align: right;
padding: 0.5ex 1.5em;
}
fieldset {
@ -80,9 +79,6 @@ fieldset legend {
#from, #to {
padding-right: 0.75em;
}
#st {
width: 100%;
}
#range {
padding: 0.5em 0;
@ -96,3 +92,17 @@ video {
width: 100%;
height: 100%;
}
@media only screen and (max-width: 768px) {
#nav {
float: none;
display: none;
}
.resolution, .frameRate, .size {
display: none;
}
tr.r th,
tr.r td {
padding: 0.5ex 0.5em;
}
}

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

@ -112,7 +112,8 @@ export default class RecordingsView {
$('<tr class="hdr">').append(
$(
_columnOrder
.map((name) => '<th>' + _columnLabels[name] + '</th>')
.map((name) =>
`<th class="${name}">${_columnLabels[name]}</th>`)
.join('')
)
),
@ -271,7 +272,7 @@ export default class RecordingsView {
$('tr.r', tbody).remove();
this.recordings_.forEach((r) => {
const row = $('<tr class="r" />');
row.append(_columnOrder.map(() => $('<td/>')));
row.append(_columnOrder.map((c) => $(`<td class="${c}"/>`)));
row.on('click', () => {
console.log('Video clicked');
if (this.clickHandler_ !== null) {

View File

@ -52,7 +52,7 @@ export default class VideoDialogView {
* This does not attach the player to the DOM anywhere! In fact, construction
* of the necessary video element is delayed until an attach is requested.
* Since the close of the video removes all traces of it in the DOM, this
* apprach allows repeated use by calling attach again!
* approach allows repeated use by calling attach again!
*/
constructor() {}
@ -80,13 +80,13 @@ export default class VideoDialogView {
* @return {VideoDialogView} Returns "this" for chaining.
*/
play(title, width, url) {
const videoDomElement = this.videoElement_[0];
this.dialogElement_.dialog({
title: title,
width: width,
close: () => {
const videoDOMElement = this.videoElement_[0];
videoDOMElement.pause();
videoDOMElement.src = ''; // Remove current source to stop loading
videoDomElement.pause();
videoDomElement.src = ''; // Remove current source to stop loading
this.videoElement_ = null;
this.dialogElement_.remove();
this.dialogElement_ = null;
@ -95,6 +95,21 @@ export default class VideoDialogView {
// Now that dialog is up, set the src so video starts
console.log('Video url: ' + url);
this.videoElement_.attr('src', url);
// On narrow displays (as defined by index.css), play videos in
// full-screen mode. When the user exits full-screen mode, close the
// dialog.
const narrowWindow = $('#nav').css('float') == 'none';
if (narrowWindow) {
console.log('Narrow window; starting video in full-screen mode.');
videoDomElement.requestFullscreen();
videoDomElement.addEventListener('fullscreenchange', () => {
if (document.fullscreenElement !== videoDomElement) {
console.log('Closing video because user exited full-screen mode.');
this.dialogElement_.dialog('close');
}
});
}
return this;
}
}

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,

3230
yarn.lock

File diff suppressed because it is too large Load Diff