mirror of
				https://github.com/scottlamb/moonfire-nvr.git
				synced 2025-10-29 15:55:01 -04: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…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user