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

2
server/Cargo.lock generated
View File

@@ -1236,6 +1236,8 @@ dependencies = [
"protobuf-codegen-pure",
"ring",
"rusqlite",
"serde",
"serde_json",
"smallvec",
"tempfile",
"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" }
ring = "0.16.2"
rusqlite = "0.25.3"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
smallvec = "1.0"
tempfile = "3.2.0"
time = "0.1"

View File

@@ -13,6 +13,7 @@ use log::info;
use parking_lot::Mutex;
use protobuf::Message;
use ring::rand::{SecureRandom, SystemRandom};
use rusqlite::types::FromSqlError;
use rusqlite::{named_params, params, Connection, Transaction};
use std::collections::BTreeMap;
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 {
Disabled = 1,
}
@@ -48,6 +75,7 @@ pub struct User {
pub password_failure_count: i64,
pub unix_uid: Option<i32>,
pub permissions: Permissions,
pub preferences: UserPreferences,
/// 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
@@ -62,6 +90,7 @@ impl User {
username: self.username.clone(),
flags: self.flags,
set_password_hash: None,
set_preferences: None,
unix_uid: self.unix_uid,
permissions: self.permissions.clone(),
}
@@ -87,6 +116,7 @@ pub struct UserChange {
pub username: String,
pub flags: i32,
set_password_hash: Option<Option<String>>,
set_preferences: Option<UserPreferences>,
pub unix_uid: Option<i32>,
pub permissions: Permissions,
}
@@ -98,6 +128,7 @@ impl UserChange {
username,
flags: 0,
set_password_hash: None,
set_preferences: None,
unix_uid: None,
permissions: Permissions::default(),
}
@@ -112,6 +143,10 @@ impl UserChange {
self.set_password_hash = Some(None);
}
pub fn set_preferences(&mut self, preferences: UserPreferences) {
self.set_preferences = Some(preferences);
}
pub fn disable(&mut self) {
self.flags |= UserFlag::Disabled as i32;
}
@@ -204,7 +239,7 @@ pub enum RevocationReason {
#[derive(Debug, Default)]
pub struct Session {
user_id: i32,
pub user_id: i32,
flags: i32, // bitmask of SessionFlag enum values
domain: Option<Vec<u8>>,
description: Option<String>,
@@ -359,7 +394,8 @@ impl State {
password_id,
password_failure_count,
unix_uid,
permissions
permissions,
preferences
from
user
"#,
@@ -382,6 +418,7 @@ impl State {
unix_uid: row.get(6)?,
dirty: false,
permissions,
preferences: row.get(8)?,
},
);
state.users_by_name.insert(name, id);
@@ -417,7 +454,8 @@ impl State {
password_failure_count = :password_failure_count,
flags = :flags,
unix_uid = :unix_uid,
permissions = :permissions
permissions = :permissions,
preferences = :preferences
where
id = :id
"#,
@@ -427,6 +465,10 @@ impl State {
::std::collections::btree_map::Entry::Vacant(_) => panic!("missing uid {}!", id),
::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() {
None => {
@@ -448,6 +490,7 @@ impl State {
":unix_uid": &change.unix_uid,
":id": &id,
":permissions": &permissions,
":preferences": &preferences,
})?;
}
let u = e.into_mut();
@@ -460,14 +503,17 @@ impl State {
u.flags = change.flags;
u.unix_uid = change.unix_uid;
u.permissions = change.permissions;
u.preferences = preferences;
Ok(u)
}
fn add_user(&mut self, conn: &Connection, change: UserChange) -> Result<&User, Error> {
let mut stmt = conn.prepare_cached(
r#"
insert into user (username, password_hash, flags, unix_uid, permissions)
values (:username, :password_hash, :flags, :unix_uid, :permissions)
insert into user (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);
@@ -475,12 +521,14 @@ impl State {
.permissions
.write_to_bytes()
.expect("proto3->vec is infallible");
let preferences = change.set_preferences.unwrap_or_default();
stmt.execute(named_params! {
":username": &change.username[..],
":password_hash": &password_hash,
":flags": &change.flags,
":unix_uid": &change.unix_uid,
":permissions": &permissions,
":preferences": &preferences,
})?;
let id = conn.last_insert_rowid() as i32;
self.users_by_name.insert(change.username.clone(), id);
@@ -499,6 +547,7 @@ impl State {
unix_uid: change.unix_uid,
dirty: false,
permissions: change.permissions,
preferences,
}))
}
@@ -1283,4 +1332,33 @@ mod tests {
assert!(u.permissions.view_video);
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
-- 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.

View File

@@ -5,6 +5,7 @@
/// Upgrades a version 6 schema to a version 7 schema.
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(())
}

View File

@@ -21,7 +21,7 @@ pub struct TopLevel<'a> {
pub cameras: (&'a db::LockedDatabase, bool, bool),
#[serde(skip_serializing_if = "Option::is_none")]
pub session: Option<Session>,
pub user: Option<ToplevelUser>,
#[serde(serialize_with = "TopLevel::serialize_signals")]
pub signals: (&'a db::LockedDatabase, bool),
@@ -33,8 +33,6 @@ pub struct TopLevel<'a> {
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Session {
pub username: String,
#[serde(serialize_with = "Session::serialize_csrf")]
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::{future::Either, sink::SinkExt};
use http::header::{self, HeaderValue};
use http::method::Method;
use http::{status::StatusCode, Request, Response};
use http_serve::dir::FsDir;
use hyper::body::Bytes;
@@ -68,86 +69,79 @@ enum Path {
Login, // "/api/login"
Logout, // "/api/logout"
Static, // (anything that doesn't start with "/api/")
User(i32), // "/api/users/<id>"
NotFound,
}
impl Path {
fn decode(path: &str) -> Self {
if !path.starts_with("/api/") {
return Path::Static;
}
let path = &path["/api".len()..];
if path == "/" {
return Path::TopLevel;
}
let path = match path.strip_prefix("/api/") {
Some(p) => p,
None => return Path::Static,
};
match path {
"/login" => return Path::Login,
"/logout" => return Path::Logout,
"/request" => return Path::Request,
"/signals" => return Path::Signals,
"" => return Path::TopLevel,
"login" => return Path::Login,
"logout" => return Path::Logout,
"request" => return Path::Request,
"signals" => return Path::Signals,
_ => {}
};
if path.starts_with("/init/") {
let (debug, path) = if path.ends_with(".txt") {
(true, &path[0..path.len() - 4])
} else {
(false, path)
if let Some(path) = path.strip_prefix("init/") {
let (debug, path) = match path.strip_suffix(".txt") {
Some(p) => (true, p),
None => (false, path),
};
if !path.ends_with(".mp4") {
return Path::NotFound;
}
let id_start = "/init/".len();
let id_end = path.len() - ".mp4".len();
if let Ok(id) = i32::from_str(&path[id_start..id_end]) {
let path = match path.strip_suffix(".mp4") {
Some(p) => p,
None => return Path::NotFound,
};
if let Ok(id) = i32::from_str(&path) {
return Path::InitSegment(id, debug);
}
return Path::NotFound;
}
if !path.starts_with("/cameras/") {
return Path::NotFound;
}
let path = &path["/cameras/".len()..];
let slash = match path.find('/') {
None => {
return Path::NotFound;
} else if let Some(path) = path.strip_prefix("cameras/") {
let (uuid, path) = match path.split_once('/') {
Some(pair) => pair,
None => return Path::NotFound,
};
// TODO(slamb): require uuid to be in canonical format.
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 uuid = match Uuid::parse_str(uuid) {
Ok(u) => u,
Err(_) => return Path::NotFound,
};
if path.is_empty() {
return Path::Camera(uuid);
}
let slash = match path.find('/') {
None => {
return Path::NotFound;
let (type_, path) = match path.split_once('/') {
Some(pair) => pair,
None => return Path::NotFound,
};
let type_ = match db::StreamType::parse(type_) {
None => {
return Path::NotFound;
}
Some(t) => t,
};
match path {
"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,
}
Some(s) => s,
};
let (type_, path) = path.split_at(slash);
let type_ = match db::StreamType::parse(type_) {
None => {
return Path::NotFound;
} else if let Some(path) = path.strip_prefix("users/") {
if let Ok(id) = i32::from_str(path) {
return Path::User(id);
}
Some(t) => t,
};
match path {
"/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,
Path::NotFound
} else {
Path::NotFound
}
}
}
@@ -253,7 +247,7 @@ impl FromStr for Segments {
struct Caller {
permissions: db::Permissions,
session: Option<json::Session>,
user: Option<json::ToplevelUser>,
}
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`
/// that borrows from the bytes.
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());
}
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 {
use http::method::Method;
match *req.method() {
Method::POST => self.post_signals(req, caller).await,
Method::GET | Method::HEAD => self.get_signals(&req),
@@ -614,6 +607,10 @@ impl Service {
self.signals(req, caller).await?,
),
Path::Static => (CacheControl::None, self.static_file(req).await?),
Path::User(id) => (
CacheControl::PrivateDynamic,
self.user(req, caller, id).await?,
),
};
match cache {
CacheControl::PrivateStatic => {
@@ -683,7 +680,7 @@ impl Service {
&json::TopLevel {
time_zone_name: &self.time_zone_name,
cameras: (&db, days, camera_configs),
session: caller.session,
user: caller.user,
signals: (&db, days),
signal_types: &db,
},
@@ -1019,6 +1016,39 @@ impl Service {
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 {
auth::Request {
when_sec: Some(self.db.clocks().realtime().sec),
@@ -1251,9 +1281,11 @@ impl Service {
Ok((s, u)) => {
return Ok(Caller {
permissions: s.permissions.clone(),
session: Some(json::Session {
username: u.username.clone(),
csrf: s.csrf(),
user: Some(json::ToplevelUser {
id: s.user_id,
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() {
return Ok(Caller {
permissions: s.clone(),
session: None,
user: None,
});
}
if unauth_path {
return Ok(Caller {
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/signals"), Path::Signals);
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]
@@ -1693,6 +1727,8 @@ mod tests {
.await
.unwrap();
let csrf = toplevel
.get("user")
.unwrap()
.get("session")
.unwrap()
.get("csrf")