mirror of
https://github.com/scottlamb/moonfire-nvr.git
synced 2024-12-24 22:25:55 -05:00
parent
33b3b669df
commit
c42314edb5
@ -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
2
server/Cargo.lock
generated
@ -1236,6 +1236,8 @@ dependencies = [
|
|||||||
"protobuf-codegen-pure",
|
"protobuf-codegen-pure",
|
||||||
"ring",
|
"ring",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"time",
|
"time",
|
||||||
|
@ -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"
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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.
|
||||||
|
@ -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(())
|
||||||
}
|
}
|
||||||
|
@ -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>,
|
||||||
|
}
|
||||||
|
@ -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")
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user