forked from lavina/lavina
Compare commits
7 Commits
1905ac1977
...
809ddaac25
Author | SHA1 | Date |
---|---|---|
JustTestingV | 809ddaac25 | |
JustTestingV | 07e26ee77a | |
JustTestingV | d76e786ceb | |
JustTestingV | 6a473f3ebb | |
Nikita Vilunov | 7613055dde | |
G1ng3r | 7ff9ffdcf7 | |
JustTestingV | 614be92be3 |
File diff suppressed because it is too large
Load Diff
|
@ -54,7 +54,7 @@ async fn handle_socket(
|
||||||
socket_addr: &SocketAddr,
|
socket_addr: &SocketAddr,
|
||||||
players: PlayerRegistry,
|
players: PlayerRegistry,
|
||||||
rooms: RoomRegistry,
|
rooms: RoomRegistry,
|
||||||
termination: Deferred<()>, // TODO use it to stop the connection gracefully
|
termination: Deferred<()>,
|
||||||
mut storage: Storage,
|
mut storage: Storage,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
log::info!("Received an IRC connection from {socket_addr}");
|
log::info!("Received an IRC connection from {socket_addr}");
|
||||||
|
@ -62,19 +62,24 @@ async fn handle_socket(
|
||||||
let mut reader: BufReader<ReadHalf> = BufReader::new(reader);
|
let mut reader: BufReader<ReadHalf> = BufReader::new(reader);
|
||||||
let mut writer = BufWriter::new(writer);
|
let mut writer = BufWriter::new(writer);
|
||||||
|
|
||||||
let registered_user: Result<RegisteredUser> =
|
pin!(termination);
|
||||||
handle_registration(&mut reader, &mut writer, &mut storage, &config).await;
|
select! {
|
||||||
|
biased;
|
||||||
match registered_user {
|
_ = &mut termination =>{
|
||||||
Ok(user) => {
|
log::info!("Socket handling was terminated");
|
||||||
log::debug!("User registered");
|
return Ok(())
|
||||||
handle_registered_socket(config, players, rooms, &mut reader, &mut writer, user).await?;
|
},
|
||||||
}
|
registered_user = handle_registration(&mut reader, &mut writer, &mut storage, &config) =>
|
||||||
Err(err) => {
|
match registered_user {
|
||||||
log::debug!("Registration failed: {err}");
|
Ok(user) => {
|
||||||
}
|
log::debug!("User registered");
|
||||||
|
handle_registered_socket(config, players, rooms, &mut reader, &mut writer, user).await?;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
log::debug!("Registration failed: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stream.shutdown().await?;
|
stream.shutdown().await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
@ -180,14 +185,16 @@ async fn handle_registration<'a>(
|
||||||
writer.flush().await?;
|
writer.flush().await?;
|
||||||
}
|
}
|
||||||
CapabilitySubcommand::End => {
|
CapabilitySubcommand::End => {
|
||||||
let Some((username, realname)) = future_username else {
|
let Some((ref username, ref realname)) = future_username else {
|
||||||
todo!()
|
todo!();
|
||||||
};
|
};
|
||||||
let Some(nickname) = future_nickname.clone() else {
|
let Some(nickname) = future_nickname.clone() else {
|
||||||
todo!()
|
todo!();
|
||||||
};
|
};
|
||||||
|
let username = username.clone();
|
||||||
|
let realname = realname.clone();
|
||||||
let candidate_user = RegisteredUser {
|
let candidate_user = RegisteredUser {
|
||||||
nickname,
|
nickname: nickname.clone(),
|
||||||
username,
|
username,
|
||||||
realname,
|
realname,
|
||||||
};
|
};
|
||||||
|
@ -197,7 +204,15 @@ async fn handle_registration<'a>(
|
||||||
break Ok(candidate_user);
|
break Ok(candidate_user);
|
||||||
} else {
|
} else {
|
||||||
let Some(candidate_password) = pass else {
|
let Some(candidate_password) = pass else {
|
||||||
todo!();
|
sasl_fail_message(
|
||||||
|
config.server_name.clone(),
|
||||||
|
nickname.clone(),
|
||||||
|
"User credentials was not provided".into(),
|
||||||
|
)
|
||||||
|
.write_async(writer)
|
||||||
|
.await?;
|
||||||
|
writer.flush().await?;
|
||||||
|
continue;
|
||||||
};
|
};
|
||||||
auth_user(storage, &*candidate_user.nickname, &*candidate_password).await?;
|
auth_user(storage, &*candidate_user.nickname, &*candidate_password).await?;
|
||||||
break Ok(candidate_user);
|
break Ok(candidate_user);
|
||||||
|
@ -209,12 +224,20 @@ async fn handle_registration<'a>(
|
||||||
future_nickname = Some(nickname);
|
future_nickname = Some(nickname);
|
||||||
} else if let Some((username, realname)) = future_username.clone() {
|
} else if let Some((username, realname)) = future_username.clone() {
|
||||||
let candidate_user = RegisteredUser {
|
let candidate_user = RegisteredUser {
|
||||||
nickname,
|
nickname: nickname.clone(),
|
||||||
username,
|
username,
|
||||||
realname,
|
realname,
|
||||||
};
|
};
|
||||||
let Some(candidate_password) = pass else {
|
let Some(candidate_password) = pass else {
|
||||||
todo!();
|
sasl_fail_message(
|
||||||
|
config.server_name.clone(),
|
||||||
|
nickname.clone(),
|
||||||
|
"User credentials was not provided".into(),
|
||||||
|
)
|
||||||
|
.write_async(writer)
|
||||||
|
.await?;
|
||||||
|
writer.flush().await?;
|
||||||
|
continue;
|
||||||
};
|
};
|
||||||
auth_user(storage, &*candidate_user.nickname, &*candidate_password).await?;
|
auth_user(storage, &*candidate_user.nickname, &*candidate_password).await?;
|
||||||
break Ok(candidate_user);
|
break Ok(candidate_user);
|
||||||
|
@ -227,12 +250,20 @@ async fn handle_registration<'a>(
|
||||||
future_username = Some((username, realname));
|
future_username = Some((username, realname));
|
||||||
} else if let Some(nickname) = future_nickname.clone() {
|
} else if let Some(nickname) = future_nickname.clone() {
|
||||||
let candidate_user = RegisteredUser {
|
let candidate_user = RegisteredUser {
|
||||||
nickname,
|
nickname: nickname.clone(),
|
||||||
username,
|
username,
|
||||||
realname,
|
realname,
|
||||||
};
|
};
|
||||||
let Some(candidate_password) = pass else {
|
let Some(candidate_password) = pass else {
|
||||||
todo!();
|
sasl_fail_message(
|
||||||
|
config.server_name.clone(),
|
||||||
|
nickname.clone(),
|
||||||
|
"User credentials was not provided".into(),
|
||||||
|
)
|
||||||
|
.write_async(writer)
|
||||||
|
.await?;
|
||||||
|
writer.flush().await?;
|
||||||
|
continue;
|
||||||
};
|
};
|
||||||
auth_user(storage, &*candidate_user.nickname, &*candidate_password).await?;
|
auth_user(storage, &*candidate_user.nickname, &*candidate_password).await?;
|
||||||
break Ok(candidate_user);
|
break Ok(candidate_user);
|
||||||
|
@ -255,38 +286,59 @@ async fn handle_registration<'a>(
|
||||||
.await?;
|
.await?;
|
||||||
writer.flush().await?;
|
writer.flush().await?;
|
||||||
} else {
|
} else {
|
||||||
// TODO respond with 904
|
if let Some(nickname) = future_nickname.clone() {
|
||||||
todo!();
|
sasl_fail_message(
|
||||||
|
config.server_name.clone(),
|
||||||
|
nickname.clone(),
|
||||||
|
"Unsupported mechanism".into(),
|
||||||
|
)
|
||||||
|
.write_async(writer)
|
||||||
|
.await?;
|
||||||
|
writer.flush().await?;
|
||||||
|
} else {
|
||||||
|
break Err(anyhow::Error::msg("Wrong authentication sequence"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let body = AuthBody::from_str(body.as_bytes())?;
|
let body = AuthBody::from_str(body.as_bytes())?;
|
||||||
auth_user(storage, &body.login, &body.password).await?;
|
if let Err(e) = auth_user(storage, &body.login, &body.password).await {
|
||||||
let login: Str = body.login.into();
|
tracing::warn!("Authentication failed: {:?}", e);
|
||||||
validated_user = Some(login.clone());
|
if let Some(nickname) = future_nickname.clone() {
|
||||||
ServerMessage {
|
sasl_fail_message(config.server_name.clone(), nickname.clone(), "Bad credentials".into())
|
||||||
tags: vec![],
|
.write_async(writer)
|
||||||
sender: Some(config.server_name.clone().into()),
|
.await?;
|
||||||
body: ServerMessageBody::N900LoggedIn {
|
writer.flush().await?;
|
||||||
nick: login.clone(),
|
} else {
|
||||||
address: login.clone(),
|
}
|
||||||
account: login.clone(),
|
} else {
|
||||||
message: format!("You are now logged in as {}", login).into(),
|
let login: Str = body.login.into();
|
||||||
},
|
validated_user = Some(login.clone());
|
||||||
|
ServerMessage {
|
||||||
|
tags: vec![],
|
||||||
|
sender: Some(config.server_name.clone().into()),
|
||||||
|
body: ServerMessageBody::N900LoggedIn {
|
||||||
|
nick: login.clone(),
|
||||||
|
address: login.clone(),
|
||||||
|
account: login.clone(),
|
||||||
|
message: format!("You are now logged in as {}", login).into(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
.write_async(writer)
|
||||||
|
.await?;
|
||||||
|
ServerMessage {
|
||||||
|
tags: vec![],
|
||||||
|
sender: Some(config.server_name.clone().into()),
|
||||||
|
body: ServerMessageBody::N903SaslSuccess {
|
||||||
|
nick: login.clone(),
|
||||||
|
message: "SASL authentication successful".into(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
.write_async(writer)
|
||||||
|
.await?;
|
||||||
|
writer.flush().await?;
|
||||||
}
|
}
|
||||||
.write_async(writer)
|
|
||||||
.await?;
|
|
||||||
ServerMessage {
|
|
||||||
tags: vec![],
|
|
||||||
sender: Some(config.server_name.clone().into()),
|
|
||||||
body: ServerMessageBody::N903SaslSuccess {
|
|
||||||
nick: login.clone(),
|
|
||||||
message: "SASL authentication successful".into(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
.write_async(writer)
|
|
||||||
.await?;
|
|
||||||
writer.flush().await?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO handle abortion of authentication
|
// TODO handle abortion of authentication
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
|
@ -297,6 +349,14 @@ async fn handle_registration<'a>(
|
||||||
Ok(user)
|
Ok(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sasl_fail_message(sender: Str, nick: Str, text: Str) -> ServerMessage {
|
||||||
|
ServerMessage {
|
||||||
|
tags: vec![],
|
||||||
|
sender: Some(sender),
|
||||||
|
body: ServerMessageBody::N904SaslFail { nick, text },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn auth_user(storage: &mut Storage, login: &str, plain_password: &str) -> Result<()> {
|
async fn auth_user(storage: &mut Storage, login: &str, plain_password: &str) -> Result<()> {
|
||||||
let stored_user = storage.retrieve_user_by_name(login).await?;
|
let stored_user = storage.retrieve_user_by_name(login).await?;
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
use std::net::SocketAddr;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
|
@ -218,3 +219,30 @@ async fn scenario_cap_short_negotiation() -> Result<()> {
|
||||||
server.server.terminate().await?;
|
server.server.terminate().await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn terminate_socket_scenario() -> Result<()> {
|
||||||
|
let mut server = TestServer::start().await?;
|
||||||
|
let address: SocketAddr = ("127.0.0.1:0".parse().unwrap());
|
||||||
|
|
||||||
|
// test scenario
|
||||||
|
|
||||||
|
server.storage.create_user("tester").await?;
|
||||||
|
server.storage.set_password("tester", "password").await?;
|
||||||
|
|
||||||
|
let mut stream = TcpStream::connect(server.server.addr).await?;
|
||||||
|
let mut s = TestScope::new(&mut stream);
|
||||||
|
|
||||||
|
s.send("NICK tester").await?;
|
||||||
|
s.send("CAP REQ :sasl").await?;
|
||||||
|
s.send("USER UserName 0 * :Real Name").await?;
|
||||||
|
s.expect(":testserver CAP tester ACK :sasl").await?;
|
||||||
|
s.send("AUTHENTICATE PLAIN").await?;
|
||||||
|
s.expect(":testserver AUTHENTICATE +").await?;
|
||||||
|
|
||||||
|
stream.shutdown().await?;
|
||||||
|
server.server.terminate().await?;
|
||||||
|
|
||||||
|
assert!(TcpStream::connect(&address).await.is_err());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
|
@ -162,7 +162,7 @@ async fn handle_socket(
|
||||||
mut players: PlayerRegistry,
|
mut players: PlayerRegistry,
|
||||||
rooms: RoomRegistry,
|
rooms: RoomRegistry,
|
||||||
mut storage: Storage,
|
mut storage: Storage,
|
||||||
termination: Deferred<()>, // TODO use it to stop the connection gracefully
|
termination: Deferred<()>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
log::info!("Received an XMPP connection from {socket_addr}");
|
log::info!("Received an XMPP connection from {socket_addr}");
|
||||||
let mut reader_buf = vec![];
|
let mut reader_buf = vec![];
|
||||||
|
@ -190,11 +190,13 @@ async fn handle_socket(
|
||||||
pin!(termination);
|
pin!(termination);
|
||||||
select! {
|
select! {
|
||||||
biased;
|
biased;
|
||||||
_ = &mut termination => return Ok(()),
|
_ = &mut termination =>{
|
||||||
|
log::info!("Socket handling was terminated");
|
||||||
|
return Ok(())
|
||||||
|
},
|
||||||
authenticated = socket_auth(&mut xml_reader, &mut xml_writer, &mut reader_buf, &mut storage) => {
|
authenticated = socket_auth(&mut xml_reader, &mut xml_writer, &mut reader_buf, &mut storage) => {
|
||||||
match authenticated {
|
match authenticated {
|
||||||
Ok(authenticated) => {
|
Ok(authenticated) => {
|
||||||
log::debug!("User authenticated");
|
|
||||||
let mut connection = players.connect_to_player(authenticated.player_id.clone()).await;
|
let mut connection = players.connect_to_player(authenticated.player_id.clone()).await;
|
||||||
socket_final(
|
socket_final(
|
||||||
&mut xml_reader,
|
&mut xml_reader,
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
use std::net::SocketAddr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
@ -202,7 +203,7 @@ async fn terminate_socket() -> Result<()> {
|
||||||
let rooms = RoomRegistry::new(&mut metrics, storage.clone()).unwrap();
|
let rooms = RoomRegistry::new(&mut metrics, storage.clone()).unwrap();
|
||||||
let players = PlayerRegistry::empty(rooms.clone(), &mut metrics).unwrap();
|
let players = PlayerRegistry::empty(rooms.clone(), &mut metrics).unwrap();
|
||||||
let server = launch(config, players, rooms, metrics, storage.clone()).await.unwrap();
|
let server = launch(config, players, rooms, metrics, storage.clone()).await.unwrap();
|
||||||
|
let address: SocketAddr = ("127.0.0.1:0".parse().unwrap());
|
||||||
// test scenario
|
// test scenario
|
||||||
|
|
||||||
storage.create_user("tester").await?;
|
storage.create_user("tester").await?;
|
||||||
|
@ -213,6 +214,7 @@ async fn terminate_socket() -> Result<()> {
|
||||||
tracing::info!("TCP connection established");
|
tracing::info!("TCP connection established");
|
||||||
|
|
||||||
s.send(r#"<?xml version="1.0"?>"#).await?;
|
s.send(r#"<?xml version="1.0"?>"#).await?;
|
||||||
|
|
||||||
s.send(r#"<stream:stream xmlns:stream="http://etherx.jabber.org/streams" to="127.0.0.1" xml:lang="en" xmlns:xml="http://www.w3.org/XML/1998/namespace" xmlns="jabber:client" version="1.0">"#).await?;
|
s.send(r#"<stream:stream xmlns:stream="http://etherx.jabber.org/streams" to="127.0.0.1" xml:lang="en" xmlns:xml="http://www.w3.org/XML/1998/namespace" xmlns="jabber:client" version="1.0">"#).await?;
|
||||||
assert_matches!(s.next_xml_event().await?, Event::Decl(_) => {});
|
assert_matches!(s.next_xml_event().await?, Event::Decl(_) => {});
|
||||||
assert_matches!(s.next_xml_event().await?, Event::Start(b) => assert_eq!(b.local_name().into_inner(), b"stream"));
|
assert_matches!(s.next_xml_event().await?, Event::Start(b) => assert_eq!(b.local_name().into_inner(), b"stream"));
|
||||||
|
@ -224,7 +226,6 @@ async fn terminate_socket() -> Result<()> {
|
||||||
s.send(r#"<starttls/>"#).await?;
|
s.send(r#"<starttls/>"#).await?;
|
||||||
assert_matches!(s.next_xml_event().await?, Event::Empty(b) => assert_eq!(b.local_name().into_inner(), b"proceed"));
|
assert_matches!(s.next_xml_event().await?, Event::Empty(b) => assert_eq!(b.local_name().into_inner(), b"proceed"));
|
||||||
let buffer = s.buffer;
|
let buffer = s.buffer;
|
||||||
tracing::info!("TLS feature negotiation complete");
|
|
||||||
|
|
||||||
let connector = TlsConnector::from(Arc::new(
|
let connector = TlsConnector::from(Arc::new(
|
||||||
ClientConfig::builder()
|
ClientConfig::builder()
|
||||||
|
@ -236,23 +237,17 @@ async fn terminate_socket() -> Result<()> {
|
||||||
let mut stream = connector.connect(ServerName::IpAddress(server.addr.ip()), stream).await?;
|
let mut stream = connector.connect(ServerName::IpAddress(server.addr.ip()), stream).await?;
|
||||||
tracing::info!("TLS connection established");
|
tracing::info!("TLS connection established");
|
||||||
|
|
||||||
server.terminate().await?;
|
|
||||||
|
|
||||||
let mut s = TestScopeTls::new(&mut stream, buffer);
|
let mut s = TestScopeTls::new(&mut stream, buffer);
|
||||||
|
|
||||||
println!("--------------------------------");
|
s.send(r#"<?xml version="1.0"?>"#).await?;
|
||||||
let res = s.send(r#"<?xml version="1.0"?>"#).await;
|
s.send(r#"<stream:stream xmlns:stream="http://etherx.jabber.org/streams" to="127.0.0.1" xml:lang="en" xmlns:xml="http://www.w3.org/XML/1998/namespace" xmlns="jabber:client" version="1.0">"#).await?;
|
||||||
println!();
|
assert_matches!(s.next_xml_event().await?, Event::Decl(_) => {});
|
||||||
assert!(res.is_ok());
|
assert_matches!(s.next_xml_event().await?, Event::Start(b) => assert_eq!(b.local_name().into_inner(), b"stream"));
|
||||||
|
|
||||||
// s.send(r#"<stream:stream xmlns:stream="http://etherx.jabber.org/streams" to="127.0.0.1" xml:lang="en" xmlns:xml="http://www.w3.org/XML/1998/namespace" xmlns="jabber:client" version="1.0">"#).await?;
|
stream.shutdown().await?;
|
||||||
// assert_matches!(s.next_xml_event().await?, Event::Decl(_) => {});
|
server.terminate().await?;
|
||||||
// assert_matches!(s.next_xml_event().await?, Event::Start(b) => assert_eq!(b.local_name().into_inner(), b"stream"));
|
|
||||||
|
|
||||||
// stream.shutdown().await?;
|
assert!(TcpStream::connect(&address).await.is_err());
|
||||||
|
|
||||||
// wrap up
|
|
||||||
|
|
||||||
// server.terminate().await?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,6 +30,10 @@ fn token(input: &str) -> IResult<&str, &str> {
|
||||||
take_while(|i| i != '\n' && i != '\r')(input)
|
take_while(|i| i != '\n' && i != '\r')(input)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn params(input: &str) -> IResult<&str, &str> {
|
||||||
|
take_while(|i| i != '\n' && i != '\r' && i != ':')(input)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub enum Chan {
|
pub enum Chan {
|
||||||
/// #<name> — network-global channel, available from any server in the network.
|
/// #<name> — network-global channel, available from any server in the network.
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use nonempty::NonEmpty;
|
use nonempty::NonEmpty;
|
||||||
use tokio::io::AsyncWrite;
|
use tokio::io::AsyncWrite;
|
||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
|
@ -152,6 +154,10 @@ pub enum ServerMessageBody {
|
||||||
N903SaslSuccess {
|
N903SaslSuccess {
|
||||||
nick: Str,
|
nick: Str,
|
||||||
message: Str,
|
message: Str,
|
||||||
|
},
|
||||||
|
N904SaslFail {
|
||||||
|
nick: Str,
|
||||||
|
text: Str,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -369,6 +375,13 @@ impl ServerMessageBody {
|
||||||
writer.write_all(b" :").await?;
|
writer.write_all(b" :").await?;
|
||||||
writer.write_all(message.as_bytes()).await?;
|
writer.write_all(message.as_bytes()).await?;
|
||||||
}
|
}
|
||||||
|
ServerMessageBody::N904SaslFail { nick, text } => {
|
||||||
|
writer.write_all(b"904").await?;
|
||||||
|
writer.write_all(b" ").await?;
|
||||||
|
writer.write_all(nick.as_bytes()).await?;
|
||||||
|
writer.write_all(b" :").await?;
|
||||||
|
writer.write_all(text.as_bytes()).await?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
@ -391,7 +404,7 @@ fn server_message_body(input: &str) -> IResult<&str, ServerMessageBody> {
|
||||||
server_message_body_notice,
|
server_message_body_notice,
|
||||||
server_message_body_ping,
|
server_message_body_ping,
|
||||||
server_message_body_pong,
|
server_message_body_pong,
|
||||||
server_message_body_cap,
|
server_message_body_cap
|
||||||
))(input)
|
))(input)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
nightly-2023-12-07
|
nightly-2024-02-08
|
||||||
|
|
Loading…
Reference in New Issue