mirror of
https://github.com/scottlamb/moonfire-nvr.git
synced 2025-01-26 06:03:18 -05:00
Merge branch 'master' into new-schema
This commit is contained in:
commit
6187aa64cf
@ -31,13 +31,13 @@ matrix:
|
|||||||
script:
|
script:
|
||||||
- yarn
|
- yarn
|
||||||
- yarn build
|
- yarn build
|
||||||
- node_modules/eslint/bin/eslint.js ui-src
|
- yarn lint
|
||||||
- language: node_js
|
- language: node_js
|
||||||
node_js: "lts/*"
|
node_js: "lts/*"
|
||||||
script:
|
script:
|
||||||
- yarn
|
- yarn
|
||||||
- yarn build
|
- yarn build
|
||||||
- node_modules/eslint/bin/eslint.js ui-src
|
- yarn lint
|
||||||
allow_failures:
|
allow_failures:
|
||||||
- rust: nightly
|
- rust: nightly
|
||||||
cache:
|
cache:
|
||||||
|
81
Cargo.lock
generated
81
Cargo.lock
generated
@ -112,12 +112,6 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "base-x"
|
|
||||||
version = "0.2.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1b20b618342cf9891c292c4f5ac2cde7287cc5c87e87e9c769d617793607dec1"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.9.3"
|
version = "0.9.3"
|
||||||
@ -501,12 +495,6 @@ dependencies = [
|
|||||||
"winapi 0.3.8",
|
"winapi 0.3.8",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "discard"
|
|
||||||
version = "1.0.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "212d0f5754cb6769937f4501cc0e67f4f4483c8d2c3e1e922ee9edbe4ab4c7c0"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dtoa"
|
name = "dtoa"
|
||||||
version = "0.4.5"
|
version = "0.4.5"
|
||||||
@ -887,9 +875,8 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http-serve"
|
name = "http-serve"
|
||||||
version = "0.2.1"
|
version = "0.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "git+https://github.com/scottlamb/http-serve?branch=dir#efde86035aedf6c623c11d1125aa256a3e99e6a2"
|
||||||
checksum = "f9e9efe3a2525d9bde418d52082845d4641c9acc426c3268b38dc14c0f5d77e3"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"flate2",
|
"flate2",
|
||||||
@ -897,6 +884,8 @@ dependencies = [
|
|||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httpdate",
|
"httpdate",
|
||||||
|
"libc",
|
||||||
|
"memchr",
|
||||||
"mime",
|
"mime",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"time 0.2.10",
|
"time 0.2.10",
|
||||||
@ -2235,12 +2224,6 @@ dependencies = [
|
|||||||
"opaque-debug",
|
"opaque-debug",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "sha1"
|
|
||||||
version = "0.6.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2579985fda508104f7587689507983eadd6a6e84dd35d6d115361f530916fa0d"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
@ -2281,9 +2264,9 @@ checksum = "c111b5bd5695e56cffe5129854aa230b39c93a305372fdbb2668ca2394eea9f8"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "smallvec"
|
name = "smallvec"
|
||||||
version = "1.3.0"
|
version = "1.4.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "05720e22615919e4734f6a99ceae50d00226c3c5aca406e102ebc33298214e0a"
|
checksum = "c7cb5678e1615754284ec264d9bb5b4c27d2018577fd90ac0ceb578591ed5ee4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "spin"
|
name = "spin"
|
||||||
@ -2309,55 +2292,6 @@ version = "0.3.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f3eb36b47e512f8f1c9e3d10c2c1965bc992bd9cdb024fa581e2194501c83d3"
|
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]]
|
[[package]]
|
||||||
name = "strsim"
|
name = "strsim"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@ -2540,12 +2474,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "cacbd5ebf7b211db6d9500b8b033c20b6e333a68368a9e8d3a1d073bb1f0a12a"
|
checksum = "cacbd5ebf7b211db6d9500b8b033c20b6e333a68368a9e8d3a1d073bb1f0a12a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
|
||||||
"rustversion",
|
"rustversion",
|
||||||
"standback",
|
"standback",
|
||||||
"stdweb",
|
|
||||||
"time-macros",
|
"time-macros",
|
||||||
"winapi 0.3.8",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -34,7 +34,7 @@ futures = "0.3"
|
|||||||
fnv = "1.0"
|
fnv = "1.0"
|
||||||
h264-reader = { git = "https://github.com/dholroyd/h264-reader" }
|
h264-reader = { git = "https://github.com/dholroyd/h264-reader" }
|
||||||
http = "0.2.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"
|
hyper = "0.13.0"
|
||||||
lazy_static = "1.0"
|
lazy_static = "1.0"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
@ -44,7 +44,7 @@ The configuration understands these environment variables:
|
|||||||
| :------------------ | :------------------------------------------ | :----------------------- |
|
| :------------------ | :------------------------------------------ | :----------------------- |
|
||||||
| `MOONFIRE_URL` | base URL of the backing Moonfire NVR server | `http://localhost:8080/` |
|
| `MOONFIRE_URL` | base URL of the backing Moonfire NVR server | `http://localhost:8080/` |
|
||||||
| `MOONFIRE_DEV_PORT` | port to listen on | 3000 |
|
| `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
|
(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`,
|
`MOONFIRE_DEV_HOST`. `webpack-dev-server` itself has a default of `localhost`,
|
||||||
|
21
package.json
21
package.json
@ -1,11 +1,16 @@
|
|||||||
{
|
{
|
||||||
"author": "Scott Lamb <slamb@slamb.org>",
|
"author": {
|
||||||
|
"name": "Scott Lamb",
|
||||||
|
"email": "slamb@slamb.org",
|
||||||
|
"url": "https://www.slamb.org/"
|
||||||
|
},
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://github.com/scottlamb/moonfire-nvr/issues"
|
"url": "https://github.com/scottlamb/moonfire-nvr/issues"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "webpack-dev-server --mode development --config webpack/dev.config.js --progress",
|
"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": {
|
"dependencies": {
|
||||||
"jquery": "^3.2.1",
|
"jquery": "^3.2.1",
|
||||||
@ -15,6 +20,7 @@
|
|||||||
"homepage": "https://github.com/scottlamb/moonfire-nvr",
|
"homepage": "https://github.com/scottlamb/moonfire-nvr",
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"name": "moonfire-nvr",
|
"name": "moonfire-nvr",
|
||||||
|
"description": "security camera network video recorder",
|
||||||
"repository": "scottlamb/moonfire-nvr",
|
"repository": "scottlamb/moonfire-nvr",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -25,12 +31,13 @@
|
|||||||
"clean-webpack-plugin": "^3.0.0",
|
"clean-webpack-plugin": "^3.0.0",
|
||||||
"compression-webpack-plugin": "^3.1.0",
|
"compression-webpack-plugin": "^3.1.0",
|
||||||
"css-loader": "^3.4.2",
|
"css-loader": "^3.4.2",
|
||||||
"eslint": "^6.8.0",
|
"eslint": "^7.0.0",
|
||||||
"eslint-config-google": "^0.14.0",
|
"eslint-config-google": "^0.14.0",
|
||||||
"file-loader": "^5.1.0",
|
"favicons-webpack-plugin": "^3.0.1",
|
||||||
"html-loader": "^0.5.5",
|
"file-loader": "^6.0.0",
|
||||||
"html-webpack-plugin": "^3.2.0",
|
"html-loader": "^1.1.0",
|
||||||
"prettier": "1.19.1",
|
"html-webpack-plugin": "^4.3.0",
|
||||||
|
"prettier": "2.0.5",
|
||||||
"style-loader": "^1.1.3",
|
"style-loader": "^1.1.3",
|
||||||
"webpack": "^4.41.6",
|
"webpack": "^4.41.6",
|
||||||
"webpack-cli": "^3.3.11",
|
"webpack-cli": "^3.3.11",
|
||||||
|
@ -39,7 +39,6 @@ use futures::future::FutureExt;
|
|||||||
use hyper::service::{make_service_fn, service_fn};
|
use hyper::service::{make_service_fn, service_fn};
|
||||||
use log::{info, warn};
|
use log::{info, warn};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::pin::Pin;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
@ -199,13 +198,13 @@ pub async fn run(args: &Args) -> Result<(), Error> {
|
|||||||
|
|
||||||
let time_zone_name = resolve_zone()?;
|
let time_zone_name = resolve_zone()?;
|
||||||
info!("Resolved timezone: {}", &time_zone_name);
|
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(),
|
db: db.clone(),
|
||||||
ui_dir: Some(&args.ui_dir),
|
ui_dir: Some(&args.ui_dir),
|
||||||
allow_unauthenticated_permissions: args.allow_unauthenticated_permissions.clone(),
|
allow_unauthenticated_permissions: args.allow_unauthenticated_permissions.clone(),
|
||||||
trust_forward_hdrs: args.trust_forward_hdrs,
|
trust_forward_hdrs: args.trust_forward_hdrs,
|
||||||
time_zone_name,
|
time_zone_name,
|
||||||
})?;
|
})?);
|
||||||
|
|
||||||
// Start a streamer for each stream.
|
// Start a streamer for each stream.
|
||||||
let shutdown_streamers = Arc::new(AtomicBool::new(false));
|
let shutdown_streamers = Arc::new(AtomicBool::new(false));
|
||||||
@ -283,8 +282,8 @@ pub async fn run(args: &Args) -> Result<(), Error> {
|
|||||||
// Start the web interface.
|
// Start the web interface.
|
||||||
let make_svc = make_service_fn(move |_conn| {
|
let make_svc = make_service_fn(move |_conn| {
|
||||||
futures::future::ok::<_, std::convert::Infallible>(service_fn({
|
futures::future::ok::<_, std::convert::Infallible>(service_fn({
|
||||||
let mut s = s.clone();
|
let svc = Arc::clone(&svc);
|
||||||
move |req| Pin::from(s.serve(req))
|
move |req| Arc::clone(&svc).serve(req)
|
||||||
}))
|
}))
|
||||||
});
|
});
|
||||||
let server = ::hyper::server::Server::bind(&args.http_addr)
|
let server = ::hyper::server::Server::bind(&args.http_addr)
|
||||||
|
776
src/web.rs
776
src/web.rs
@ -31,7 +31,7 @@
|
|||||||
use base::clock::Clocks;
|
use base::clock::Clocks;
|
||||||
use base::{ErrorKind, bail_t};
|
use base::{ErrorKind, bail_t};
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use crate::body::{Body, BoxedError};
|
use crate::body::Body;
|
||||||
use crate::json;
|
use crate::json;
|
||||||
use crate::mp4;
|
use crate::mp4;
|
||||||
use bytes::{BufMut, BytesMut};
|
use bytes::{BufMut, BytesMut};
|
||||||
@ -42,30 +42,24 @@ use db::dir::SampleFileDir;
|
|||||||
use failure::{Error, bail, format_err};
|
use failure::{Error, bail, format_err};
|
||||||
use fnv::FnvHashMap;
|
use fnv::FnvHashMap;
|
||||||
use futures::sink::SinkExt;
|
use futures::sink::SinkExt;
|
||||||
use futures::future::{self, Future, TryFutureExt};
|
|
||||||
use futures::stream::StreamExt;
|
use futures::stream::StreamExt;
|
||||||
use http::{Request, Response, status::StatusCode};
|
use http::{Request, Response, status::StatusCode};
|
||||||
use http::header::{self, HeaderValue};
|
use http::header::{self, HeaderValue};
|
||||||
|
use http_serve::dir::FsDir;
|
||||||
use log::{debug, info, warn};
|
use log::{debug, info, warn};
|
||||||
|
use memchr::memchr;
|
||||||
use nom::IResult;
|
use nom::IResult;
|
||||||
use nom::bytes::complete::{take_while1, tag};
|
use nom::bytes::complete::{take_while1, tag};
|
||||||
use nom::combinator::{all_consuming, map, map_res, opt};
|
use nom::combinator::{all_consuming, map, map_res, opt};
|
||||||
use nom::sequence::{preceded, tuple};
|
use nom::sequence::{preceded, tuple};
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::cmp;
|
use std::cmp;
|
||||||
use std::fs;
|
|
||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::pin::Pin;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio_tungstenite::tungstenite;
|
use tokio_tungstenite::tungstenite;
|
||||||
use url::form_urlencoded;
|
use url::form_urlencoded;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
type BoxedFuture = Box<dyn Future<Output = Result<Response<Body>, BoxedError>> +
|
|
||||||
Sync + Send + 'static>;
|
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq)]
|
#[derive(Debug, Eq, PartialEq)]
|
||||||
enum Path {
|
enum Path {
|
||||||
TopLevel, // "/api/"
|
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 {
|
struct Caller {
|
||||||
permissions: db::Permissions,
|
permissions: db::Permissions,
|
||||||
session: Option<json::Session>,
|
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>>;
|
type ResponseResult = Result<Response<Body>, Response<Body>>;
|
||||||
|
|
||||||
fn serve_json<T: serde::ser::Serialize>(req: &Request<hyper::Body>, out: &T) -> ResponseResult {
|
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)
|
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 {
|
fn top_level(&self, req: &Request<::hyper::Body>, caller: Caller) -> ResponseResult {
|
||||||
let mut days = false;
|
let mut days = false;
|
||||||
let mut camera_configs = false;
|
let mut camera_configs = false;
|
||||||
@ -523,14 +795,30 @@ impl ServiceInner {
|
|||||||
Ok(http_serve::serve(mp4, req))
|
Ok(http_serve::serve(mp4, req))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn static_file(&self, req: &Request<::hyper::Body>, path: &str) -> ResponseResult {
|
async fn static_file(&self, req: Request<hyper::Body>) -> ResponseResult {
|
||||||
let s = self.ui_files.get(path).ok_or_else(|| not_found("no such static file"))?;
|
let dir = self.ui_dir.clone()
|
||||||
let f = tokio::task::block_in_place(move || {
|
.ok_or_else(|| not_found("--ui-dir not configured; no static files available."))?;
|
||||||
fs::File::open(&s.path).map_err(internal_server_err)
|
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();
|
let mut hdrs = http::HeaderMap::new();
|
||||||
hdrs.insert(header::CONTENT_TYPE, s.mime.clone());
|
node.add_encoding_headers(&mut hdrs);
|
||||||
let e = http_serve::ChunkedReadFile::new(f, hdrs).map_err(internal_server_err)?;
|
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))
|
Ok(http_serve::serve(e, &req))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -573,18 +861,19 @@ impl ServiceInner {
|
|||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn login(&self, req: &Request<::hyper::Body>, body: Bytes) -> ResponseResult {
|
async fn login(&self, mut req: Request<::hyper::Body>) -> ResponseResult {
|
||||||
let r: json::LoginRequest = serde_json::from_slice(&body)
|
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()))?;
|
.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 = req.headers().get(header::HOST).ok_or_else(|| bad_req("missing Host header!"))?;
|
||||||
let host = host.as_bytes();
|
let host = host.as_bytes();
|
||||||
let domain = match ::memchr::memchr(b':', host) {
|
let domain = match memchr(b':', host) {
|
||||||
Some(colon) => &host[0..colon],
|
Some(colon) => &host[0..colon],
|
||||||
None => host,
|
None => host,
|
||||||
}.to_owned();
|
}.to_owned();
|
||||||
let mut l = self.db.lock();
|
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) |
|
let flags = (auth::SessionFlag::HttpOnly as i32) |
|
||||||
(auth::SessionFlag::SameSite as i32) |
|
(auth::SessionFlag::SameSite as i32) |
|
||||||
(auth::SessionFlag::SameSiteStrict as i32) |
|
(auth::SessionFlag::SameSiteStrict as i32) |
|
||||||
@ -610,13 +899,14 @@ impl ServiceInner {
|
|||||||
.body(b""[..].into()).unwrap())
|
.body(b""[..].into()).unwrap())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn logout(&self, req: &Request<hyper::Body>, body: Bytes) -> ResponseResult {
|
async fn logout(&self, mut req: Request<hyper::Body>) -> ResponseResult {
|
||||||
let r: json::LogoutRequest = serde_json::from_slice(&body)
|
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()))?;
|
.map_err(|e| bad_req(e.to_string()))?;
|
||||||
|
|
||||||
let mut res = Response::new(b""[..].into());
|
let mut res = Response::new(b""[..].into());
|
||||||
if let Some(sid) = extract_sid(req) {
|
if let Some(sid) = extract_sid(&req) {
|
||||||
let authreq = self.authreq(req);
|
let authreq = self.authreq(&req);
|
||||||
let mut l = self.db.lock();
|
let mut l = self.db.lock();
|
||||||
let hash = sid.hash();
|
let hash = sid.hash();
|
||||||
let need_revoke = match l.authenticate_session(authreq.clone(), &hash) {
|
let need_revoke = match l.authenticate_session(authreq.clone(), &hash) {
|
||||||
@ -651,12 +941,13 @@ impl ServiceInner {
|
|||||||
Ok(res)
|
Ok(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn post_signals(&self, req: &Request<hyper::Body>, caller: Caller, body: Bytes)
|
async fn post_signals(&self, mut req: Request<hyper::Body>, caller: Caller)
|
||||||
-> ResponseResult {
|
-> ResponseResult {
|
||||||
if !caller.permissions.update_signals {
|
if !caller.permissions.update_signals {
|
||||||
return Err(plain_response(StatusCode::UNAUTHORIZED, "update_signals required"));
|
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()))?;
|
.map_err(|e| bad_req(e.to_string()))?;
|
||||||
let mut l = self.db.lock();
|
let mut l = self.db.lock();
|
||||||
let now = recording::Time::new(self.db.clocks().realtime());
|
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)?;
|
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,
|
time_90k: now.0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -739,331 +1030,44 @@ impl ServiceInner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn csrf_matches(csrf: &str, session: auth::SessionHash) -> bool {
|
#[derive(Debug, Eq, PartialEq)]
|
||||||
let mut b64 = [0u8; 32];
|
struct StaticFileRequest<'a> {
|
||||||
session.encode_base64(&mut b64);
|
path: &'a str,
|
||||||
::ring::constant_time::verify_slices_are_equal(&b64[..], csrf.as_bytes()).is_ok()
|
immutable: bool,
|
||||||
|
mime: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extracts `s` cookie from the HTTP request. Does not authenticate.
|
impl<'a> StaticFileRequest<'a> {
|
||||||
fn extract_sid(req: &Request<hyper::Body>) -> Option<auth::RawSessionId> {
|
fn parse(path: &'a str) -> Option<Self> {
|
||||||
let hdr = match req.headers().get(header::COOKIE) {
|
if !path.starts_with("/") {
|
||||||
None => return 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
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns a future separating the request from its JSON body.
|
let (path, immutable) = match &path[1..] {
|
||||||
///
|
"" => ("index.html", false),
|
||||||
/// If this is not a `POST` or the body's `Content-Type` is not
|
p => (p, true),
|
||||||
/// `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)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Service(Arc::new(ServiceInner {
|
let last_dot = match path.rfind('.') {
|
||||||
db: config.db,
|
None => return None,
|
||||||
dirs_by_stream_id,
|
Some(d) => d,
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
for e in r {
|
let ext = &path[last_dot+1..];
|
||||||
let e = match e {
|
let mime = match ext {
|
||||||
Ok(e) => e,
|
"html" => "text/html",
|
||||||
Err(e) => {
|
"ico" => "image/x-icon",
|
||||||
warn!("Error searching UI directory; may be missing files. Error was: {}", e);
|
"js" | "map" => "text/javascript",
|
||||||
continue;
|
"json" => "application/json",
|
||||||
},
|
"png" => "image/png",
|
||||||
};
|
"webapp" => "application/x-web-app-manifest+json",
|
||||||
let (p, mime) = match e.file_name().to_str() {
|
_ => return None
|
||||||
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 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(
|
Some(StaticFileRequest {
|
||||||
&self, open_id: u32, stream_id: i32,
|
path,
|
||||||
ws: &mut tokio_tungstenite::WebSocketStream<hyper::upgrade::Upgraded>,
|
immutable,
|
||||||
live: db::LiveSegment) -> Result<(), Error> {
|
mime,
|
||||||
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())),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1074,7 +1078,8 @@ mod tests {
|
|||||||
use log::info;
|
use log::info;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use super::Segments;
|
use std::sync::Arc;
|
||||||
|
use super::{Segments, StaticFileRequest};
|
||||||
|
|
||||||
struct Server {
|
struct Server {
|
||||||
db: TestDb<base::clock::RealClocks>,
|
db: TestDb<base::clock::RealClocks>,
|
||||||
@ -1088,17 +1093,17 @@ mod tests {
|
|||||||
fn new(allow_unauthenticated_permissions: Option<db::Permissions>) -> Server {
|
fn new(allow_unauthenticated_permissions: Option<db::Permissions>) -> Server {
|
||||||
let db = TestDb::new(base::clock::RealClocks {});
|
let db = TestDb::new(base::clock::RealClocks {});
|
||||||
let (shutdown_tx, shutdown_rx) = futures::channel::oneshot::channel::<()>();
|
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(),
|
db: db.db.clone(),
|
||||||
ui_dir: None,
|
ui_dir: None,
|
||||||
allow_unauthenticated_permissions,
|
allow_unauthenticated_permissions,
|
||||||
trust_forward_hdrs: true,
|
trust_forward_hdrs: true,
|
||||||
time_zone_name: "".to_owned(),
|
time_zone_name: "".to_owned(),
|
||||||
}).unwrap();
|
}).unwrap());
|
||||||
let make_svc = hyper::service::make_service_fn(move |_conn| {
|
let make_svc = hyper::service::make_service_fn(move |_conn| {
|
||||||
futures::future::ok::<_, std::convert::Infallible>(hyper::service::service_fn({
|
futures::future::ok::<_, std::convert::Infallible>(hyper::service::service_fn({
|
||||||
let mut s = service.clone();
|
let s = Arc::clone(&service);
|
||||||
move |req| std::pin::Pin::from(s.serve(req))
|
move |req| Arc::clone(&s).serve(req)
|
||||||
}))
|
}))
|
||||||
});
|
});
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
@ -1216,6 +1221,24 @@ mod tests {
|
|||||||
assert_eq!(Path::decode("/api/junk"), Path::NotFound);
|
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]
|
#[test]
|
||||||
fn test_segments() {
|
fn test_segments() {
|
||||||
testutil::init();
|
testutil::init();
|
||||||
@ -1364,6 +1387,7 @@ mod bench {
|
|||||||
use db::testutil::{self, TestDb};
|
use db::testutil::{self, TestDb};
|
||||||
use hyper;
|
use hyper;
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
|
use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
struct Server {
|
struct Server {
|
||||||
@ -1376,17 +1400,17 @@ mod bench {
|
|||||||
let db = TestDb::new(::base::clock::RealClocks {});
|
let db = TestDb::new(::base::clock::RealClocks {});
|
||||||
let test_camera_uuid = db.test_camera_uuid;
|
let test_camera_uuid = db.test_camera_uuid;
|
||||||
testutil::add_dummy_recordings_to_db(&db.db, 1440);
|
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(),
|
db: db.db.clone(),
|
||||||
ui_dir: None,
|
ui_dir: None,
|
||||||
allow_unauthenticated_permissions: Some(db::Permissions::default()),
|
allow_unauthenticated_permissions: Some(db::Permissions::default()),
|
||||||
trust_forward_hdrs: false,
|
trust_forward_hdrs: false,
|
||||||
time_zone_name: "".to_owned(),
|
time_zone_name: "".to_owned(),
|
||||||
}).unwrap();
|
}).unwrap());
|
||||||
let make_svc = hyper::service::make_service_fn(move |_conn| {
|
let make_svc = hyper::service::make_service_fn(move |_conn| {
|
||||||
futures::future::ok::<_, std::convert::Infallible>(hyper::service::service_fn({
|
futures::future::ok::<_, std::convert::Infallible>(hyper::service::service_fn({
|
||||||
let mut s = service.clone();
|
let s = Arc::clone(&service);
|
||||||
move |req| std::pin::Pin::from(s.serve(req))
|
move |req| Arc::clone(&s).serve(req)
|
||||||
}))
|
}))
|
||||||
});
|
});
|
||||||
let mut rt = tokio::runtime::Runtime::new().unwrap();
|
let mut rt = tokio::runtime::Runtime::new().unwrap();
|
||||||
|
@ -46,7 +46,7 @@ import 'jquery-ui/themes/base/tooltip.css';
|
|||||||
import 'jquery-ui/themes/base/theme.css';
|
import 'jquery-ui/themes/base/theme.css';
|
||||||
|
|
||||||
// This causes our custom css to be loaded after the above!
|
// This causes our custom css to be loaded after the above!
|
||||||
import './assets/index.css';
|
import './index.css';
|
||||||
|
|
||||||
// Get ui widgets themselves
|
// Get ui widgets themselves
|
||||||
import 'jquery-ui/ui/widgets/tooltip';
|
import 'jquery-ui/ui/widgets/tooltip';
|
||||||
@ -384,7 +384,7 @@ export default class NVRApplication {
|
|||||||
* Start the application.
|
* Start the application.
|
||||||
*/
|
*/
|
||||||
start() {
|
start() {
|
||||||
let nav = $('#nav');
|
const nav = $('#nav');
|
||||||
|
|
||||||
$('#toggle-nav').click(() => {
|
$('#toggle-nav').click(() => {
|
||||||
nav.toggle('slide');
|
nav.toggle('slide');
|
||||||
|
@ -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">☰</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>
|
|
||||||
<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 "fall back" 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 "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 |
@ -18,6 +18,7 @@ body {
|
|||||||
}
|
}
|
||||||
#nav {
|
#nav {
|
||||||
float: left;
|
float: left;
|
||||||
|
margin: 0 0.5em 0.5ex 0;
|
||||||
}
|
}
|
||||||
#session {
|
#session {
|
||||||
float: right;
|
float: right;
|
||||||
@ -32,17 +33,15 @@ body {
|
|||||||
|
|
||||||
#videos {
|
#videos {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding-top: 0.5em;
|
|
||||||
padding-left: 1em;
|
|
||||||
padding-right: 1em;
|
|
||||||
}
|
}
|
||||||
#videos tbody:after {
|
#videos tbody:after {
|
||||||
content: "";
|
content: "";
|
||||||
display: block;
|
display: block;
|
||||||
height: 3ex;
|
height: 3ex;
|
||||||
}
|
}
|
||||||
table.videos {
|
table#videos {
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
|
/*border-spacing: 0.5em 0.5ex;*/
|
||||||
}
|
}
|
||||||
tbody tr.name {
|
tbody tr.name {
|
||||||
font-size: 110%;
|
font-size: 110%;
|
||||||
@ -65,8 +64,8 @@ tr.r td {
|
|||||||
tr.r th,
|
tr.r th,
|
||||||
tr.r td {
|
tr.r td {
|
||||||
border: 0;
|
border: 0;
|
||||||
padding: 0.5ex 1.5em;
|
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
padding: 0.5ex 1.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
fieldset {
|
fieldset {
|
||||||
@ -80,9 +79,6 @@ fieldset legend {
|
|||||||
#from, #to {
|
#from, #to {
|
||||||
padding-right: 0.75em;
|
padding-right: 0.75em;
|
||||||
}
|
}
|
||||||
#st {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#range {
|
#range {
|
||||||
padding: 0.5em 0;
|
padding: 0.5em 0;
|
||||||
@ -96,3 +92,17 @@ video {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 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
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 NVRApplication from './NVRApplication';
|
||||||
|
|
||||||
import $ from 'jquery';
|
import $ from 'jquery';
|
||||||
import './favicon.ico';
|
|
||||||
|
|
||||||
// On document load, start application
|
// On document load, start application
|
||||||
$(function() {
|
$(function() {
|
||||||
|
@ -146,16 +146,14 @@ export default class MoonfireAPI {
|
|||||||
* Start a new AJAX request with the specified URL.
|
* Start a new AJAX request with the specified URL.
|
||||||
*
|
*
|
||||||
* @param {String} url URL to use
|
* @param {String} url URL to use
|
||||||
* @param {String} cacheOk True if cached results are OK
|
|
||||||
* @return {Request} jQuery request type
|
* @return {Request} jQuery request type
|
||||||
*/
|
*/
|
||||||
request(url, cacheOk = false) {
|
request(url) {
|
||||||
return $.ajax(url, {
|
return $.ajax(url, {
|
||||||
dataType: 'json',
|
dataType: 'json',
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
},
|
},
|
||||||
cache: cacheOk,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -112,7 +112,8 @@ export default class RecordingsView {
|
|||||||
$('<tr class="hdr">').append(
|
$('<tr class="hdr">').append(
|
||||||
$(
|
$(
|
||||||
_columnOrder
|
_columnOrder
|
||||||
.map((name) => '<th>' + _columnLabels[name] + '</th>')
|
.map((name) =>
|
||||||
|
`<th class="${name}">${_columnLabels[name]}</th>`)
|
||||||
.join('')
|
.join('')
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
@ -271,7 +272,7 @@ export default class RecordingsView {
|
|||||||
$('tr.r', tbody).remove();
|
$('tr.r', tbody).remove();
|
||||||
this.recordings_.forEach((r) => {
|
this.recordings_.forEach((r) => {
|
||||||
const row = $('<tr class="r" />');
|
const row = $('<tr class="r" />');
|
||||||
row.append(_columnOrder.map(() => $('<td/>')));
|
row.append(_columnOrder.map((c) => $(`<td class="${c}"/>`)));
|
||||||
row.on('click', () => {
|
row.on('click', () => {
|
||||||
console.log('Video clicked');
|
console.log('Video clicked');
|
||||||
if (this.clickHandler_ !== null) {
|
if (this.clickHandler_ !== null) {
|
||||||
|
@ -52,7 +52,7 @@ export default class VideoDialogView {
|
|||||||
* This does not attach the player to the DOM anywhere! In fact, construction
|
* 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.
|
* 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
|
* 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() {}
|
constructor() {}
|
||||||
|
|
||||||
@ -80,13 +80,13 @@ export default class VideoDialogView {
|
|||||||
* @return {VideoDialogView} Returns "this" for chaining.
|
* @return {VideoDialogView} Returns "this" for chaining.
|
||||||
*/
|
*/
|
||||||
play(title, width, url) {
|
play(title, width, url) {
|
||||||
|
const videoDomElement = this.videoElement_[0];
|
||||||
this.dialogElement_.dialog({
|
this.dialogElement_.dialog({
|
||||||
title: title,
|
title: title,
|
||||||
width: width,
|
width: width,
|
||||||
close: () => {
|
close: () => {
|
||||||
const videoDOMElement = this.videoElement_[0];
|
videoDomElement.pause();
|
||||||
videoDOMElement.pause();
|
videoDomElement.src = ''; // Remove current source to stop loading
|
||||||
videoDOMElement.src = ''; // Remove current source to stop loading
|
|
||||||
this.videoElement_ = null;
|
this.videoElement_ = null;
|
||||||
this.dialogElement_.remove();
|
this.dialogElement_.remove();
|
||||||
this.dialogElement_ = null;
|
this.dialogElement_ = null;
|
||||||
@ -95,6 +95,21 @@ export default class VideoDialogView {
|
|||||||
// Now that dialog is up, set the src so video starts
|
// Now that dialog is up, set the src so video starts
|
||||||
console.log('Video url: ' + url);
|
console.log('Video url: ' + url);
|
||||||
this.videoElement_.attr('src', 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;
|
return this;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
// vim: set et sw=2 ts=2:
|
// vim: set et sw=2 ts=2:
|
||||||
//
|
//
|
||||||
// This file is part of Moonfire NVR, a security camera network video recorder.
|
// 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
|
// 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
|
// it under the terms of the GNU General Public License as published by
|
||||||
@ -32,6 +32,7 @@
|
|||||||
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const webpack = require('webpack');
|
const webpack = require('webpack');
|
||||||
|
const FaviconsWebpackPlugin = require('favicons-webpack-plugin')
|
||||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
@ -39,7 +40,7 @@ module.exports = {
|
|||||||
nvr: './ui-src/index.js',
|
nvr: './ui-src/index.js',
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
filename: '[name].bundle.js',
|
filename: '[name].[chunkhash].js',
|
||||||
path: path.resolve('./ui-dist/'),
|
path: path.resolve('./ui-dist/'),
|
||||||
publicPath: '/',
|
publicPath: '/',
|
||||||
},
|
},
|
||||||
@ -63,15 +64,11 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
test: /\.png$/,
|
test: /\.png$/,
|
||||||
use: ['file-loader'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
test: /\.ico$/,
|
|
||||||
use: [
|
use: [
|
||||||
{
|
{
|
||||||
loader: 'file-loader',
|
loader: 'file-loader',
|
||||||
options: {
|
options: {
|
||||||
name: '[name].[ext]',
|
name: '[name].[contenthash].[ext]',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@ -86,9 +83,7 @@ module.exports = {
|
|||||||
plugins: [
|
plugins: [
|
||||||
new webpack.IgnorePlugin(/\.\/locale$/),
|
new webpack.IgnorePlugin(/\.\/locale$/),
|
||||||
new HtmlWebpackPlugin({
|
new HtmlWebpackPlugin({
|
||||||
title: 'Moonfire NVR',
|
template: './ui-src/index.html',
|
||||||
filename: 'index.html',
|
|
||||||
template: './ui-src/assets/index.html',
|
|
||||||
}),
|
}),
|
||||||
new webpack.NormalModuleReplacementPlugin(
|
new webpack.NormalModuleReplacementPlugin(
|
||||||
/node_modules\/moment\/moment\.js$/,
|
/node_modules\/moment\/moment\.js$/,
|
||||||
@ -98,5 +93,16 @@ module.exports = {
|
|||||||
/node_modules\/moment-timezone\/index\.js$/,
|
/node_modules\/moment-timezone\/index\.js$/,
|
||||||
'./builds/moment-timezone-with-data-2012-2022.min.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,
|
minimize: false,
|
||||||
namedChunks: true,
|
namedChunks: true,
|
||||||
},
|
},
|
||||||
|
output: {
|
||||||
|
filename: '[name].[hash].js',
|
||||||
|
},
|
||||||
devServer: {
|
devServer: {
|
||||||
inline: true,
|
inline: true,
|
||||||
port: process.env.MOONFIRE_DEV_PORT || 3000,
|
port: process.env.MOONFIRE_DEV_PORT || 3000,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user