2022-05-20 20:37:32 +02:00
|
|
|
use std::{
|
|
|
|
net::SocketAddr,
|
|
|
|
sync::{
|
|
|
|
atomic::{AtomicBool, Ordering},
|
|
|
|
Arc,
|
|
|
|
},
|
|
|
|
time::Duration,
|
|
|
|
};
|
2019-12-06 22:19:07 +01:00
|
|
|
|
2022-05-20 20:37:32 +02:00
|
|
|
use chrono::NaiveDateTime;
|
|
|
|
use futures::{SinkExt, StreamExt};
|
|
|
|
use rmpv::Value;
|
|
|
|
use rocket::{serde::json::Json, Route};
|
2018-10-15 00:25:16 +02:00
|
|
|
use serde_json::Value as JsonValue;
|
2022-05-20 20:37:32 +02:00
|
|
|
use tokio::{
|
|
|
|
net::{TcpListener, TcpStream},
|
|
|
|
sync::mpsc::Sender,
|
|
|
|
};
|
|
|
|
use tokio_tungstenite::{
|
|
|
|
accept_hdr_async,
|
|
|
|
tungstenite::{handshake, Message},
|
|
|
|
};
|
|
|
|
|
|
|
|
use crate::{
|
|
|
|
api::EmptyResult,
|
|
|
|
auth::Headers,
|
|
|
|
db::models::{Cipher, Folder, Send, User},
|
|
|
|
Error, CONFIG,
|
|
|
|
};
|
2018-09-13 20:59:51 +02:00
|
|
|
|
2018-08-24 19:02:34 +02:00
|
|
|
pub fn routes() -> Vec<Route> {
|
2018-09-11 17:09:33 +02:00
|
|
|
routes![negotiate, websockets_err]
|
|
|
|
}
|
|
|
|
|
|
|
|
#[get("/hub")]
|
2019-12-06 22:19:07 +01:00
|
|
|
fn websockets_err() -> EmptyResult {
|
2022-05-20 20:37:32 +02:00
|
|
|
static SHOW_WEBSOCKETS_MSG: AtomicBool = AtomicBool::new(true);
|
|
|
|
|
2021-03-31 22:18:35 +02:00
|
|
|
if CONFIG.websocket_enabled()
|
2021-04-06 22:54:42 +02:00
|
|
|
&& SHOW_WEBSOCKETS_MSG.compare_exchange(true, false, Ordering::Relaxed, Ordering::Relaxed).is_ok()
|
2021-03-31 22:18:35 +02:00
|
|
|
{
|
|
|
|
err!(
|
|
|
|
"
|
2021-01-31 20:07:42 +01:00
|
|
|
###########################################################
|
2019-12-06 22:19:07 +01:00
|
|
|
'/notifications/hub' should be proxied to the websocket server or notifications won't work.
|
|
|
|
Go to the Wiki for more info, or disable WebSockets setting WEBSOCKET_ENABLED=false.
|
2021-03-31 22:18:35 +02:00
|
|
|
###########################################################################################\n"
|
|
|
|
)
|
2019-12-06 22:19:07 +01:00
|
|
|
} else {
|
|
|
|
Err(Error::empty())
|
|
|
|
}
|
2018-08-24 19:02:34 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
#[post("/hub/negotiate")]
|
2021-11-05 19:18:54 +01:00
|
|
|
fn negotiate(_headers: Headers) -> Json<JsonValue> {
|
2018-12-07 02:05:45 +01:00
|
|
|
use crate::crypto;
|
2018-08-30 17:43:46 +02:00
|
|
|
use data_encoding::BASE64URL;
|
2018-08-24 19:02:34 +02:00
|
|
|
|
2022-11-11 10:55:04 +01:00
|
|
|
let conn_id = crypto::encode_random_bytes::<16>(BASE64URL);
|
2018-10-15 00:25:16 +02:00
|
|
|
let mut available_transports: Vec<JsonValue> = Vec::new();
|
|
|
|
|
2019-01-25 18:23:51 +01:00
|
|
|
if CONFIG.websocket_enabled() {
|
2018-10-15 00:25:16 +02:00
|
|
|
available_transports.push(json!({"transport":"WebSockets", "transferFormats":["Text","Binary"]}));
|
|
|
|
}
|
2018-08-24 19:02:34 +02:00
|
|
|
|
|
|
|
// TODO: Implement transports
|
|
|
|
// Rocket WS support: https://github.com/SergioBenitez/Rocket/issues/90
|
|
|
|
// Rocket SSE support: https://github.com/SergioBenitez/Rocket/issues/33
|
2018-10-15 00:25:16 +02:00
|
|
|
// {"transport":"ServerSentEvents", "transferFormats":["Text"]},
|
|
|
|
// {"transport":"LongPolling", "transferFormats":["Text","Binary"]}
|
2021-03-27 16:07:26 +01:00
|
|
|
Json(json!({
|
2018-08-24 19:02:34 +02:00
|
|
|
"connectionId": conn_id,
|
2018-10-15 00:25:16 +02:00
|
|
|
"availableTransports": available_transports
|
2021-03-27 16:07:26 +01:00
|
|
|
}))
|
2018-08-30 17:43:46 +02:00
|
|
|
}
|
|
|
|
|
2018-12-30 23:34:31 +01:00
|
|
|
//
|
|
|
|
// Websockets server
|
|
|
|
//
|
2018-08-30 17:43:46 +02:00
|
|
|
|
|
|
|
fn serialize(val: Value) -> Vec<u8> {
|
|
|
|
use rmpv::encode::write_value;
|
|
|
|
|
|
|
|
let mut buf = Vec::new();
|
|
|
|
write_value(&mut buf, &val).expect("Error encoding MsgPack");
|
|
|
|
|
|
|
|
// Add size bytes at the start
|
|
|
|
// Extracted from BinaryMessageFormat.js
|
2018-09-13 21:55:23 +02:00
|
|
|
let mut size: usize = buf.len();
|
2018-08-30 17:43:46 +02:00
|
|
|
let mut len_buf: Vec<u8> = Vec::new();
|
|
|
|
|
|
|
|
loop {
|
|
|
|
let mut size_part = size & 0x7f;
|
2018-09-13 21:55:23 +02:00
|
|
|
size >>= 7;
|
2018-08-30 17:43:46 +02:00
|
|
|
|
|
|
|
if size > 0 {
|
2018-09-13 21:55:23 +02:00
|
|
|
size_part |= 0x80;
|
2018-08-30 17:43:46 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
len_buf.push(size_part as u8);
|
|
|
|
|
2018-09-13 21:55:23 +02:00
|
|
|
if size == 0 {
|
2018-08-30 17:43:46 +02:00
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
len_buf.append(&mut buf);
|
|
|
|
len_buf
|
|
|
|
}
|
|
|
|
|
|
|
|
fn serialize_date(date: NaiveDateTime) -> Value {
|
|
|
|
let seconds: i64 = date.timestamp();
|
2019-02-20 17:54:18 +01:00
|
|
|
let nanos: i64 = date.timestamp_subsec_nanos().into();
|
2018-08-30 17:43:46 +02:00
|
|
|
let timestamp = nanos << 34 | seconds;
|
2019-01-25 18:23:51 +01:00
|
|
|
|
2019-01-16 22:14:17 +01:00
|
|
|
let bs = timestamp.to_be_bytes();
|
2018-08-30 17:43:46 +02:00
|
|
|
|
|
|
|
// -1 is Timestamp
|
|
|
|
// https://github.com/msgpack/msgpack/blob/master/spec.md#timestamp-extension-type
|
|
|
|
Value::Ext(-1, bs.to_vec())
|
|
|
|
}
|
|
|
|
|
|
|
|
fn convert_option<T: Into<Value>>(option: Option<T>) -> Value {
|
|
|
|
match option {
|
|
|
|
Some(a) => a.into(),
|
|
|
|
None => Value::Nil,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
const RECORD_SEPARATOR: u8 = 0x1e;
|
|
|
|
const INITIAL_RESPONSE: [u8; 3] = [0x7b, 0x7d, RECORD_SEPARATOR]; // {, }, <RS>
|
|
|
|
|
2022-05-20 20:37:32 +02:00
|
|
|
#[derive(Deserialize, Copy, Clone, Eq, PartialEq)]
|
|
|
|
struct InitialMessage<'a> {
|
|
|
|
protocol: &'a str,
|
2018-08-30 17:43:46 +02:00
|
|
|
version: i32,
|
|
|
|
}
|
|
|
|
|
2022-05-20 20:37:32 +02:00
|
|
|
static INITIAL_MESSAGE: InitialMessage<'static> = InitialMessage {
|
|
|
|
protocol: "messagepack",
|
|
|
|
version: 1,
|
|
|
|
};
|
2018-08-30 17:43:46 +02:00
|
|
|
|
2022-05-20 20:37:32 +02:00
|
|
|
// We attach the UUID to the sender so we can differentiate them when we need to remove them from the Vec
|
|
|
|
type UserSenders = (uuid::Uuid, Sender<Message>);
|
2018-08-30 17:43:46 +02:00
|
|
|
#[derive(Clone)]
|
|
|
|
pub struct WebSocketUsers {
|
2022-05-20 20:37:32 +02:00
|
|
|
map: Arc<dashmap::DashMap<String, Vec<UserSenders>>>,
|
2018-08-30 17:43:46 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
impl WebSocketUsers {
|
2022-05-20 20:37:32 +02:00
|
|
|
async fn send_update(&self, user_uuid: &str, data: &[u8]) {
|
|
|
|
if let Some(user) = self.map.get(user_uuid).map(|v| v.clone()) {
|
|
|
|
for (_, sender) in user.iter() {
|
|
|
|
if sender.send(Message::binary(data)).await.is_err() {
|
|
|
|
// TODO: Delete from map here too?
|
|
|
|
}
|
2018-08-30 17:43:46 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// NOTE: The last modified date needs to be updated before calling these methods
|
2022-05-20 20:37:32 +02:00
|
|
|
pub async fn send_user_update(&self, ut: UpdateType, user: &User) {
|
2018-08-30 17:43:46 +02:00
|
|
|
let data = create_update(
|
2021-04-06 22:54:42 +02:00
|
|
|
vec![("UserId".into(), user.uuid.clone().into()), ("Date".into(), serialize_date(user.updated_at))],
|
2018-08-30 17:43:46 +02:00
|
|
|
ut,
|
|
|
|
);
|
|
|
|
|
2022-05-20 20:37:32 +02:00
|
|
|
self.send_update(&user.uuid, &data).await;
|
2018-08-30 17:43:46 +02:00
|
|
|
}
|
|
|
|
|
2022-05-20 20:37:32 +02:00
|
|
|
pub async fn send_folder_update(&self, ut: UpdateType, folder: &Folder) {
|
2018-08-30 17:43:46 +02:00
|
|
|
let data = create_update(
|
|
|
|
vec![
|
|
|
|
("Id".into(), folder.uuid.clone().into()),
|
|
|
|
("UserId".into(), folder.user_uuid.clone().into()),
|
|
|
|
("RevisionDate".into(), serialize_date(folder.updated_at)),
|
2018-09-13 21:55:23 +02:00
|
|
|
],
|
2018-08-30 17:43:46 +02:00
|
|
|
ut,
|
|
|
|
);
|
|
|
|
|
2022-05-20 20:37:32 +02:00
|
|
|
self.send_update(&folder.user_uuid, &data).await;
|
2018-08-30 17:43:46 +02:00
|
|
|
}
|
|
|
|
|
2022-05-20 20:37:32 +02:00
|
|
|
pub async fn send_cipher_update(&self, ut: UpdateType, cipher: &Cipher, user_uuids: &[String]) {
|
2018-08-30 17:43:46 +02:00
|
|
|
let user_uuid = convert_option(cipher.user_uuid.clone());
|
|
|
|
let org_uuid = convert_option(cipher.organization_uuid.clone());
|
|
|
|
|
|
|
|
let data = create_update(
|
|
|
|
vec![
|
|
|
|
("Id".into(), cipher.uuid.clone().into()),
|
|
|
|
("UserId".into(), user_uuid),
|
|
|
|
("OrganizationId".into(), org_uuid),
|
|
|
|
("CollectionIds".into(), Value::Nil),
|
|
|
|
("RevisionDate".into(), serialize_date(cipher.updated_at)),
|
2018-09-13 21:55:23 +02:00
|
|
|
],
|
2018-08-30 17:43:46 +02:00
|
|
|
ut,
|
|
|
|
);
|
|
|
|
|
2021-08-03 17:33:59 +02:00
|
|
|
for uuid in user_uuids {
|
2022-05-20 20:37:32 +02:00
|
|
|
self.send_update(uuid, &data).await;
|
2021-08-03 17:33:59 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-05-20 20:37:32 +02:00
|
|
|
pub async fn send_send_update(&self, ut: UpdateType, send: &Send, user_uuids: &[String]) {
|
2021-08-03 17:33:59 +02:00
|
|
|
let user_uuid = convert_option(send.user_uuid.clone());
|
|
|
|
|
|
|
|
let data = create_update(
|
|
|
|
vec![
|
|
|
|
("Id".into(), send.uuid.clone().into()),
|
|
|
|
("UserId".into(), user_uuid),
|
|
|
|
("RevisionDate".into(), serialize_date(send.revision_date)),
|
|
|
|
],
|
|
|
|
ut,
|
|
|
|
);
|
|
|
|
|
2018-09-01 06:30:53 +02:00
|
|
|
for uuid in user_uuids {
|
2022-05-20 20:37:32 +02:00
|
|
|
self.send_update(uuid, &data).await;
|
2018-08-30 17:43:46 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/* Message Structure
|
|
|
|
[
|
|
|
|
1, // MessageType.Invocation
|
2020-08-31 19:05:07 +02:00
|
|
|
{}, // Headers (map)
|
2018-08-30 17:43:46 +02:00
|
|
|
null, // InvocationId
|
|
|
|
"ReceiveMessage", // Target
|
|
|
|
[ // Arguments
|
|
|
|
{
|
|
|
|
"ContextId": "app_id",
|
|
|
|
"Type": ut as i32,
|
|
|
|
"Payload": {}
|
|
|
|
}
|
|
|
|
]
|
|
|
|
]
|
|
|
|
*/
|
|
|
|
fn create_update(payload: Vec<(Value, Value)>, ut: UpdateType) -> Vec<u8> {
|
|
|
|
use rmpv::Value as V;
|
|
|
|
|
|
|
|
let value = V::Array(vec![
|
|
|
|
1.into(),
|
2020-08-31 19:05:07 +02:00
|
|
|
V::Map(vec![]),
|
2018-08-30 17:43:46 +02:00
|
|
|
V::Nil,
|
|
|
|
"ReceiveMessage".into(),
|
|
|
|
V::Array(vec![V::Map(vec![
|
|
|
|
("ContextId".into(), "app_id".into()),
|
|
|
|
("Type".into(), (ut as i32).into()),
|
|
|
|
("Payload".into(), payload.into()),
|
|
|
|
])]),
|
|
|
|
]);
|
|
|
|
|
|
|
|
serialize(value)
|
|
|
|
}
|
|
|
|
|
|
|
|
fn create_ping() -> Vec<u8> {
|
|
|
|
serialize(Value::Array(vec![6.into()]))
|
|
|
|
}
|
|
|
|
|
|
|
|
#[allow(dead_code)]
|
2022-05-23 19:52:58 +02:00
|
|
|
#[derive(Eq, PartialEq)]
|
2018-08-30 17:43:46 +02:00
|
|
|
pub enum UpdateType {
|
2018-12-30 23:34:31 +01:00
|
|
|
CipherUpdate = 0,
|
|
|
|
CipherCreate = 1,
|
|
|
|
LoginDelete = 2,
|
|
|
|
FolderDelete = 3,
|
|
|
|
Ciphers = 4,
|
|
|
|
|
|
|
|
Vault = 5,
|
|
|
|
OrgKeys = 6,
|
|
|
|
FolderCreate = 7,
|
|
|
|
FolderUpdate = 8,
|
|
|
|
CipherDelete = 9,
|
2018-08-30 17:43:46 +02:00
|
|
|
SyncSettings = 10,
|
|
|
|
|
|
|
|
LogOut = 11,
|
2019-01-28 00:39:14 +01:00
|
|
|
|
2021-03-14 23:35:55 +01:00
|
|
|
SyncSendCreate = 12,
|
|
|
|
SyncSendUpdate = 13,
|
|
|
|
SyncSendDelete = 14,
|
|
|
|
|
2019-01-28 00:39:14 +01:00
|
|
|
None = 100,
|
2018-08-30 17:43:46 +02:00
|
|
|
}
|
|
|
|
|
2022-05-20 20:37:32 +02:00
|
|
|
pub type Notify<'a> = &'a rocket::State<WebSocketUsers>;
|
2018-12-30 23:34:31 +01:00
|
|
|
|
2018-08-30 17:43:46 +02:00
|
|
|
pub fn start_notification_server() -> WebSocketUsers {
|
2022-05-20 20:37:32 +02:00
|
|
|
let users = WebSocketUsers {
|
|
|
|
map: Arc::new(dashmap::DashMap::new()),
|
|
|
|
};
|
2018-08-30 17:43:46 +02:00
|
|
|
|
2019-01-25 18:23:51 +01:00
|
|
|
if CONFIG.websocket_enabled() {
|
2022-05-20 20:37:32 +02:00
|
|
|
let users2 = users.clone();
|
|
|
|
tokio::spawn(async move {
|
|
|
|
let addr = (CONFIG.websocket_address(), CONFIG.websocket_port());
|
2022-05-21 19:12:38 +02:00
|
|
|
info!("Starting WebSockets server on {}:{}", addr.0, addr.1);
|
2022-05-20 20:37:32 +02:00
|
|
|
let listener = TcpListener::bind(addr).await.expect("Can't listen on websocket port");
|
|
|
|
|
|
|
|
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel::<()>();
|
|
|
|
CONFIG.set_ws_shutdown_handle(shutdown_tx);
|
|
|
|
|
|
|
|
loop {
|
|
|
|
tokio::select! {
|
|
|
|
Ok((stream, addr)) = listener.accept() => {
|
|
|
|
tokio::spawn(handle_connection(stream, users2.clone(), addr));
|
|
|
|
}
|
|
|
|
|
|
|
|
_ = &mut shutdown_rx => {
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2021-11-07 18:53:39 +01:00
|
|
|
|
2022-05-20 20:37:32 +02:00
|
|
|
info!("Shutting down WebSockets server!")
|
2018-10-15 16:08:15 +02:00
|
|
|
});
|
|
|
|
}
|
2018-08-30 17:43:46 +02:00
|
|
|
|
|
|
|
users
|
|
|
|
}
|
2022-05-20 20:37:32 +02:00
|
|
|
|
2022-05-21 19:12:38 +02:00
|
|
|
async fn handle_connection(stream: TcpStream, users: WebSocketUsers, addr: SocketAddr) -> Result<(), Error> {
|
2022-05-20 20:37:32 +02:00
|
|
|
let mut user_uuid: Option<String> = None;
|
|
|
|
|
2022-05-21 19:12:38 +02:00
|
|
|
info!("Accepting WS connection from {addr}");
|
|
|
|
|
2022-05-20 20:37:32 +02:00
|
|
|
// Accept connection, do initial handshake, validate auth token and get the user ID
|
|
|
|
use handshake::server::{Request, Response};
|
|
|
|
let mut stream = accept_hdr_async(stream, |req: &Request, res: Response| {
|
|
|
|
if let Some(token) = get_request_token(req) {
|
|
|
|
if let Ok(claims) = crate::auth::decode_login(&token) {
|
|
|
|
user_uuid = Some(claims.sub);
|
|
|
|
return Ok(res);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
Err(Response::builder().status(401).body(None).unwrap())
|
|
|
|
})
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
let user_uuid = user_uuid.expect("User UUID should be set after the handshake");
|
|
|
|
|
|
|
|
// Add a channel to send messages to this client to the map
|
|
|
|
let entry_uuid = uuid::Uuid::new_v4();
|
|
|
|
let (tx, mut rx) = tokio::sync::mpsc::channel(100);
|
|
|
|
users.map.entry(user_uuid.clone()).or_default().push((entry_uuid, tx));
|
|
|
|
|
|
|
|
let mut interval = tokio::time::interval(Duration::from_secs(15));
|
|
|
|
loop {
|
|
|
|
tokio::select! {
|
|
|
|
res = stream.next() => {
|
|
|
|
match res {
|
|
|
|
Some(Ok(message)) => {
|
2022-05-21 19:12:38 +02:00
|
|
|
// Respond to any pings
|
|
|
|
if let Message::Ping(ping) = message {
|
|
|
|
if stream.send(Message::Pong(ping)).await.is_err() {
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
continue;
|
|
|
|
} else if let Message::Pong(_) = message {
|
|
|
|
/* Ignored */
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
2022-05-20 20:37:32 +02:00
|
|
|
// We should receive an initial message with the protocol and version, and we will reply to it
|
|
|
|
if let Message::Text(ref message) = message {
|
2022-05-21 19:12:38 +02:00
|
|
|
let msg = message.strip_suffix(RECORD_SEPARATOR as char).unwrap_or(message);
|
2022-05-20 20:37:32 +02:00
|
|
|
|
|
|
|
if serde_json::from_str(msg).ok() == Some(INITIAL_MESSAGE) {
|
|
|
|
stream.send(Message::binary(INITIAL_RESPONSE)).await?;
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Just echo anything else the client sends
|
|
|
|
if stream.send(message).await.is_err() {
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
_ => break,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
res = rx.recv() => {
|
|
|
|
match res {
|
|
|
|
Some(res) => {
|
|
|
|
if stream.send(res).await.is_err() {
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
},
|
|
|
|
None => break,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
_= interval.tick() => {
|
2022-05-21 19:12:38 +02:00
|
|
|
if stream.send(Message::Ping(create_ping())).await.is_err() {
|
2022-05-20 20:37:32 +02:00
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-05-21 19:12:38 +02:00
|
|
|
info!("Closing WS connection from {addr}");
|
|
|
|
|
2022-05-20 20:37:32 +02:00
|
|
|
// Delete from map
|
|
|
|
users.map.entry(user_uuid).or_default().retain(|(uuid, _)| uuid != &entry_uuid);
|
|
|
|
Ok(())
|
|
|
|
}
|
|
|
|
|
|
|
|
fn get_request_token(req: &handshake::server::Request) -> Option<String> {
|
|
|
|
const ACCESS_TOKEN_KEY: &str = "access_token=";
|
|
|
|
|
|
|
|
if let Some(Ok(auth)) = req.headers().get("Authorization").map(|a| a.to_str()) {
|
|
|
|
if let Some(token_part) = auth.strip_prefix("Bearer ") {
|
|
|
|
return Some(token_part.to_owned());
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if let Some(params) = req.uri().query() {
|
|
|
|
let params_iter = params.split('&').take(1);
|
|
|
|
for val in params_iter {
|
|
|
|
if let Some(stripped) = val.strip_prefix(ACCESS_TOKEN_KEY) {
|
|
|
|
return Some(stripped.to_owned());
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
None
|
|
|
|
}
|