mirror of
https://github.com/scottlamb/moonfire-nvr.git
synced 2025-01-12 15:33:22 -05:00
Merge branch 'master' into new-schema
This commit is contained in:
commit
6187aa64cf
@ -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
81
Cargo.lock
generated
@ -112,12 +112,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base-x"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b20b618342cf9891c292c4f5ac2cde7287cc5c87e87e9c769d617793607dec1"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.9.3"
|
||||
@ -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]]
|
||||
|
@ -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"
|
||||
|
@ -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`,
|
||||
|
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": {
|
||||
"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",
|
||||
|
@ -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)
|
||||
|
776
src/web.rs
776
src/web.rs
@ -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();
|
||||
|
@ -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');
|
||||
|
@ -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 {
|
||||
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
110
ui-src/index.html
Normal file
@ -0,0 +1,110 @@
|
||||
<!DOCTYPE html>
|
||||
<head>
|
||||
<title>Moonfire NVR</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
|
||||
<div id="top">
|
||||
<a id="toggle-nav">☰</a>
|
||||
<span id="session"></div>
|
||||
</div>
|
||||
<div id="nav">
|
||||
<form action="#">
|
||||
<fieldset>
|
||||
<legend>Streams</legend>
|
||||
<table id="streams"></table>
|
||||
</fieldset>
|
||||
<fieldset id="datetime">
|
||||
<legend>Date & Time Range</legend>
|
||||
<div id="from">
|
||||
<legend>From</legend>
|
||||
<div id="start-date"></div>
|
||||
<label for="start-time">Time:</label>
|
||||
<input id="start-time" name="start-time" type="text"
|
||||
max-length="20"
|
||||
title="Starting time within the day. Blank for the beginning of
|
||||
the day. Otherwise HH:mm[:ss[:FFFFF]][+-HH:mm], where F is
|
||||
90,000ths of a second. Timezone is normally left out; it's useful
|
||||
once a year during the ambiguous times of the "fall
|
||||
back" hour.">
|
||||
</div>
|
||||
<div id="to">
|
||||
<legend>To</legend>
|
||||
<div id="range">
|
||||
<input type="radio" name="end-date-type" id="end-date-same" checked>
|
||||
<label for="end-date-same">Same Day</label>
|
||||
<input type="radio" name="end-date-type" id="end-date-other">
|
||||
<label for="end-date-other">Other Day</label><br/>
|
||||
</div>
|
||||
<div id="end-date"></div>
|
||||
<label for="end-time">Time:</label>
|
||||
<input id="end-time" name="end-time" type="text" max-length="20"
|
||||
title="Ending time within the day. Blank for the end of the day.
|
||||
Otherwise HH:mm[:ss[:FFFFF]][+-HH:mm], where F is 90,000ths of a
|
||||
second. Timezone is normally left out; it's useful once a year
|
||||
during the ambiguous times of the "fall back" hour.">
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Recordings Display</legend>
|
||||
<label for="split">Max Video Duration:</label>
|
||||
<select name="split" id="split">
|
||||
<option value="324000000">1 hour</option>
|
||||
<option value="1296000000">4 hours</option>
|
||||
<option value="7776000000">24 hours</option>
|
||||
<option value="infinite">infinite</option>
|
||||
</select><br>
|
||||
<input type="checkbox" checked id="trim" name="trim">
|
||||
<label for="trim" title="Trim each segment of video so that it is fully
|
||||
contained within the select time range. When this is not selected,
|
||||
all segments will overlap with the selected time range but may start
|
||||
and/or end outside it.">Trim Segment Start & End</label><br>
|
||||
<input type="checkbox" checked id="ts" name="ts">
|
||||
<label for="ts" title="Include a text track in each .mp4 with the
|
||||
timestamp at which the video was recorded.">Timestamp Track</label><br>
|
||||
<label for="timefmt" title="The time format to use when displaying start
|
||||
and end times in the video segment list. Note this currently doesn't
|
||||
apply to the start/entry inputs.">Time Format:</label>
|
||||
<select name="timefmt" id="timefmt">
|
||||
<option value="MM/DD/YY hh:mm A">US-short</option>
|
||||
<option value="MM/DD/YYYY hh:mm:ss A">US</option>
|
||||
<option value="MM/DD/YY HH:mm" selected>Military-short</option>
|
||||
<option value="MM/DD/YYYY HH:mm:ss">Military</option>
|
||||
<option value="DD.MM.YY HH:mm">EU-short</option>
|
||||
<option value="DD-MM-YYYY HH:mm:ss">EU</option>
|
||||
<option value="YY-MM-DD hh:mm A">ISO-short (12h)</option>
|
||||
<option value="YY-MM-DD HH:mm">ISO-short (24h)</option>
|
||||
<option value="YYYY-MM-DD hh:mm:ss A">ISO (12h)</option>
|
||||
<option value="YYYY-MM-DD HH:mm:ss">ISO (24h)</option>
|
||||
<option value="YYYY-MM-DD HH:mm:ss">ISO 8601-like (No TZ)</option>
|
||||
<option value="YYYY-MM-DDTHH:mm:ss">ISO 8601 (No TZ)</option>
|
||||
<option value="YYYY-MM-DDTHH:mm:ssZ">ISO 8601</option>
|
||||
<option value="YYYY-MM-DDTHH:mm:ss:FFFFFZ">Internal</option>
|
||||
</select>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
<table id="videos"></table>
|
||||
<div id="login">
|
||||
<form>
|
||||
<fieldset>
|
||||
<table>
|
||||
<tr>
|
||||
<td><label for="login-username">Username:</label></td>
|
||||
<td><input type="text" id="login-username" name="username"
|
||||
autocomplete="username"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="login-password">Password:</label></td>
|
||||
<td><input type="password" id="login-password" name="password"
|
||||
autocomplete="current-password"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td><input type="submit" tabindex="-1" style="position:absolute; top:-1000px"></td>
|
||||
</tr>
|
||||
</table>
|
||||
<p id="login-error"></p>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
@ -33,7 +33,6 @@
|
||||
import NVRApplication from './NVRApplication';
|
||||
|
||||
import $ from 'jquery';
|
||||
import './favicon.ico';
|
||||
|
||||
// On document load, start application
|
||||
$(function() {
|
||||
|
@ -146,16 +146,14 @@ export default class MoonfireAPI {
|
||||
* Start a new AJAX request with the specified URL.
|
||||
*
|
||||
* @param {String} url URL to use
|
||||
* @param {String} cacheOk True if cached results are OK
|
||||
* @return {Request} jQuery request type
|
||||
*/
|
||||
request(url, cacheOk = false) {
|
||||
request(url) {
|
||||
return $.ajax(url, {
|
||||
dataType: 'json',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
cache: cacheOk,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
// vim: set et sw=2 ts=2:
|
||||
//
|
||||
// This file is part of Moonfire NVR, a security camera network video recorder.
|
||||
// Copyright (C) 2018 The Moonfire NVR Authors
|
||||
// Copyright (C) 2018-2020 The Moonfire NVR Authors
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
@ -32,6 +32,7 @@
|
||||
|
||||
const path = require('path');
|
||||
const webpack = require('webpack');
|
||||
const FaviconsWebpackPlugin = require('favicons-webpack-plugin')
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
|
||||
module.exports = {
|
||||
@ -39,7 +40,7 @@ module.exports = {
|
||||
nvr: './ui-src/index.js',
|
||||
},
|
||||
output: {
|
||||
filename: '[name].bundle.js',
|
||||
filename: '[name].[chunkhash].js',
|
||||
path: path.resolve('./ui-dist/'),
|
||||
publicPath: '/',
|
||||
},
|
||||
@ -63,15 +64,11 @@ module.exports = {
|
||||
},
|
||||
{
|
||||
test: /\.png$/,
|
||||
use: ['file-loader'],
|
||||
},
|
||||
{
|
||||
test: /\.ico$/,
|
||||
use: [
|
||||
{
|
||||
loader: 'file-loader',
|
||||
options: {
|
||||
name: '[name].[ext]',
|
||||
name: '[name].[contenthash].[ext]',
|
||||
},
|
||||
},
|
||||
],
|
||||
@ -86,9 +83,7 @@ module.exports = {
|
||||
plugins: [
|
||||
new webpack.IgnorePlugin(/\.\/locale$/),
|
||||
new HtmlWebpackPlugin({
|
||||
title: 'Moonfire NVR',
|
||||
filename: 'index.html',
|
||||
template: './ui-src/assets/index.html',
|
||||
template: './ui-src/index.html',
|
||||
}),
|
||||
new webpack.NormalModuleReplacementPlugin(
|
||||
/node_modules\/moment\/moment\.js$/,
|
||||
@ -98,5 +93,16 @@ module.exports = {
|
||||
/node_modules\/moment-timezone\/index\.js$/,
|
||||
'./builds/moment-timezone-with-data-2012-2022.min.js'
|
||||
),
|
||||
new FaviconsWebpackPlugin({
|
||||
logo: './ui-src/favicon.svg',
|
||||
mode: 'webapp',
|
||||
devMode: 'light',
|
||||
prefix: 'favicons-[hash]/',
|
||||
favicons: {
|
||||
coast: false,
|
||||
windows: false,
|
||||
yandex: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
@ -44,6 +44,9 @@ module.exports = merge(baseConfig, {
|
||||
minimize: false,
|
||||
namedChunks: true,
|
||||
},
|
||||
output: {
|
||||
filename: '[name].[hash].js',
|
||||
},
|
||||
devServer: {
|
||||
inline: true,
|
||||
port: process.env.MOONFIRE_DEV_PORT || 3000,
|
||||
|
Loading…
Reference in New Issue
Block a user