mirror of
https://github.com/aljazceru/notedeck.git
synced 2025-12-18 17:14:21 +01:00
move login logic from promise to async fns
Signed-off-by: kernelkind <kernelkind@gmail.com> Signed-off-by: William Casarin <jb55@jb55.com>
This commit is contained in:
committed by
William Casarin
parent
64904da5e8
commit
baaa7cc05d
@@ -2,9 +2,9 @@ use std::collections::HashMap;
|
|||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
use crate::Error;
|
use crate::Error;
|
||||||
use ehttp::{Request, Response};
|
|
||||||
use nostr_sdk::{prelude::Keys, PublicKey, SecretKey};
|
use nostr_sdk::{prelude::Keys, PublicKey, SecretKey};
|
||||||
use poll_promise::Promise;
|
use poll_promise::Promise;
|
||||||
|
use reqwest::{Request, Response};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, PartialEq)]
|
#[derive(Debug, PartialEq)]
|
||||||
@@ -30,9 +30,13 @@ pub struct Nip05Result {
|
|||||||
pub relays: Option<HashMap<String, Vec<String>>>,
|
pub relays: Option<HashMap<String, Vec<String>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_nip05_response(response: Response) -> Result<Nip05Result, Error> {
|
async fn parse_nip05_response(response: Response) -> Result<Nip05Result, Error> {
|
||||||
serde_json::from_slice::<Nip05Result>(&response.bytes)
|
match response.bytes().await {
|
||||||
.map_err(|e| Error::Generic(e.to_string()))
|
Ok(bytes) => {
|
||||||
|
serde_json::from_slice::<Nip05Result>(&bytes).map_err(|e| Error::Generic(e.to_string()))
|
||||||
|
}
|
||||||
|
Err(e) => Err(Error::Generic(e.to_string())),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_pubkey_from_result(result: Nip05Result, user: String) -> Result<PublicKey, Error> {
|
fn get_pubkey_from_result(result: Nip05Result, user: String) -> Result<PublicKey, Error> {
|
||||||
@@ -44,70 +48,56 @@ fn get_pubkey_from_result(result: Nip05Result, user: String) -> Result<PublicKey
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_nip05_pubkey(id: &str) -> Promise<Result<PublicKey, Error>> {
|
async fn get_nip05_pubkey(id: &str) -> Result<PublicKey, Error> {
|
||||||
let (sender, promise) = Promise::new();
|
|
||||||
let mut parts = id.split('@');
|
let mut parts = id.split('@');
|
||||||
|
|
||||||
let user = match parts.next() {
|
let user = match parts.next() {
|
||||||
Some(user) => user,
|
Some(user) => user,
|
||||||
None => {
|
None => {
|
||||||
sender.send(Err(Error::Generic(
|
return Err(Error::Generic(
|
||||||
"Address does not contain username.".to_string(),
|
"Address does not contain username.".to_string(),
|
||||||
)));
|
));
|
||||||
return promise;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let host = match parts.next() {
|
let host = match parts.next() {
|
||||||
Some(host) => host,
|
Some(host) => host,
|
||||||
None => {
|
None => {
|
||||||
sender.send(Err(Error::Generic(
|
return Err(Error::Generic(
|
||||||
"Nip05 address does not contain host.".to_string(),
|
"Nip05 address does not contain host.".to_string(),
|
||||||
)));
|
));
|
||||||
return promise;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if parts.next().is_some() {
|
if parts.next().is_some() {
|
||||||
sender.send(Err(Error::Generic(
|
return Err(Error::Generic(
|
||||||
"Nip05 address contains extraneous parts.".to_string(),
|
"Nip05 address contains extraneous parts.".to_string(),
|
||||||
)));
|
));
|
||||||
return promise;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let url = format!("https://{host}/.well-known/nostr.json?name={user}");
|
let url = format!("https://{host}/.well-known/nostr.json?name={user}");
|
||||||
let request = Request::get(url);
|
let request = Request::new(reqwest::Method::GET, url.parse().unwrap());
|
||||||
|
|
||||||
let cloned_user = user.to_string();
|
let cloned_user = user.to_string();
|
||||||
ehttp::fetch(request, move |response: Result<Response, String>| {
|
|
||||||
let result = match response {
|
|
||||||
Ok(resp) => parse_nip05_response(resp)
|
|
||||||
.and_then(move |result| get_pubkey_from_result(result, cloned_user)),
|
|
||||||
Err(e) => Err(Error::Generic(e.to_string())),
|
|
||||||
};
|
|
||||||
sender.send(result);
|
|
||||||
});
|
|
||||||
|
|
||||||
promise
|
let client = reqwest::Client::new();
|
||||||
|
match client.execute(request).await {
|
||||||
|
Ok(resp) => match parse_nip05_response(resp).await {
|
||||||
|
Ok(result) => match get_pubkey_from_result(result, cloned_user) {
|
||||||
|
Ok(pubkey) => Ok(pubkey),
|
||||||
|
Err(e) => Err(Error::Generic(e.to_string())),
|
||||||
|
},
|
||||||
|
Err(e) => Err(Error::Generic(e.to_string())),
|
||||||
|
},
|
||||||
|
Err(e) => Err(Error::Generic(e.to_string())),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn retrieving_nip05_pubkey(key: &str) -> bool {
|
fn retrieving_nip05_pubkey(key: &str) -> bool {
|
||||||
key.contains('@')
|
key.contains('@')
|
||||||
}
|
}
|
||||||
|
|
||||||
fn nip05_promise_wrapper(id: &str) -> Promise<Result<Keys, LoginError>> {
|
pub fn perform_key_retrieval(key: &str) -> Promise<Result<Keys, LoginError>> {
|
||||||
let (sender, promise) = Promise::new();
|
let key_string = String::from(key);
|
||||||
let original_promise = get_nip05_pubkey(id);
|
Promise::spawn_async(async move { get_login_key(&key_string).await })
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
let result = original_promise.block_and_take();
|
|
||||||
let transformed_result = match result {
|
|
||||||
Ok(public_key) => Ok(Keys::from_public_key(public_key)),
|
|
||||||
Err(e) => Err(LoginError::Nip05Failed(e.to_string())),
|
|
||||||
};
|
|
||||||
sender.send(transformed_result);
|
|
||||||
});
|
|
||||||
|
|
||||||
promise
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Attempts to turn a string slice key from the user into a Nostr-Sdk Keys object.
|
/// Attempts to turn a string slice key from the user into a Nostr-Sdk Keys object.
|
||||||
@@ -118,15 +108,7 @@ fn nip05_promise_wrapper(id: &str) -> Promise<Result<Keys, LoginError>> {
|
|||||||
/// - Private hex key: "5dab..."
|
/// - Private hex key: "5dab..."
|
||||||
/// - NIP-05 address: "example@nostr.com"
|
/// - NIP-05 address: "example@nostr.com"
|
||||||
///
|
///
|
||||||
/// For NIP-05 addresses, retrieval of the public key is an asynchronous operation that returns a `Promise`, so it
|
pub async fn get_login_key(key: &str) -> Result<Keys, LoginError> {
|
||||||
/// will not be immediately ready.
|
|
||||||
/// All other key formats are processed synchronously even though they are still behind a Promise, they will be
|
|
||||||
/// available immediately.
|
|
||||||
///
|
|
||||||
/// Returns a `Promise` that resolves to `Result<Keys, LoginError>`. `LoginError` is returned in case of invalid format,
|
|
||||||
/// unsupported key types, or network errors during NIP-05 address resolution.
|
|
||||||
///
|
|
||||||
pub fn perform_key_retrieval(key: &str) -> Promise<Result<Keys, LoginError>> {
|
|
||||||
let tmp_key: &str = if let Some(stripped) = key.strip_prefix('@') {
|
let tmp_key: &str = if let Some(stripped) = key.strip_prefix('@') {
|
||||||
stripped
|
stripped
|
||||||
} else {
|
} else {
|
||||||
@@ -134,16 +116,16 @@ pub fn perform_key_retrieval(key: &str) -> Promise<Result<Keys, LoginError>> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if retrieving_nip05_pubkey(tmp_key) {
|
if retrieving_nip05_pubkey(tmp_key) {
|
||||||
nip05_promise_wrapper(tmp_key)
|
match get_nip05_pubkey(tmp_key).await {
|
||||||
|
Ok(pubkey) => Ok(Keys::from_public_key(pubkey)),
|
||||||
|
Err(e) => Err(LoginError::Nip05Failed(e.to_string())),
|
||||||
|
}
|
||||||
|
} else if let Ok(pubkey) = PublicKey::from_str(tmp_key) {
|
||||||
|
Ok(Keys::from_public_key(pubkey))
|
||||||
|
} else if let Ok(secret_key) = SecretKey::from_str(tmp_key) {
|
||||||
|
Ok(Keys::new(secret_key))
|
||||||
} else {
|
} else {
|
||||||
let result: Result<Keys, LoginError> = if let Ok(pubkey) = PublicKey::from_str(tmp_key) {
|
Err(LoginError::InvalidKey)
|
||||||
Ok(Keys::from_public_key(pubkey))
|
|
||||||
} else if let Ok(secret_key) = SecretKey::from_str(tmp_key) {
|
|
||||||
Ok(Keys::new(secret_key))
|
|
||||||
} else {
|
|
||||||
Err(LoginError::InvalidKey)
|
|
||||||
};
|
|
||||||
Promise::from_ready(result)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,8 +134,17 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::promise_assert;
|
use crate::promise_assert;
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn test_pubkey() {
|
async fn test_pubkey_async() {
|
||||||
|
let pubkey_str = "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s";
|
||||||
|
let expected_pubkey = PublicKey::from_str(pubkey_str).expect("Should not have errored.");
|
||||||
|
let login_key_result = get_login_key(pubkey_str).await;
|
||||||
|
|
||||||
|
assert_eq!(Ok(Keys::from_public_key(expected_pubkey)), login_key_result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn test_pubkey() {
|
||||||
let pubkey_str = "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s";
|
let pubkey_str = "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s";
|
||||||
let expected_pubkey = PublicKey::from_str(pubkey_str).expect("Should not have errored.");
|
let expected_pubkey = PublicKey::from_str(pubkey_str).expect("Should not have errored.");
|
||||||
let login_key_result = perform_key_retrieval(pubkey_str);
|
let login_key_result = perform_key_retrieval(pubkey_str);
|
||||||
@@ -165,8 +156,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
fn test_hex_pubkey() {
|
async fn test_hex_pubkey() {
|
||||||
let pubkey_str = "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245";
|
let pubkey_str = "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245";
|
||||||
let expected_pubkey = PublicKey::from_str(pubkey_str).expect("Should not have errored.");
|
let expected_pubkey = PublicKey::from_str(pubkey_str).expect("Should not have errored.");
|
||||||
let login_key_result = perform_key_retrieval(pubkey_str);
|
let login_key_result = perform_key_retrieval(pubkey_str);
|
||||||
@@ -178,8 +169,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
fn test_privkey() {
|
async fn test_privkey() {
|
||||||
let privkey_str = "nsec1g8wt3hlwjpa4827xylr3r0lccufxltyekhraexes8lqmpp2hensq5aujhs";
|
let privkey_str = "nsec1g8wt3hlwjpa4827xylr3r0lccufxltyekhraexes8lqmpp2hensq5aujhs";
|
||||||
let expected_privkey = SecretKey::from_str(privkey_str).expect("Should not have errored.");
|
let expected_privkey = SecretKey::from_str(privkey_str).expect("Should not have errored.");
|
||||||
let login_key_result = perform_key_retrieval(privkey_str);
|
let login_key_result = perform_key_retrieval(privkey_str);
|
||||||
@@ -191,8 +182,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
fn test_hex_privkey() {
|
async fn test_hex_privkey() {
|
||||||
let privkey_str = "41dcb8dfee907b53abc627c711bff8c7126fac99b5c7dc9b303fc1b08557cce0";
|
let privkey_str = "41dcb8dfee907b53abc627c711bff8c7126fac99b5c7dc9b303fc1b08557cce0";
|
||||||
let expected_privkey = SecretKey::from_str(privkey_str).expect("Should not have errored.");
|
let expected_privkey = SecretKey::from_str(privkey_str).expect("Should not have errored.");
|
||||||
let login_key_result = perform_key_retrieval(privkey_str);
|
let login_key_result = perform_key_retrieval(privkey_str);
|
||||||
@@ -204,8 +195,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
fn test_nip05() {
|
async fn test_nip05() {
|
||||||
let nip05_str = "damus@damus.io";
|
let nip05_str = "damus@damus.io";
|
||||||
let expected_pubkey =
|
let expected_pubkey =
|
||||||
PublicKey::from_str("npub18m76awca3y37hkvuneavuw6pjj4525fw90necxmadrvjg0sdy6qsngq955")
|
PublicKey::from_str("npub18m76awca3y37hkvuneavuw6pjj4525fw90necxmadrvjg0sdy6qsngq955")
|
||||||
@@ -218,16 +209,4 @@ mod tests {
|
|||||||
&login_key_result
|
&login_key_result
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_nip05_pubkey() {
|
|
||||||
let nip05_str = "damus@damus.io";
|
|
||||||
let expected_pubkey =
|
|
||||||
PublicKey::from_str("npub18m76awca3y37hkvuneavuw6pjj4525fw90necxmadrvjg0sdy6qsngq955")
|
|
||||||
.expect("Should not have errored.");
|
|
||||||
let login_key_result = get_nip05_pubkey(nip05_str);
|
|
||||||
|
|
||||||
let res = login_key_result.block_and_take().expect("Should not error");
|
|
||||||
assert_eq!(expected_pubkey, res);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,11 @@ use egui::{TextBuffer, TextEdit};
|
|||||||
use nostr_sdk::Keys;
|
use nostr_sdk::Keys;
|
||||||
use poll_promise::Promise;
|
use poll_promise::Promise;
|
||||||
|
|
||||||
/// Helper storage object for retrieving the plaintext key from the user and converting it into a
|
/// The UI view interface to log in to a nostr account.
|
||||||
/// nostr-sdk Keys object if possible.
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct LoginManager {
|
pub struct LoginManager {
|
||||||
login_key: String,
|
login_key: String,
|
||||||
promise: Option<Promise<Result<Keys, LoginError>>>,
|
promise_query: Option<(String, Promise<Result<Keys, LoginError>>)>,
|
||||||
error: Option<LoginError>,
|
error: Option<LoginError>,
|
||||||
key_on_error: Option<String>,
|
key_on_error: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -18,12 +17,13 @@ impl<'a> LoginManager {
|
|||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
LoginManager {
|
LoginManager {
|
||||||
login_key: String::new(),
|
login_key: String::new(),
|
||||||
promise: None,
|
promise_query: None,
|
||||||
error: None,
|
error: None,
|
||||||
key_on_error: None,
|
key_on_error: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the textedit for the login UI without exposing the key variable
|
||||||
pub fn get_login_textedit(
|
pub fn get_login_textedit(
|
||||||
&'a mut self,
|
&'a mut self,
|
||||||
textedit_closure: fn(&'a mut dyn TextBuffer) -> TextEdit<'a>,
|
textedit_closure: fn(&'a mut dyn TextBuffer) -> TextEdit<'a>,
|
||||||
@@ -31,14 +31,30 @@ impl<'a> LoginManager {
|
|||||||
textedit_closure(&mut self.login_key)
|
textedit_closure(&mut self.login_key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// User pressed the 'login' button
|
||||||
pub fn apply_login(&'a mut self) {
|
pub fn apply_login(&'a mut self) {
|
||||||
self.promise = Some(perform_key_retrieval(&self.login_key));
|
let new_promise = match &self.promise_query {
|
||||||
|
Some((query, _)) => {
|
||||||
|
if query != &self.login_key {
|
||||||
|
Some(perform_key_retrieval(&self.login_key))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => Some(perform_key_retrieval(&self.login_key)),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(new_promise) = new_promise {
|
||||||
|
self.promise_query = Some((self.login_key.clone(), new_promise));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether to indicate to the user that there is a network operation occuring
|
||||||
pub fn is_awaiting_network(&self) -> bool {
|
pub fn is_awaiting_network(&self) -> bool {
|
||||||
self.promise.is_some()
|
self.promise_query.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether to indicate to the user that a login error occured
|
||||||
pub fn check_for_error(&'a mut self) -> Option<&'a LoginError> {
|
pub fn check_for_error(&'a mut self) -> Option<&'a LoginError> {
|
||||||
if let Some(error_key) = &self.key_on_error {
|
if let Some(error_key) = &self.key_on_error {
|
||||||
if self.login_key != *error_key {
|
if self.login_key != *error_key {
|
||||||
@@ -50,10 +66,11 @@ impl<'a> LoginManager {
|
|||||||
self.error.as_ref()
|
self.error.as_ref()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether to indicate to the user that a successful login occured
|
||||||
pub fn check_for_successful_login(&mut self) -> Option<Keys> {
|
pub fn check_for_successful_login(&mut self) -> Option<Keys> {
|
||||||
if let Some(promise) = &mut self.promise {
|
if let Some((_, promise)) = &mut self.promise_query {
|
||||||
if promise.ready().is_some() {
|
if promise.ready().is_some() {
|
||||||
if let Some(promise) = self.promise.take() {
|
if let Some((_, promise)) = self.promise_query.take() {
|
||||||
match promise.block_and_take() {
|
match promise.block_and_take() {
|
||||||
Ok(key) => {
|
Ok(key) => {
|
||||||
return Some(key);
|
return Some(key);
|
||||||
@@ -76,10 +93,9 @@ mod tests {
|
|||||||
use nostr_sdk::PublicKey;
|
use nostr_sdk::PublicKey;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
#[test]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
fn test_retrieve_key() {
|
async fn test_retrieve_key() {
|
||||||
let mut manager = LoginManager::new();
|
let mut manager = LoginManager::new();
|
||||||
let manager_ref = &mut manager;
|
|
||||||
let expected_str = "3efdaebb1d8923ebd99c9e7ace3b4194ab45512e2be79c1b7d68d9243e0d2681";
|
let expected_str = "3efdaebb1d8923ebd99c9e7ace3b4194ab45512e2be79c1b7d68d9243e0d2681";
|
||||||
let expected_key = Keys::from_public_key(PublicKey::from_hex(expected_str).unwrap());
|
let expected_key = Keys::from_public_key(PublicKey::from_hex(expected_str).unwrap());
|
||||||
|
|
||||||
@@ -89,24 +105,37 @@ mod tests {
|
|||||||
let cur_time = start_time.elapsed();
|
let cur_time = start_time.elapsed();
|
||||||
|
|
||||||
if cur_time < Duration::from_millis(10u64) {
|
if cur_time < Duration::from_millis(10u64) {
|
||||||
let key = "test";
|
let _ = manager.get_login_textedit(|text| {
|
||||||
manager_ref.login_key = String::from(key);
|
text.clear();
|
||||||
manager_ref.promise = Some(perform_key_retrieval(key));
|
text.insert_text("test", 0);
|
||||||
|
egui::TextEdit::singleline(text)
|
||||||
|
});
|
||||||
|
manager.apply_login();
|
||||||
} else if cur_time < Duration::from_millis(30u64) {
|
} else if cur_time < Duration::from_millis(30u64) {
|
||||||
let key = "test2";
|
let _ = manager.get_login_textedit(|text| {
|
||||||
manager_ref.login_key = String::from(key);
|
text.clear();
|
||||||
manager_ref.promise = Some(perform_key_retrieval(key));
|
text.insert_text("test2", 0);
|
||||||
|
egui::TextEdit::singleline(text)
|
||||||
|
});
|
||||||
|
manager.apply_login();
|
||||||
} else {
|
} else {
|
||||||
manager_ref.login_key = String::from(expected_str);
|
let _ = manager.get_login_textedit(|text| {
|
||||||
manager_ref.promise = Some(perform_key_retrieval(expected_str));
|
text.clear();
|
||||||
|
text.insert_text(
|
||||||
|
"3efdaebb1d8923ebd99c9e7ace3b4194ab45512e2be79c1b7d68d9243e0d2681",
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
egui::TextEdit::singleline(text)
|
||||||
|
});
|
||||||
|
manager.apply_login();
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(key) = manager_ref.check_for_successful_login() {
|
if let Some(key) = manager.check_for_successful_login() {
|
||||||
assert_eq!(expected_key, key);
|
assert_eq!(expected_key, key);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
panic!();
|
panic!("Test failed to get expected key.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user