mirror of
https://github.com/nushell/nushell.git
synced 2024-11-22 00:13:21 +01:00
Feat/7725 url join (#7823)
# Description Added command: `url join`. Closes: #7725 # User-Facing Changes ``` Converts a record to url Search terms: scheme, username, password, hostname, port, path, query, fragment Usage: > url join Flags: -h, --help - Display the help message for this command Signatures: <record> | url join -> <string> Examples: Outputs a url representing the contents of this record > { "scheme": "http", "username": "", "password": "", "host": "www.pixiv.net", "port": "", "path": "/member_illust.php", "query": "mode=medium&illust_id=99260204", "fragment": "", "params": { "mode": "medium", "illust_id": "99260204" } } | url join Outputs a url representing the contents of this record > { "scheme": "http", "username": "user", "password": "pwd", "host": "www.pixiv.net", "port": "1234", "query": "test=a", "fragment": "" } | url join Outputs a url representing the contents of this record > { "scheme": "http", "host": "www.pixiv.net", "port": "1234", "path": "user", "fragment": "frag" } | url join ``` # Questions - Which parameters should be required? Currenlty are: `scheme` and `host`.
This commit is contained in:
parent
d8027656b5
commit
8d5165c449
@ -439,6 +439,7 @@ pub fn create_default_context() -> EngineState {
|
||||
Url,
|
||||
UrlBuildQuery,
|
||||
UrlEncode,
|
||||
UrlJoin,
|
||||
UrlParse,
|
||||
Port,
|
||||
}
|
||||
|
338
crates/nu-command/src/network/url/join.rs
Normal file
338
crates/nu-command/src/network/url/join.rs
Normal file
@ -0,0 +1,338 @@
|
||||
use nu_protocol::engine::{Command, EngineState, Stack};
|
||||
use nu_protocol::{Category, Example, IntoPipelineData, ShellError, Signature, Span, Type, Value};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SubCommand;
|
||||
|
||||
impl Command for SubCommand {
|
||||
fn name(&self) -> &str {
|
||||
"url join"
|
||||
}
|
||||
|
||||
fn signature(&self) -> nu_protocol::Signature {
|
||||
Signature::build("url join")
|
||||
.input_output_types(vec![(Type::Record(vec![]), Type::String)])
|
||||
.category(Category::Network)
|
||||
}
|
||||
|
||||
fn usage(&self) -> &str {
|
||||
"Converts a record to url"
|
||||
}
|
||||
|
||||
fn search_terms(&self) -> Vec<&str> {
|
||||
vec![
|
||||
"scheme", "username", "password", "hostname", "port", "path", "query", "fragment",
|
||||
]
|
||||
}
|
||||
|
||||
fn examples(&self) -> Vec<Example> {
|
||||
vec![
|
||||
Example {
|
||||
description: "Outputs a url representing the contents of this record",
|
||||
example: r#"{
|
||||
"scheme": "http",
|
||||
"username": "",
|
||||
"password": "",
|
||||
"host": "www.pixiv.net",
|
||||
"port": "",
|
||||
"path": "/member_illust.php",
|
||||
"query": "mode=medium&illust_id=99260204",
|
||||
"fragment": "",
|
||||
"params":
|
||||
{
|
||||
"mode": "medium",
|
||||
"illust_id": "99260204"
|
||||
}
|
||||
} | url join"#,
|
||||
result: Some(Value::test_string(
|
||||
"http://www.pixiv.net/member_illust.php?mode=medium&illust_id=99260204",
|
||||
)),
|
||||
},
|
||||
Example {
|
||||
description: "Outputs a url representing the contents of this record",
|
||||
example: r#"{
|
||||
"scheme": "http",
|
||||
"username": "user",
|
||||
"password": "pwd",
|
||||
"host": "www.pixiv.net",
|
||||
"port": "1234",
|
||||
"query": "test=a",
|
||||
"fragment": ""
|
||||
} | url join"#,
|
||||
result: Some(Value::test_string(
|
||||
"http://user:pwd@www.pixiv.net:1234?test=a",
|
||||
)),
|
||||
},
|
||||
Example {
|
||||
description: "Outputs a url representing the contents of this record",
|
||||
example: r#"{
|
||||
"scheme": "http",
|
||||
"host": "www.pixiv.net",
|
||||
"port": "1234",
|
||||
"path": "user",
|
||||
"fragment": "frag"
|
||||
} | url join"#,
|
||||
result: Some(Value::test_string("http://www.pixiv.net:1234/user#frag")),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn run(
|
||||
&self,
|
||||
_engine_state: &EngineState,
|
||||
_stack: &mut Stack,
|
||||
call: &nu_protocol::ast::Call,
|
||||
input: nu_protocol::PipelineData,
|
||||
) -> Result<nu_protocol::PipelineData, nu_protocol::ShellError> {
|
||||
let head = call.head;
|
||||
|
||||
let output: Result<String, ShellError> = input
|
||||
.into_iter()
|
||||
.map(move |value| match value {
|
||||
Value::Record {
|
||||
ref cols,
|
||||
ref vals,
|
||||
span,
|
||||
} => {
|
||||
let url_components = cols
|
||||
.iter()
|
||||
.zip(vals.iter())
|
||||
.fold(Ok(UrlComponents::new()), |url, (k, v)| {
|
||||
url?.add_component(k.clone(), v.clone(), span)
|
||||
});
|
||||
|
||||
url_components?.to_url(span)
|
||||
}
|
||||
Value::Error { error } => Err(error),
|
||||
other => Err(ShellError::UnsupportedInput(
|
||||
"Expected a record from pipeline".to_string(),
|
||||
"value originates from here".into(),
|
||||
head,
|
||||
other.expect_span(),
|
||||
)),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Value::string(output?, head).into_pipeline_data())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct UrlComponents {
|
||||
scheme: Option<String>,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
host: Option<String>,
|
||||
port: Option<i64>,
|
||||
path: Option<String>,
|
||||
query: Option<String>,
|
||||
fragment: Option<String>,
|
||||
query_span: Option<Span>,
|
||||
params_span: Option<Span>,
|
||||
}
|
||||
|
||||
impl UrlComponents {
|
||||
fn new() -> Self {
|
||||
Default::default()
|
||||
}
|
||||
|
||||
pub fn add_component(self, key: String, value: Value, _span: Span) -> Result<Self, ShellError> {
|
||||
if key == "port" {
|
||||
return match value {
|
||||
Value::String { val, span } => {
|
||||
if val.trim().is_empty() {
|
||||
Ok(self)
|
||||
} else {
|
||||
match val.parse::<i64>() {
|
||||
Ok(p) => Ok(Self {
|
||||
port: Some(p),
|
||||
..self
|
||||
}),
|
||||
Err(_) => Err(ShellError::IncompatibleParametersSingle(
|
||||
String::from("Port parameter should represent an unsigned integer"),
|
||||
span,
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Int { val, span: _ } => Ok(Self {
|
||||
port: Some(val),
|
||||
..self
|
||||
}),
|
||||
Value::Error { error } => Err(error),
|
||||
other => Err(ShellError::IncompatibleParametersSingle(
|
||||
String::from(
|
||||
"Port parameter should be an unsigned integer or a string representing it",
|
||||
),
|
||||
other.expect_span(),
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
if key == "params" {
|
||||
return match value {
|
||||
Value::Record {
|
||||
ref cols,
|
||||
ref vals,
|
||||
span,
|
||||
} => {
|
||||
let mut qs = cols
|
||||
.iter()
|
||||
.zip(vals.iter())
|
||||
.map(|(k, v)| match v.as_string() {
|
||||
Ok(val) => Ok(format!("{}={}", k, val)),
|
||||
Err(err) => Err(err),
|
||||
})
|
||||
.collect::<Result<Vec<String>, ShellError>>()?
|
||||
.join("&");
|
||||
|
||||
qs = format!("?{}", qs);
|
||||
|
||||
if let Some(q) = self.query {
|
||||
if q != qs {
|
||||
// if query is present it means that also query_span is setted.
|
||||
return Err(ShellError::IncompatibleParameters {
|
||||
left_message: format!("Mismatch, qs from params is: {}", qs),
|
||||
left_span: value.expect_span(),
|
||||
right_message: format!("instead query is: {}", q),
|
||||
right_span: self.query_span.unwrap_or(Span::unknown()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
query: Some(qs),
|
||||
params_span: Some(span),
|
||||
..self
|
||||
})
|
||||
}
|
||||
Value::Error { error } => Err(error),
|
||||
other => Err(ShellError::IncompatibleParametersSingle(
|
||||
String::from("Key params has to be a record"),
|
||||
other.expect_span(),
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
// a part from port and params all other keys are strings.
|
||||
match value.as_string() {
|
||||
Ok(s) => {
|
||||
if s.trim().is_empty() {
|
||||
Ok(self)
|
||||
} else {
|
||||
match key.as_str() {
|
||||
"host" => Ok(Self {
|
||||
host: Some(s),
|
||||
..self
|
||||
}),
|
||||
"scheme" => Ok(Self {
|
||||
scheme: Some(s),
|
||||
..self
|
||||
}),
|
||||
"username" => Ok(Self {
|
||||
username: Some(s),
|
||||
..self
|
||||
}),
|
||||
"password" => Ok(Self {
|
||||
password: Some(s),
|
||||
..self
|
||||
}),
|
||||
"path" => Ok(Self {
|
||||
path: Some(if s.starts_with('/') {
|
||||
s
|
||||
} else {
|
||||
format!("/{}", s)
|
||||
}),
|
||||
..self
|
||||
}),
|
||||
"query" => {
|
||||
if let Some(q) = self.query {
|
||||
if q != s {
|
||||
// if query is present it means that also params_span is setted.
|
||||
return Err(ShellError::IncompatibleParameters {
|
||||
left_message: format!("Mismatch, query param is: {}", s),
|
||||
left_span: value.expect_span(),
|
||||
right_message: format!("instead qs from params is: {}", q),
|
||||
right_span: self.params_span.unwrap_or(Span::unknown()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
query: Some(format!("?{}", s)),
|
||||
query_span: Some(value.expect_span()),
|
||||
..self
|
||||
})
|
||||
}
|
||||
"fragment" => Ok(Self {
|
||||
fragment: Some(if s.starts_with('#') {
|
||||
s
|
||||
} else {
|
||||
format!("#{}", s)
|
||||
}),
|
||||
..self
|
||||
}),
|
||||
_ => Ok(self),
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => Ok(self),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_url(&self, span: Span) -> Result<String, ShellError> {
|
||||
let mut user_and_pwd: String = String::from("");
|
||||
|
||||
if let Some(usr) = &self.username {
|
||||
if let Some(pwd) = &self.password {
|
||||
user_and_pwd = format!("{}:{}@", usr, pwd);
|
||||
}
|
||||
}
|
||||
|
||||
let scheme_result = match &self.scheme {
|
||||
Some(s) => Ok(s),
|
||||
None => Err(UrlComponents::generate_shell_error_for_missing_parameter(
|
||||
String::from("scheme"),
|
||||
span,
|
||||
)),
|
||||
};
|
||||
|
||||
let host_result = match &self.host {
|
||||
Some(h) => Ok(h),
|
||||
None => Err(UrlComponents::generate_shell_error_for_missing_parameter(
|
||||
String::from("host"),
|
||||
span,
|
||||
)),
|
||||
};
|
||||
|
||||
Ok(format!(
|
||||
"{}://{}{}{}{}{}{}",
|
||||
scheme_result?,
|
||||
user_and_pwd,
|
||||
host_result?,
|
||||
self.port
|
||||
.map(|p| format!(":{}", p))
|
||||
.as_deref()
|
||||
.unwrap_or_default(),
|
||||
self.path.as_deref().unwrap_or_default(),
|
||||
self.query.as_deref().unwrap_or_default(),
|
||||
self.fragment.as_deref().unwrap_or_default()
|
||||
))
|
||||
}
|
||||
|
||||
fn generate_shell_error_for_missing_parameter(pname: String, span: Span) -> ShellError {
|
||||
ShellError::MissingParameter(pname, span)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_examples() {
|
||||
use crate::test_examples;
|
||||
|
||||
test_examples(SubCommand {})
|
||||
}
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
mod build_query;
|
||||
mod encode;
|
||||
mod join;
|
||||
mod parse;
|
||||
mod url_;
|
||||
|
||||
@ -8,4 +9,5 @@ use url::{self};
|
||||
pub use self::parse::SubCommand as UrlParse;
|
||||
pub use build_query::SubCommand as UrlBuildQuery;
|
||||
pub use encode::SubCommand as UrlEncode;
|
||||
pub use join::SubCommand as UrlJoin;
|
||||
pub use url_::Url;
|
||||
|
368
crates/nu-command/tests/commands/url/join.rs
Normal file
368
crates/nu-command/tests/commands/url/join.rs
Normal file
@ -0,0 +1,368 @@
|
||||
use nu_test_support::{nu, pipeline};
|
||||
|
||||
#[test]
|
||||
fn url_join_simple() {
|
||||
let actual = nu!(
|
||||
cwd: ".", pipeline(
|
||||
r#"
|
||||
{
|
||||
"scheme": "http",
|
||||
"username": "",
|
||||
"password": "",
|
||||
"host": "localhost",
|
||||
"port": "",
|
||||
} | url join
|
||||
"#
|
||||
)
|
||||
);
|
||||
|
||||
assert_eq!(actual.out, "http://localhost");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_join_with_only_user() {
|
||||
let actual = nu!(
|
||||
cwd: ".", pipeline(
|
||||
r#"
|
||||
{
|
||||
"scheme": "http",
|
||||
"username": "usr",
|
||||
"password": "",
|
||||
"host": "localhost",
|
||||
"port": "",
|
||||
} | url join
|
||||
"#
|
||||
)
|
||||
);
|
||||
|
||||
assert_eq!(actual.out, "http://localhost");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_join_with_only_pwd() {
|
||||
let actual = nu!(
|
||||
cwd: ".", pipeline(
|
||||
r#"
|
||||
{
|
||||
"scheme": "http",
|
||||
"username": "",
|
||||
"password": "pwd",
|
||||
"host": "localhost",
|
||||
"port": "",
|
||||
} | url join
|
||||
"#
|
||||
)
|
||||
);
|
||||
|
||||
assert_eq!(actual.out, "http://localhost");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_join_with_user_and_pwd() {
|
||||
let actual = nu!(
|
||||
cwd: ".", pipeline(
|
||||
r#"
|
||||
{
|
||||
"scheme": "http",
|
||||
"username": "usr",
|
||||
"password": "pwd",
|
||||
"host": "localhost",
|
||||
"port": "",
|
||||
} | url join
|
||||
"#
|
||||
)
|
||||
);
|
||||
|
||||
assert_eq!(actual.out, "http://usr:pwd@localhost");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_join_with_query() {
|
||||
let actual = nu!(
|
||||
cwd: ".", pipeline(
|
||||
r#"
|
||||
{
|
||||
"scheme": "http",
|
||||
"username": "usr",
|
||||
"password": "pwd",
|
||||
"host": "localhost",
|
||||
"query": "par_1=aaa&par_2=bbb"
|
||||
"port": "",
|
||||
} | url join
|
||||
"#
|
||||
)
|
||||
);
|
||||
|
||||
assert_eq!(actual.out, "http://usr:pwd@localhost?par_1=aaa&par_2=bbb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_join_with_params() {
|
||||
let actual = nu!(
|
||||
cwd: ".", pipeline(
|
||||
r#"
|
||||
{
|
||||
"scheme": "http",
|
||||
"username": "usr",
|
||||
"password": "pwd",
|
||||
"host": "localhost",
|
||||
"params": {
|
||||
"par_1": "aaa",
|
||||
"par_2": "bbb"
|
||||
},
|
||||
"port": "1234",
|
||||
} | url join
|
||||
"#
|
||||
)
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
actual.out,
|
||||
"http://usr:pwd@localhost:1234?par_1=aaa&par_2=bbb"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_join_with_same_query_and_params() {
|
||||
let actual = nu!(
|
||||
cwd: ".", pipeline(
|
||||
r#"
|
||||
{
|
||||
"scheme": "http",
|
||||
"username": "usr",
|
||||
"password": "pwd",
|
||||
"host": "localhost",
|
||||
"query": "par_1=aaa&par_2=bbb",
|
||||
"params": {
|
||||
"par_1": "aaa",
|
||||
"par_2": "bbb"
|
||||
},
|
||||
"port": "1234",
|
||||
} | url join
|
||||
"#
|
||||
)
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
actual.out,
|
||||
"http://usr:pwd@localhost:1234?par_1=aaa&par_2=bbb"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_join_with_different_query_and_params() {
|
||||
let actual = nu!(
|
||||
cwd: ".", pipeline(
|
||||
r#"
|
||||
{
|
||||
"scheme": "http",
|
||||
"username": "usr",
|
||||
"password": "pwd",
|
||||
"host": "localhost",
|
||||
"query": "par_1=aaa&par_2=bbb",
|
||||
"params": {
|
||||
"par_1": "aaab",
|
||||
"par_2": "bbb"
|
||||
},
|
||||
"port": "1234",
|
||||
} | url join
|
||||
"#
|
||||
)
|
||||
);
|
||||
|
||||
assert!(actual
|
||||
.err
|
||||
.contains("Mismatch, qs from params is: ?par_1=aaab&par_2=bbb"));
|
||||
assert!(actual
|
||||
.err
|
||||
.contains("instead query is: ?par_1=aaa&par_2=bbb"));
|
||||
|
||||
let actual = nu!(
|
||||
cwd: ".", pipeline(
|
||||
r#"
|
||||
{
|
||||
"scheme": "http",
|
||||
"username": "usr",
|
||||
"password": "pwd",
|
||||
"host": "localhost",
|
||||
"params": {
|
||||
"par_1": "aaab",
|
||||
"par_2": "bbb"
|
||||
},
|
||||
"query": "par_1=aaa&par_2=bbb",
|
||||
"port": "1234",
|
||||
} | url join
|
||||
"#
|
||||
)
|
||||
);
|
||||
|
||||
assert!(actual
|
||||
.err
|
||||
.contains("Mismatch, query param is: par_1=aaa&par_2=bbb"));
|
||||
assert!(actual
|
||||
.err
|
||||
.contains("instead qs from params is: ?par_1=aaab&par_2=bbb"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_join_with_invalid_params() {
|
||||
let actual = nu!(
|
||||
cwd: ".", pipeline(
|
||||
r#"
|
||||
{
|
||||
"scheme": "http",
|
||||
"username": "usr",
|
||||
"password": "pwd",
|
||||
"host": "localhost",
|
||||
"params": "aaa",
|
||||
"port": "1234",
|
||||
} | url join
|
||||
"#
|
||||
)
|
||||
);
|
||||
|
||||
assert!(actual.err.contains("Key params has to be a record"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_join_with_port() {
|
||||
let actual = nu!(
|
||||
cwd: ".", pipeline(
|
||||
r#"
|
||||
{
|
||||
"scheme": "http",
|
||||
"host": "localhost",
|
||||
"port": "1234",
|
||||
} | url join
|
||||
"#
|
||||
)
|
||||
);
|
||||
|
||||
assert_eq!(actual.out, "http://localhost:1234");
|
||||
|
||||
let actual = nu!(
|
||||
cwd: ".", pipeline(
|
||||
r#"
|
||||
{
|
||||
"scheme": "http",
|
||||
"host": "localhost",
|
||||
"port": 1234,
|
||||
} | url join
|
||||
"#
|
||||
)
|
||||
);
|
||||
|
||||
assert_eq!(actual.out, "http://localhost:1234");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_join_with_invalid_port() {
|
||||
let actual = nu!(
|
||||
cwd: ".", pipeline(
|
||||
r#"
|
||||
{
|
||||
"scheme": "http",
|
||||
"host": "localhost",
|
||||
"port": "aaaa",
|
||||
} | url join
|
||||
"#
|
||||
)
|
||||
);
|
||||
|
||||
assert!(actual
|
||||
.err
|
||||
.contains("Port parameter should represent an unsigned integer"));
|
||||
|
||||
let actual = nu!(
|
||||
cwd: ".", pipeline(
|
||||
r#"
|
||||
{
|
||||
"scheme": "http",
|
||||
"host": "localhost",
|
||||
"port": [],
|
||||
} | url join
|
||||
"#
|
||||
)
|
||||
);
|
||||
|
||||
assert!(actual
|
||||
.err
|
||||
.contains("Port parameter should be an unsigned integer or a string representing it"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_join_with_missing_scheme() {
|
||||
let actual = nu!(
|
||||
cwd: ".", pipeline(
|
||||
r#"
|
||||
{
|
||||
"host": "localhost"
|
||||
} | url join
|
||||
"#
|
||||
)
|
||||
);
|
||||
|
||||
assert!(actual.err.contains("missing parameter: scheme"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_join_with_missing_host() {
|
||||
let actual = nu!(
|
||||
cwd: ".", pipeline(
|
||||
r#"
|
||||
{
|
||||
"scheme": "https"
|
||||
} | url join
|
||||
"#
|
||||
)
|
||||
);
|
||||
|
||||
assert!(actual.err.contains("missing parameter: host"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_join_with_fragment() {
|
||||
let actual = nu!(
|
||||
cwd: ".", pipeline(
|
||||
r#"
|
||||
{
|
||||
"scheme": "http",
|
||||
"username": "usr",
|
||||
"password": "pwd",
|
||||
"host": "localhost",
|
||||
"fragment": "frag",
|
||||
"port": "1234",
|
||||
} | url join
|
||||
"#
|
||||
)
|
||||
);
|
||||
|
||||
assert_eq!(actual.out, "http://usr:pwd@localhost:1234#frag");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_join_with_fragment_and_params() {
|
||||
let actual = nu!(
|
||||
cwd: ".", pipeline(
|
||||
r#"
|
||||
{
|
||||
"scheme": "http",
|
||||
"username": "usr",
|
||||
"password": "pwd",
|
||||
"host": "localhost",
|
||||
"params": {
|
||||
"par_1": "aaa",
|
||||
"par_2": "bbb"
|
||||
},
|
||||
"port": "1234",
|
||||
"fragment": "frag"
|
||||
} | url join
|
||||
"#
|
||||
)
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
actual.out,
|
||||
"http://usr:pwd@localhost:1234?par_1=aaa&par_2=bbb#frag"
|
||||
);
|
||||
}
|
@ -1 +1,2 @@
|
||||
mod join;
|
||||
mod parse;
|
||||
|
Loading…
Reference in New Issue
Block a user