UI preferences: #153 #155

This commit is contained in:
Scott Lamb 2021-09-01 15:01:42 -07:00
parent 33b3b669df
commit c42314edb5
11 changed files with 280 additions and 105 deletions

View File

@ -21,6 +21,7 @@ Status: **current**.
* [Request 1](#request-1) * [Request 1](#request-1)
* [Request 2](#request-2) * [Request 2](#request-2)
* [Request 3](#request-3) * [Request 3](#request-3)
* [`POST /api/users/<id>`](#post-apiusersid)
## Objective ## Objective
@ -42,7 +43,7 @@ All requests for JSON data should be sent with the header
### `POST /api/login` ### `POST /api/login`
The request should have an `application/json` body containing a dict with The request should have an `application/json` body containing a JSON object with
`username` and `password` keys. `username` and `password` keys.
On successful authentication, the server will return an HTTP 204 (no content) On successful authentication, the server will return an HTTP 204 (no content)
@ -81,11 +82,11 @@ Example request URI (with added whitespace between parameters):
&cameraConfigs=true &cameraConfigs=true
``` ```
The `application/json` response will have a dict as follows: The `application/json` response will have a JSON object as follows:
* `timeZoneName`: the name of the IANA time zone the server is using * `timeZoneName`: the name of the IANA time zone the server is using
to divide recordings into days as described further below. to divide recordings into days as described further below.
* `cameras`: a list of cameras. Each is a dict as follows: * `cameras`: a list of cameras. Each is a JSON object as follows:
* `uuid`: in text format * `uuid`: in text format
* `id`: an integer. The client doesn't ever need to send the id * `id`: an integer. The client doesn't ever need to send the id
back in API requests, but camera ids are helpful to know when debugging back in API requests, but camera ids are helpful to know when debugging
@ -93,12 +94,12 @@ The `application/json` response will have a dict as follows:
* `shortName`: a short name (typically one or two words) * `shortName`: a short name (typically one or two words)
* `description`: a longer description (typically a phrase or paragraph) * `description`: a longer description (typically a phrase or paragraph)
* `config`: (only included if request parameter `cameraConfigs` is true) * `config`: (only included if request parameter `cameraConfigs` is true)
a dictionary describing the configuration of the camera: a JSON object describing the configuration of the camera:
* `username` * `username`
* `password` * `password`
* `onvif_host` * `onvif_host`
* `streams`: a dict of stream type ("main" or "sub") to a dictionary * `streams`: a JSON object of stream type ("main" or "sub") to a JSON
describing the stream: object describing the stream:
* `id`: an integer. The client doesn't ever need to send the id * `id`: an integer. The client doesn't ever need to send the id
back in API requests, but stream ids are helpful to know when back in API requests, but stream ids are helpful to know when
debugging by reading logs or directly examining the debugging by reading logs or directly examining the
@ -119,7 +120,7 @@ The `application/json` response will have a dict as follows:
because it also includes the wasted portion of the final because it also includes the wasted portion of the final
filesystem block allocated to each file. filesystem block allocated to each file.
* `days`: (only included if request parameter `days` is true) * `days`: (only included if request parameter `days` is true)
dictionary representing calendar days (in the server's time zone) JSON object representing calendar days (in the server's time zone)
with non-zero total duration of recordings for that day. Currently with non-zero total duration of recordings for that day. Currently
this includes uncommitted and growing recordings. This is likely this includes uncommitted and growing recordings. This is likely
to change in a future release for to change in a future release for
@ -136,10 +137,10 @@ The `application/json` response will have a dict as follows:
might be 23 hours or 25 hours during spring forward or fall might be 23 hours or 25 hours during spring forward or fall
back, respectively. back, respectively.
* `config`: (only included if request parameter `cameraConfigs` is * `config`: (only included if request parameter `cameraConfigs` is
true) a dictionary describing the configuration of the stream: true) a JSON object describing the configuration of the stream:
* `rtsp_url` * `rtsp_url`
* `signals`: a list of all *signals* known to the server. Each is a dictionary * `signals`: a list of all *signals* known to the server. Each is a JSON
with the following properties: object with the following properties:
* `id`: an integer identifier. * `id`: an integer identifier.
* `shortName`: a unique, human-readable description of the signal * `shortName`: a unique, human-readable description of the signal
* `cameras`: a map of associated cameras' UUIDs to the type of association: * `cameras`: a map of associated cameras' UUIDs to the type of association:
@ -158,9 +159,14 @@ The `application/json` response will have a dict as follows:
as in the [HTML specification](https://html.spec.whatwg.org/#colours). as in the [HTML specification](https://html.spec.whatwg.org/#colours).
* `motion`: if present and true, directly associated cameras will be * `motion`: if present and true, directly associated cameras will be
considered to have motion when this signal is in this state. considered to have motion when this signal is in this state.
* `session`: if logged in, a dict with the following properties: * `user`: if authenticated, a JSON object:
* `username` * `name`: a human-readable name
* `csrf`: a cross-site request forgery token for use in `POST` requests. * `id`: an integer
* `preferences`: a JSON object
* `session`: an object, present only if authenticated via session cookie.
(In the future, it will be possible to instead authenticate via uid over
a Unix domain socket.)
* `csrf`: a cross-site request forgery token for use in `POST` requests.
Example response: Example response:
@ -239,9 +245,12 @@ Example response:
} }
} }
], ],
"session": { "user": {
"username": "slamb", "id": 1,
"csrf": "2DivvlnKUQ9JD4ao6YACBJm8XK4bFmOc" "name": "slamb",
"session": {
"csrf": "2DivvlnKUQ9JD4ao6YACBJm8XK4bFmOc"
}
} }
} }
``` ```
@ -671,18 +680,18 @@ analytics client starts up and analyzes all video segments recorded since it
last ran. These will specify beginning and end times. last ran. These will specify beginning and end times.
The request should have an `application/json` body describing the change to The request should have an `application/json` body describing the change to
make. It should be a dict with these attributes: make. It should be a JSON object with these attributes:
* `signalIds`: a list of signal ids to change. Must be sorted. * `signalIds`: a list of signal ids to change. Must be sorted.
* `states`: a list (one per `signalIds` entry) of states to set. * `states`: a list (one per `signalIds` entry) of states to set.
* `start`: the starting time of the change, as a dict of the form * `start`: the starting time of the change, as a JSON object of the form
`{'base': 'epoch', 'rel90k': t}` or `{'base': 'now', 'rel90k': t}`. In `{'base': 'epoch', 'rel90k': t}` or `{'base': 'now', 'rel90k': t}`. In
the `epoch` form, `rel90k` is 90 kHz units since 1970-01-01 00:00:00 UTC. the `epoch` form, `rel90k` is 90 kHz units since 1970-01-01 00:00:00 UTC.
In the `now` form, `rel90k` is relative to current time and may be In the `now` form, `rel90k` is relative to current time and may be
negative. negative.
* `end`: the ending time of the change, in the same form as `start`. * `end`: the ending time of the change, in the same form as `start`.
The response will be an `application/json` body dict with the following The response will be an `application/json` body JSON object with the following
attributes: attributes:
* `time90k`: the current time. When the request's `startTime90k` is absent * `time90k`: the current time. When the request's `startTime90k` is absent
@ -765,6 +774,22 @@ Response:
} }
``` ```
## `POST /api/users/<id>`
Currently this request only allows updating the preferences for the
currently-authenticated user. This is likely to change.
Expects a JSON object:
* `update`: sets the provided fields
* `precondition`: forces the request to fail with HTTP status 412
(Precondition failed) if the provided fields don't have the given value.
Currently both objects support a single field, `preferences`, which should be
a JSON dictionary.
Returns HTTP status 204 (No Content) on success.
[media-segment]: https://w3c.github.io/media-source/isobmff-byte-stream-format.html#iso-media-segments [media-segment]: https://w3c.github.io/media-source/isobmff-byte-stream-format.html#iso-media-segments
[init-segment]: https://w3c.github.io/media-source/isobmff-byte-stream-format.html#iso-init-segments [init-segment]: https://w3c.github.io/media-source/isobmff-byte-stream-format.html#iso-init-segments
[rfc-6381]: https://tools.ietf.org/html/rfc6381 [rfc-6381]: https://tools.ietf.org/html/rfc6381

2
server/Cargo.lock generated
View File

@ -1236,6 +1236,8 @@ dependencies = [
"protobuf-codegen-pure", "protobuf-codegen-pure",
"ring", "ring",
"rusqlite", "rusqlite",
"serde",
"serde_json",
"smallvec", "smallvec",
"tempfile", "tempfile",
"time", "time",

View File

@ -37,6 +37,8 @@ prettydiff = { git = "https://github.com/scottlamb/prettydiff", branch = "pr-upd
protobuf = { git = "https://github.com/stepancheg/rust-protobuf" } protobuf = { git = "https://github.com/stepancheg/rust-protobuf" }
ring = "0.16.2" ring = "0.16.2"
rusqlite = "0.25.3" rusqlite = "0.25.3"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
smallvec = "1.0" smallvec = "1.0"
tempfile = "3.2.0" tempfile = "3.2.0"
time = "0.1" time = "0.1"

View File

@ -13,6 +13,7 @@ use log::info;
use parking_lot::Mutex; use parking_lot::Mutex;
use protobuf::Message; use protobuf::Message;
use ring::rand::{SecureRandom, SystemRandom}; use ring::rand::{SecureRandom, SystemRandom};
use rusqlite::types::FromSqlError;
use rusqlite::{named_params, params, Connection, Transaction}; use rusqlite::{named_params, params, Connection, Transaction};
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::fmt; use std::fmt;
@ -34,6 +35,32 @@ pub(crate) fn set_test_config() {
)); ));
} }
#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, Default, Eq, PartialEq)]
pub struct UserPreferences(serde_json::Map<String, serde_json::Value>);
impl rusqlite::types::FromSql for UserPreferences {
fn column_result(value: rusqlite::types::ValueRef) -> rusqlite::types::FromSqlResult<Self> {
Ok(Self(match value {
rusqlite::types::ValueRef::Null => serde_json::Map::default(),
rusqlite::types::ValueRef::Text(t) => {
serde_json::from_slice(t).map_err(|e| FromSqlError::Other(Box::new(e)))?
}
_ => return Err(FromSqlError::InvalidType),
}))
}
}
impl rusqlite::types::ToSql for UserPreferences {
fn to_sql(&self) -> rusqlite::Result<rusqlite::types::ToSqlOutput<'_>> {
if self.0.is_empty() {
return Ok(rusqlite::types::Null.into());
}
Ok(serde_json::to_string(&self.0)
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(e.into()))?
.into())
}
}
enum UserFlag { enum UserFlag {
Disabled = 1, Disabled = 1,
} }
@ -48,6 +75,7 @@ pub struct User {
pub password_failure_count: i64, pub password_failure_count: i64,
pub unix_uid: Option<i32>, pub unix_uid: Option<i32>,
pub permissions: Permissions, pub permissions: Permissions,
pub preferences: UserPreferences,
/// True iff this `User` has changed since the last flush. /// True iff this `User` has changed since the last flush.
/// Only a couple things are flushed lazily: `password_failure_count` and (on upgrade to a new /// Only a couple things are flushed lazily: `password_failure_count` and (on upgrade to a new
@ -62,6 +90,7 @@ impl User {
username: self.username.clone(), username: self.username.clone(),
flags: self.flags, flags: self.flags,
set_password_hash: None, set_password_hash: None,
set_preferences: None,
unix_uid: self.unix_uid, unix_uid: self.unix_uid,
permissions: self.permissions.clone(), permissions: self.permissions.clone(),
} }
@ -87,6 +116,7 @@ pub struct UserChange {
pub username: String, pub username: String,
pub flags: i32, pub flags: i32,
set_password_hash: Option<Option<String>>, set_password_hash: Option<Option<String>>,
set_preferences: Option<UserPreferences>,
pub unix_uid: Option<i32>, pub unix_uid: Option<i32>,
pub permissions: Permissions, pub permissions: Permissions,
} }
@ -98,6 +128,7 @@ impl UserChange {
username, username,
flags: 0, flags: 0,
set_password_hash: None, set_password_hash: None,
set_preferences: None,
unix_uid: None, unix_uid: None,
permissions: Permissions::default(), permissions: Permissions::default(),
} }
@ -112,6 +143,10 @@ impl UserChange {
self.set_password_hash = Some(None); self.set_password_hash = Some(None);
} }
pub fn set_preferences(&mut self, preferences: UserPreferences) {
self.set_preferences = Some(preferences);
}
pub fn disable(&mut self) { pub fn disable(&mut self) {
self.flags |= UserFlag::Disabled as i32; self.flags |= UserFlag::Disabled as i32;
} }
@ -204,7 +239,7 @@ pub enum RevocationReason {
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct Session { pub struct Session {
user_id: i32, pub user_id: i32,
flags: i32, // bitmask of SessionFlag enum values flags: i32, // bitmask of SessionFlag enum values
domain: Option<Vec<u8>>, domain: Option<Vec<u8>>,
description: Option<String>, description: Option<String>,
@ -359,7 +394,8 @@ impl State {
password_id, password_id,
password_failure_count, password_failure_count,
unix_uid, unix_uid,
permissions permissions,
preferences
from from
user user
"#, "#,
@ -382,6 +418,7 @@ impl State {
unix_uid: row.get(6)?, unix_uid: row.get(6)?,
dirty: false, dirty: false,
permissions, permissions,
preferences: row.get(8)?,
}, },
); );
state.users_by_name.insert(name, id); state.users_by_name.insert(name, id);
@ -417,7 +454,8 @@ impl State {
password_failure_count = :password_failure_count, password_failure_count = :password_failure_count,
flags = :flags, flags = :flags,
unix_uid = :unix_uid, unix_uid = :unix_uid,
permissions = :permissions permissions = :permissions,
preferences = :preferences
where where
id = :id id = :id
"#, "#,
@ -427,6 +465,10 @@ impl State {
::std::collections::btree_map::Entry::Vacant(_) => panic!("missing uid {}!", id), ::std::collections::btree_map::Entry::Vacant(_) => panic!("missing uid {}!", id),
::std::collections::btree_map::Entry::Occupied(e) => e, ::std::collections::btree_map::Entry::Occupied(e) => e,
}; };
let preferences = change.set_preferences.unwrap_or_else(|| {
let u = e.get();
u.preferences.clone()
});
{ {
let (phash, pid, pcount) = match change.set_password_hash.as_ref() { let (phash, pid, pcount) = match change.set_password_hash.as_ref() {
None => { None => {
@ -448,6 +490,7 @@ impl State {
":unix_uid": &change.unix_uid, ":unix_uid": &change.unix_uid,
":id": &id, ":id": &id,
":permissions": &permissions, ":permissions": &permissions,
":preferences": &preferences,
})?; })?;
} }
let u = e.into_mut(); let u = e.into_mut();
@ -460,14 +503,17 @@ impl State {
u.flags = change.flags; u.flags = change.flags;
u.unix_uid = change.unix_uid; u.unix_uid = change.unix_uid;
u.permissions = change.permissions; u.permissions = change.permissions;
u.preferences = preferences;
Ok(u) Ok(u)
} }
fn add_user(&mut self, conn: &Connection, change: UserChange) -> Result<&User, Error> { fn add_user(&mut self, conn: &Connection, change: UserChange) -> Result<&User, Error> {
let mut stmt = conn.prepare_cached( let mut stmt = conn.prepare_cached(
r#" r#"
insert into user (username, password_hash, flags, unix_uid, permissions) insert into user (username, password_hash, flags, unix_uid, permissions,
values (:username, :password_hash, :flags, :unix_uid, :permissions) preferences)
values (:username, :password_hash, :flags, :unix_uid, :permissions,
:preferences)
"#, "#,
)?; )?;
let password_hash = change.set_password_hash.unwrap_or(None); let password_hash = change.set_password_hash.unwrap_or(None);
@ -475,12 +521,14 @@ impl State {
.permissions .permissions
.write_to_bytes() .write_to_bytes()
.expect("proto3->vec is infallible"); .expect("proto3->vec is infallible");
let preferences = change.set_preferences.unwrap_or_default();
stmt.execute(named_params! { stmt.execute(named_params! {
":username": &change.username[..], ":username": &change.username[..],
":password_hash": &password_hash, ":password_hash": &password_hash,
":flags": &change.flags, ":flags": &change.flags,
":unix_uid": &change.unix_uid, ":unix_uid": &change.unix_uid,
":permissions": &permissions, ":permissions": &permissions,
":preferences": &preferences,
})?; })?;
let id = conn.last_insert_rowid() as i32; let id = conn.last_insert_rowid() as i32;
self.users_by_name.insert(change.username.clone(), id); self.users_by_name.insert(change.username.clone(), id);
@ -499,6 +547,7 @@ impl State {
unix_uid: change.unix_uid, unix_uid: change.unix_uid,
dirty: false, dirty: false,
permissions: change.permissions, permissions: change.permissions,
preferences,
})) }))
} }
@ -1283,4 +1332,33 @@ mod tests {
assert!(u.permissions.view_video); assert!(u.permissions.view_video);
assert!(u.permissions.update_signals); assert!(u.permissions.update_signals);
} }
#[test]
fn preferences() {
testutil::init();
let mut conn = Connection::open_in_memory().unwrap();
db::init(&mut conn).unwrap();
let mut state = State::init(&conn).unwrap();
let mut change = UserChange::add_user("slamb".to_owned());
let mut preferences = UserPreferences::default();
preferences.0.insert("foo".to_string(), 42.into());
change.set_preferences(preferences.clone());
let u = state.apply(&conn, change).unwrap();
assert_eq!(preferences, u.preferences);
let mut change = u.change();
preferences.0.insert("bar".to_string(), 26.into());
change.set_preferences(preferences.clone());
let u = state.apply(&conn, change).unwrap();
assert_eq!(preferences, u.preferences);
let uid = u.id;
{
let tx = conn.transaction().unwrap();
state.flush(&tx).unwrap();
tx.commit().unwrap();
}
let state = State::init(&conn).unwrap();
let u = state.users_by_id().get(&uid).unwrap();
assert_eq!(preferences, u.preferences);
}
} }

View File

@ -341,7 +341,11 @@ create table user (
-- Permissions available for newly created tokens or when authenticating via -- Permissions available for newly created tokens or when authenticating via
-- unix_uid above. A serialized "Permissions" protobuf. -- unix_uid above. A serialized "Permissions" protobuf.
permissions blob not null default X'' permissions blob not null default X'',
-- Preferences controlled by the user. A JSON object, or null to represent
-- the empty object. Can be returned and modified through the API.
preferences text
); );
-- A single session, whether for browser or robot use. -- A single session, whether for browser or robot use.

View File

@ -5,6 +5,7 @@
/// Upgrades a version 6 schema to a version 7 schema. /// Upgrades a version 6 schema to a version 7 schema.
use failure::Error; use failure::Error;
pub fn run(_args: &super::Args, _tx: &rusqlite::Transaction) -> Result<(), Error> { pub fn run(_args: &super::Args, tx: &rusqlite::Transaction) -> Result<(), Error> {
tx.execute_batch("alter table user add preferences text")?;
Ok(()) Ok(())
} }

View File

@ -21,7 +21,7 @@ pub struct TopLevel<'a> {
pub cameras: (&'a db::LockedDatabase, bool, bool), pub cameras: (&'a db::LockedDatabase, bool, bool),
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub session: Option<Session>, pub user: Option<ToplevelUser>,
#[serde(serialize_with = "TopLevel::serialize_signals")] #[serde(serialize_with = "TopLevel::serialize_signals")]
pub signals: (&'a db::LockedDatabase, bool), pub signals: (&'a db::LockedDatabase, bool),
@ -33,8 +33,6 @@ pub struct TopLevel<'a> {
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Session { pub struct Session {
pub username: String,
#[serde(serialize_with = "Session::serialize_csrf")] #[serde(serialize_with = "Session::serialize_csrf")]
pub csrf: SessionHash, pub csrf: SessionHash,
} }
@ -519,3 +517,25 @@ impl VideoSampleEntry {
} }
} }
} }
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ToplevelUser {
pub name: String,
pub id: i32,
pub preferences: db::auth::UserPreferences,
pub session: Option<Session>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PostUser {
pub update: Option<UserSubset>,
pub precondition: Option<UserSubset>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UserSubset {
pub preferences: Option<db::auth::UserPreferences>,
}

View File

@ -16,6 +16,7 @@ use fnv::FnvHashMap;
use futures::stream::StreamExt; use futures::stream::StreamExt;
use futures::{future::Either, sink::SinkExt}; use futures::{future::Either, sink::SinkExt};
use http::header::{self, HeaderValue}; use http::header::{self, HeaderValue};
use http::method::Method;
use http::{status::StatusCode, Request, Response}; use http::{status::StatusCode, Request, Response};
use http_serve::dir::FsDir; use http_serve::dir::FsDir;
use hyper::body::Bytes; use hyper::body::Bytes;
@ -68,86 +69,79 @@ enum Path {
Login, // "/api/login" Login, // "/api/login"
Logout, // "/api/logout" Logout, // "/api/logout"
Static, // (anything that doesn't start with "/api/") Static, // (anything that doesn't start with "/api/")
User(i32), // "/api/users/<id>"
NotFound, NotFound,
} }
impl Path { impl Path {
fn decode(path: &str) -> Self { fn decode(path: &str) -> Self {
if !path.starts_with("/api/") { let path = match path.strip_prefix("/api/") {
return Path::Static; Some(p) => p,
} None => return Path::Static,
let path = &path["/api".len()..]; };
if path == "/" {
return Path::TopLevel;
}
match path { match path {
"/login" => return Path::Login, "" => return Path::TopLevel,
"/logout" => return Path::Logout, "login" => return Path::Login,
"/request" => return Path::Request, "logout" => return Path::Logout,
"/signals" => return Path::Signals, "request" => return Path::Request,
"signals" => return Path::Signals,
_ => {} _ => {}
}; };
if path.starts_with("/init/") { if let Some(path) = path.strip_prefix("init/") {
let (debug, path) = if path.ends_with(".txt") { let (debug, path) = match path.strip_suffix(".txt") {
(true, &path[0..path.len() - 4]) Some(p) => (true, p),
} else { None => (false, path),
(false, path)
}; };
if !path.ends_with(".mp4") { let path = match path.strip_suffix(".mp4") {
return Path::NotFound; Some(p) => p,
} None => return Path::NotFound,
let id_start = "/init/".len(); };
let id_end = path.len() - ".mp4".len(); if let Ok(id) = i32::from_str(&path) {
if let Ok(id) = i32::from_str(&path[id_start..id_end]) {
return Path::InitSegment(id, debug); return Path::InitSegment(id, debug);
} }
return Path::NotFound; return Path::NotFound;
} } else if let Some(path) = path.strip_prefix("cameras/") {
if !path.starts_with("/cameras/") { let (uuid, path) = match path.split_once('/') {
return Path::NotFound; Some(pair) => pair,
} None => return Path::NotFound,
let path = &path["/cameras/".len()..]; };
let slash = match path.find('/') {
None => { // TODO(slamb): require uuid to be in canonical format.
return Path::NotFound; let uuid = match Uuid::parse_str(uuid) {
Ok(u) => u,
Err(_) => return Path::NotFound,
};
if path.is_empty() {
return Path::Camera(uuid);
} }
Some(s) => s,
};
let uuid = &path[0..slash];
let path = &path[slash + 1..];
// TODO(slamb): require uuid to be in canonical format. let (type_, path) = match path.split_once('/') {
let uuid = match Uuid::parse_str(uuid) { Some(pair) => pair,
Ok(u) => u, None => return Path::NotFound,
Err(_) => return Path::NotFound, };
}; let type_ = match db::StreamType::parse(type_) {
None => {
if path.is_empty() { return Path::NotFound;
return Path::Camera(uuid); }
} Some(t) => t,
};
let slash = match path.find('/') { match path {
None => { "recordings" => Path::StreamRecordings(uuid, type_),
return Path::NotFound; "view.mp4" => Path::StreamViewMp4(uuid, type_, false),
"view.mp4.txt" => Path::StreamViewMp4(uuid, type_, true),
"view.m4s" => Path::StreamViewMp4Segment(uuid, type_, false),
"view.m4s.txt" => Path::StreamViewMp4Segment(uuid, type_, true),
"live.m4s" => Path::StreamLiveMp4Segments(uuid, type_),
_ => Path::NotFound,
} }
Some(s) => s, } else if let Some(path) = path.strip_prefix("users/") {
}; if let Ok(id) = i32::from_str(path) {
let (type_, path) = path.split_at(slash); return Path::User(id);
let type_ = match db::StreamType::parse(type_) {
None => {
return Path::NotFound;
} }
Some(t) => t, Path::NotFound
}; } else {
match path { Path::NotFound
"/recordings" => Path::StreamRecordings(uuid, type_),
"/view.mp4" => Path::StreamViewMp4(uuid, type_, false),
"/view.mp4.txt" => Path::StreamViewMp4(uuid, type_, true),
"/view.m4s" => Path::StreamViewMp4Segment(uuid, type_, false),
"/view.m4s.txt" => Path::StreamViewMp4Segment(uuid, type_, true),
"/live.m4s" => Path::StreamLiveMp4Segments(uuid, type_),
_ => Path::NotFound,
} }
} }
} }
@ -253,7 +247,7 @@ impl FromStr for Segments {
struct Caller { struct Caller {
permissions: db::Permissions, permissions: db::Permissions,
session: Option<json::Session>, user: Option<json::ToplevelUser>,
} }
type ResponseResult = Result<Response<Body>, HttpError>; type ResponseResult = Result<Response<Body>, HttpError>;
@ -302,7 +296,7 @@ fn extract_sid(req: &Request<hyper::Body>) -> Option<auth::RawSessionId> {
/// deserialization. Keeping the bytes allows the caller to use a `Deserialize` /// deserialization. Keeping the bytes allows the caller to use a `Deserialize`
/// that borrows from the bytes. /// that borrows from the bytes.
async fn extract_json_body(req: &mut Request<hyper::Body>) -> Result<Bytes, HttpError> { async fn extract_json_body(req: &mut Request<hyper::Body>) -> Result<Bytes, HttpError> {
if *req.method() != http::method::Method::POST { if *req.method() != Method::POST {
return Err(plain_response(StatusCode::METHOD_NOT_ALLOWED, "POST expected").into()); return Err(plain_response(StatusCode::METHOD_NOT_ALLOWED, "POST expected").into());
} }
let correct_mime_type = match req.headers().get(header::CONTENT_TYPE) { let correct_mime_type = match req.headers().get(header::CONTENT_TYPE) {
@ -560,7 +554,6 @@ impl Service {
} }
async fn signals(&self, req: Request<hyper::Body>, caller: Caller) -> ResponseResult { async fn signals(&self, req: Request<hyper::Body>, caller: Caller) -> ResponseResult {
use http::method::Method;
match *req.method() { match *req.method() {
Method::POST => self.post_signals(req, caller).await, Method::POST => self.post_signals(req, caller).await,
Method::GET | Method::HEAD => self.get_signals(&req), Method::GET | Method::HEAD => self.get_signals(&req),
@ -614,6 +607,10 @@ impl Service {
self.signals(req, caller).await?, self.signals(req, caller).await?,
), ),
Path::Static => (CacheControl::None, self.static_file(req).await?), Path::Static => (CacheControl::None, self.static_file(req).await?),
Path::User(id) => (
CacheControl::PrivateDynamic,
self.user(req, caller, id).await?,
),
}; };
match cache { match cache {
CacheControl::PrivateStatic => { CacheControl::PrivateStatic => {
@ -683,7 +680,7 @@ impl Service {
&json::TopLevel { &json::TopLevel {
time_zone_name: &self.time_zone_name, time_zone_name: &self.time_zone_name,
cameras: (&db, days, camera_configs), cameras: (&db, days, camera_configs),
session: caller.session, user: caller.user,
signals: (&db, days), signals: (&db, days),
signal_types: &db, signal_types: &db,
}, },
@ -1019,6 +1016,39 @@ impl Service {
Ok(http_serve::serve(e, &req)) Ok(http_serve::serve(e, &req))
} }
async fn user(&self, req: Request<hyper::Body>, caller: Caller, id: i32) -> ResponseResult {
if caller.user.map(|u| u.id) != Some(id) {
bail_t!(Unauthenticated, "must be authenticated as supplied user");
}
match *req.method() {
Method::POST => self.post_user(req, id).await,
_ => Err(plain_response(StatusCode::METHOD_NOT_ALLOWED, "POST expected").into()),
}
}
async fn post_user(&self, mut req: Request<hyper::Body>, id: i32) -> ResponseResult {
let r = extract_json_body(&mut req).await?;
let r: json::PostUser = serde_json::from_slice(&r).map_err(|e| bad_req(e.to_string()))?;
let mut db = self.db.lock();
let user = db
.users_by_id()
.get(&id)
.ok_or_else(|| format_err_t!(Internal, "can't find currently authenticated user"))?;
if let Some(precondition) = r.precondition {
if matches!(precondition.preferences, Some(p) if p != user.preferences) {
bail_t!(FailedPrecondition, "preferences mismatch");
}
}
if let Some(update) = r.update {
let mut change = user.change();
if let Some(preferences) = update.preferences {
change.set_preferences(preferences);
}
db.apply_user_change(change).map_err(internal_server_err)?;
}
Ok(plain_response(StatusCode::NO_CONTENT, &b""[..]))
}
fn authreq(&self, req: &Request<::hyper::Body>) -> auth::Request { fn authreq(&self, req: &Request<::hyper::Body>) -> auth::Request {
auth::Request { auth::Request {
when_sec: Some(self.db.clocks().realtime().sec), when_sec: Some(self.db.clocks().realtime().sec),
@ -1251,9 +1281,11 @@ impl Service {
Ok((s, u)) => { Ok((s, u)) => {
return Ok(Caller { return Ok(Caller {
permissions: s.permissions.clone(), permissions: s.permissions.clone(),
session: Some(json::Session { user: Some(json::ToplevelUser {
username: u.username.clone(), id: s.user_id,
csrf: s.csrf(), name: u.username.clone(),
preferences: u.preferences.clone(),
session: Some(json::Session { csrf: s.csrf() }),
}), }),
}) })
} }
@ -1270,14 +1302,14 @@ impl Service {
if let Some(s) = self.allow_unauthenticated_permissions.as_ref() { if let Some(s) = self.allow_unauthenticated_permissions.as_ref() {
return Ok(Caller { return Ok(Caller {
permissions: s.clone(), permissions: s.clone(),
session: None, user: None,
}); });
} }
if unauth_path { if unauth_path {
return Ok(Caller { return Ok(Caller {
permissions: db::Permissions::default(), permissions: db::Permissions::default(),
session: None, user: None,
}); });
} }
@ -1526,6 +1558,8 @@ mod tests {
assert_eq!(Path::decode("/api/logout"), Path::Logout); assert_eq!(Path::decode("/api/logout"), Path::Logout);
assert_eq!(Path::decode("/api/signals"), Path::Signals); assert_eq!(Path::decode("/api/signals"), Path::Signals);
assert_eq!(Path::decode("/api/junk"), Path::NotFound); assert_eq!(Path::decode("/api/junk"), Path::NotFound);
assert_eq!(Path::decode("/api/users/42"), Path::User(42));
assert_eq!(Path::decode("/api/users/asdf"), Path::NotFound);
} }
#[test] #[test]
@ -1693,6 +1727,8 @@ mod tests {
.await .await
.unwrap(); .unwrap();
let csrf = toplevel let csrf = toplevel
.get("user")
.unwrap()
.get("session") .get("session")
.unwrap() .unwrap()
.get("csrf") .get("csrf")

View File

@ -97,9 +97,11 @@ function App() {
case "success": case "success":
setError(null); setError(null);
setLoginState( setLoginState(
resp.response.session === undefined ? "not-logged-in" : "logged-in" resp.response.user?.session === undefined
? "not-logged-in"
: "logged-in"
); );
setSession(resp.response.session || null); setSession(resp.response.user?.session || null);
setCameras(resp.response.cameras); setCameras(resp.response.cameras);
setTimeZoneName(resp.response.timeZoneName); setTimeZoneName(resp.response.timeZoneName);
} }

View File

@ -145,6 +145,12 @@ async function json<T>(
export interface ToplevelResponse { export interface ToplevelResponse {
timeZoneName: string; timeZoneName: string;
cameras: Camera[]; cameras: Camera[];
user: ToplevelUser | undefined;
}
export interface ToplevelUser {
name: string;
id: number;
session: Session | undefined; session: Session | undefined;
} }

View File

@ -10,7 +10,6 @@
export type StreamType = "main" | "sub"; export type StreamType = "main" | "sub";
export interface Session { export interface Session {
username: string;
csrf: string; csrf: string;
} }