Migrate server to new crate
This commit is contained in:
parent
b806b28a6e
commit
321d36b743
18 changed files with 167 additions and 63 deletions
40
server/Cargo.toml
Normal file
40
server/Cargo.toml
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
[package]
|
||||
name = "server"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.93"
|
||||
jwt-simple = "0.12.10"
|
||||
log = "0.4.22"
|
||||
reqwest = { version = "0.12.9", default-features = false, features = [
|
||||
"json",
|
||||
"rustls-tls",
|
||||
] }
|
||||
serde = "1.0.215"
|
||||
serde_json = "1.0.133"
|
||||
tokio = { version = "1.41.1", features = [
|
||||
"rt",
|
||||
"macros",
|
||||
"sync",
|
||||
"io-util",
|
||||
"rt-multi-thread",
|
||||
"signal",
|
||||
"time",
|
||||
"tracing",
|
||||
"fs",
|
||||
] }
|
||||
tonic = "0.12.3"
|
||||
clap = { version = "4.4", features = ["derive"] }
|
||||
rusqlite = { version = "0.31.0", features = ["blob"] }
|
||||
dotenvy = "0.15.7"
|
||||
config = "0.14.1"
|
||||
prost = "0.13.3"
|
||||
prost-types = "0.13.3"
|
||||
|
||||
|
||||
[features]
|
||||
bundled = ["rusqlite/bundled"]
|
||||
|
||||
[build-dependencies]
|
||||
tonic-build = "0.12.3"
|
||||
4
server/build.rs
Normal file
4
server/build.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
tonic_build::configure().compile_protos(&["../proto/burrowweb.proto"], &["../proto"])?;
|
||||
Ok(())
|
||||
}
|
||||
66
server/src/auth/server/providers/mod.rs
Normal file
66
server/src/auth/server/providers/mod.rs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
pub mod slack;
|
||||
use self::grpc_defs::JwtInfo;
|
||||
|
||||
pub use super::{db, grpc_defs, settings::BurrowAuthServerConfig};
|
||||
use anyhow::{anyhow, Result};
|
||||
use jwt_simple::{
|
||||
claims::{Claims, NoCustomClaims},
|
||||
prelude::{Duration, Ed25519KeyPair, EdDSAKeyPairLike, EdDSAPublicKeyLike},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub type KeypairT = Ed25519KeyPair;
|
||||
|
||||
#[derive(Serialize, Deserialize, Default, Debug, PartialEq, Eq, Clone)]
|
||||
pub struct OpenIdUser {
|
||||
pub sub: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
struct OpenIDCustomField {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl OpenIdUser {
|
||||
pub fn try_from_jwt(jwt_info: &JwtInfo, keypair: &KeypairT) -> Result<Self> {
|
||||
let claims = keypair
|
||||
.public_key()
|
||||
.verify_token::<OpenIDCustomField>(&jwt_info.jwt, None)?;
|
||||
Ok(Self {
|
||||
sub: claims.subject.ok_or(anyhow!("No Subject!"))?,
|
||||
name: claims.custom.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl JwtInfo {
|
||||
fn try_from_oid(oid_user: OpenIdUser, keypair: &KeypairT) -> Result<Self> {
|
||||
let claims = Claims::with_custom_claims(
|
||||
OpenIDCustomField { name: oid_user.name },
|
||||
Duration::from_days(10),
|
||||
)
|
||||
.with_subject(oid_user.sub);
|
||||
let jwt = keypair.sign(claims)?;
|
||||
Ok(Self { jwt })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_jwt() -> Result<()> {
|
||||
let key_pair = Ed25519KeyPair::generate();
|
||||
let sample_usr = OpenIdUser {
|
||||
sub: "Spanish".into(),
|
||||
name: "Inquisition".into(),
|
||||
};
|
||||
let encoded = JwtInfo::try_from_oid(sample_usr.clone(), &key_pair)?;
|
||||
println!("{}", encoded.jwt);
|
||||
let decoded = OpenIdUser::try_from_jwt(&encoded, &key_pair)?;
|
||||
assert_eq!(decoded, sample_usr);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
1
server/src/build.rs
Normal file
1
server/src/build.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
24
server/src/client.rs
Normal file
24
server/src/client.rs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
use std::env::var;
|
||||
|
||||
use anyhow::Result;
|
||||
use reqwest::Url;
|
||||
|
||||
pub async fn login() -> Result<()> {
|
||||
let state = "vt :P";
|
||||
let nonce = "no";
|
||||
|
||||
let mut url = Url::parse("https://slack.com/openid/connect/authorize")?;
|
||||
let mut q = url.query_pairs_mut();
|
||||
q.append_pair("response_type", "code");
|
||||
q.append_pair("scope", "openid profile email");
|
||||
q.append_pair("client_id", &var("CLIENT_ID")?);
|
||||
q.append_pair("state", state);
|
||||
q.append_pair("team", &var("SLACK_TEAM_ID")?);
|
||||
q.append_pair("nonce", nonce);
|
||||
q.append_pair("redirect_uri", "https://burrow.rs/callback");
|
||||
drop(q);
|
||||
|
||||
println!("Continue auth in your browser:\n{}", url.as_str());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
6
server/src/main.rs
Normal file
6
server/src/main.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
pub mod client;
|
||||
pub mod server;
|
||||
|
||||
fn main() {
|
||||
println!("Hello, world!");
|
||||
}
|
||||
113
server/src/server/db.rs
Normal file
113
server/src/server/db.rs
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
use anyhow::Result;
|
||||
|
||||
pub static PATH: &str = "./server.sqlite3";
|
||||
|
||||
pub fn init_db() -> Result<()> {
|
||||
let conn = rusqlite::Connection::open(PATH)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS user (
|
||||
id PRIMARY KEY,
|
||||
created_at TEXT NOT NULL
|
||||
)",
|
||||
(),
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS user_connection (
|
||||
user_id INTEGER REFERENCES user(id) ON DELETE CASCADE,
|
||||
openid_provider TEXT NOT NULL,
|
||||
openid_user_id TEXT NOT NULL,
|
||||
openid_user_name TEXT NOT NULL,
|
||||
access_token TEXT NOT NULL,
|
||||
refresh_token TEXT,
|
||||
PRIMARY KEY (openid_provider, openid_user_id)
|
||||
)",
|
||||
(),
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS device (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT,
|
||||
public_key TEXT NOT NULL,
|
||||
apns_token TEXT UNIQUE,
|
||||
user_id INT REFERENCES user(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')) CHECK(created_at IS datetime(created_at)),
|
||||
ipv4 TEXT NOT NULL UNIQUE,
|
||||
ipv6 TEXT NOT NULL UNIQUE,
|
||||
access_token TEXT NOT NULL UNIQUE,
|
||||
refresh_token TEXT NOT NULL UNIQUE,
|
||||
expires_at TEXT NOT NULL DEFAULT (datetime('now', '+7 days')) CHECK(expires_at IS datetime(expires_at))
|
||||
)",
|
||||
()
|
||||
).unwrap();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn store_connection(
|
||||
openid_user: &super::providers::OpenIdUser,
|
||||
openid_provider: &str,
|
||||
access_token: &str,
|
||||
refresh_token: Option<&str>,
|
||||
) -> Result<()> {
|
||||
log::debug!("Storing openid user {:#?}", openid_user);
|
||||
let conn = rusqlite::Connection::open(PATH)?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO user (id, created_at) VALUES (?, datetime('now'))",
|
||||
(&openid_user.sub,),
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO user_connection (user_id, openid_provider, openid_user_id, openid_user_name, access_token, refresh_token) VALUES (
|
||||
(SELECT id FROM user WHERE id = ?),
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?
|
||||
)",
|
||||
(&openid_user.sub, &openid_provider, &openid_user.sub, &openid_user.name, access_token, refresh_token),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn store_device(
|
||||
openid_user: super::providers::OpenIdUser,
|
||||
openid_provider: &str,
|
||||
access_token: &str,
|
||||
refresh_token: Option<&str>,
|
||||
) -> Result<()> {
|
||||
log::debug!("Storing openid user {:#?}", openid_user);
|
||||
let conn = rusqlite::Connection::open(PATH)?;
|
||||
todo!();
|
||||
conn.execute(
|
||||
"INSERT INTO device (name, public_key, apns_token, user_id, ipv4, ipv6, access_token, refresh_token)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_device(id: i64) -> Result<()> {
|
||||
let conn = rusqlite::Connection::open(PATH)?;
|
||||
|
||||
conn.execute("DELETE FROM device WHERE id = ?", [id])?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_devices(user_id: i64) -> Result<Vec<String>> {
|
||||
let conn = rusqlite::Connection::open(PATH)?;
|
||||
let mut stmt = conn.prepare("SELECT name FROM device WHERE user_id = ?")?;
|
||||
|
||||
let result: Vec<String> = stmt
|
||||
.query_map([user_id], |row| {
|
||||
let name: String = row.get(0)?;
|
||||
Ok(name)
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
5
server/src/server/grpc_defs.rs
Normal file
5
server/src/server/grpc_defs.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
pub use burrowwebrpc::*;
|
||||
|
||||
pub mod burrowwebrpc {
|
||||
tonic::include_proto!("burrowweb");
|
||||
}
|
||||
53
server/src/server/grpc_server.rs
Normal file
53
server/src/server/grpc_server.rs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use tonic::{Request, Response, Status};
|
||||
|
||||
use super::providers::{KeypairT, OpenIdUser};
|
||||
|
||||
use super::{
|
||||
grpc_defs::{
|
||||
burrowwebrpc::burrow_web_server::BurrowWeb, CreateDeviceRequest, CreateDeviceResponse,
|
||||
Empty, JwtInfo, ListDevicesResponse, SlackAuthRequest,
|
||||
},
|
||||
providers::slack::auth,
|
||||
settings::BurrowAuthServerConfig,
|
||||
};
|
||||
|
||||
struct BurrowGrpcServer {
|
||||
config: Arc<BurrowAuthServerConfig>,
|
||||
jwt_keypair: Arc<KeypairT>,
|
||||
}
|
||||
|
||||
#[tonic::async_trait]
|
||||
impl BurrowWeb for BurrowGrpcServer {
|
||||
async fn slack_auth(
|
||||
&self,
|
||||
request: Request<SlackAuthRequest>,
|
||||
) -> Result<Response<JwtInfo>, Status> {
|
||||
auth(request, &self.jwt_keypair).await
|
||||
}
|
||||
|
||||
async fn create_device(
|
||||
&self,
|
||||
request: Request<CreateDeviceRequest>,
|
||||
) -> Result<Response<CreateDeviceResponse>, Status> {
|
||||
let req = request.into_inner();
|
||||
let jwt = req
|
||||
.jwt
|
||||
.ok_or(Status::invalid_argument("JWT Not existent!"))?;
|
||||
let oid_user = OpenIdUser::try_from_jwt(&jwt, &self.jwt_keypair)
|
||||
.map_err(|e| Status::invalid_argument(e.to_string()))?;
|
||||
todo!()
|
||||
}
|
||||
|
||||
async fn delete_device(&self, request: Request<JwtInfo>) -> Result<Response<Empty>, Status> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
async fn list_devices(
|
||||
&self,
|
||||
request: Request<JwtInfo>,
|
||||
) -> Result<Response<ListDevicesResponse>, Status> {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
50
server/src/server/mod.rs
Normal file
50
server/src/server/mod.rs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
pub mod db;
|
||||
pub mod grpc_defs;
|
||||
mod grpc_server;
|
||||
pub mod providers;
|
||||
pub mod settings;
|
||||
|
||||
use anyhow::Result;
|
||||
use providers::slack::auth;
|
||||
use tokio::signal;
|
||||
|
||||
pub async fn serve() -> Result<()> {
|
||||
db::init_db()?;
|
||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
|
||||
log::info!("Starting auth server on port 8080");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn shutdown_signal() {
|
||||
let ctrl_c = async {
|
||||
signal::ctrl_c()
|
||||
.await
|
||||
.expect("failed to install Ctrl+C handler");
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
signal::unix::signal(signal::unix::SignalKind::terminate())
|
||||
.expect("failed to install signal handler")
|
||||
.recv()
|
||||
.await;
|
||||
};
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
|
||||
tokio::select! {
|
||||
_ = ctrl_c => {},
|
||||
_ = terminate => {},
|
||||
}
|
||||
}
|
||||
|
||||
// mod db {
|
||||
// use rusqlite::{Connection, Result};
|
||||
|
||||
// #[derive(Debug)]
|
||||
// struct User {
|
||||
// id: i32,
|
||||
// created_at: String,
|
||||
// }
|
||||
// }
|
||||
66
server/src/server/providers/mod.rs
Normal file
66
server/src/server/providers/mod.rs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
pub mod slack;
|
||||
use self::grpc_defs::JwtInfo;
|
||||
|
||||
pub use super::{db, grpc_defs, settings::BurrowAuthServerConfig};
|
||||
use anyhow::{anyhow, Result};
|
||||
use jwt_simple::{
|
||||
claims::{Claims, NoCustomClaims},
|
||||
prelude::{Duration, Ed25519KeyPair, EdDSAKeyPairLike, EdDSAPublicKeyLike},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub type KeypairT = Ed25519KeyPair;
|
||||
|
||||
#[derive(Serialize, Deserialize, Default, Debug, PartialEq, Eq, Clone)]
|
||||
pub struct OpenIdUser {
|
||||
pub sub: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
struct OpenIDCustomField {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl OpenIdUser {
|
||||
pub fn try_from_jwt(jwt_info: &JwtInfo, keypair: &KeypairT) -> Result<Self> {
|
||||
let claims = keypair
|
||||
.public_key()
|
||||
.verify_token::<OpenIDCustomField>(&jwt_info.jwt, None)?;
|
||||
Ok(Self {
|
||||
sub: claims.subject.ok_or(anyhow!("No Subject!"))?,
|
||||
name: claims.custom.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl JwtInfo {
|
||||
fn try_from_oid(oid_user: OpenIdUser, keypair: &KeypairT) -> Result<Self> {
|
||||
let claims = Claims::with_custom_claims(
|
||||
OpenIDCustomField { name: oid_user.name },
|
||||
Duration::from_days(10),
|
||||
)
|
||||
.with_subject(oid_user.sub);
|
||||
let jwt = keypair.sign(claims)?;
|
||||
Ok(Self { jwt })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_jwt() -> Result<()> {
|
||||
let key_pair = Ed25519KeyPair::generate();
|
||||
let sample_usr = OpenIdUser {
|
||||
sub: "Spanish".into(),
|
||||
name: "Inquisition".into(),
|
||||
};
|
||||
let encoded = JwtInfo::try_from_oid(sample_usr.clone(), &key_pair)?;
|
||||
println!("{}", encoded.jwt);
|
||||
let decoded = OpenIdUser::try_from_jwt(&encoded, &key_pair)?;
|
||||
assert_eq!(decoded, sample_usr);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
107
server/src/server/providers/slack.rs
Normal file
107
server/src/server/providers/slack.rs
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
use anyhow::Result;
|
||||
use reqwest::header::AUTHORIZATION;
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::db::store_connection;
|
||||
use super::grpc_defs::{JwtInfo, SlackAuthRequest};
|
||||
use super::KeypairT;
|
||||
use tonic::{Request as TRequest, Response as TResponse, Result as TResult, Status as TStatus};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SlackToken {
|
||||
slack_token: String,
|
||||
}
|
||||
pub async fn auth(
|
||||
request: TRequest<SlackAuthRequest>,
|
||||
key_pair: &KeypairT,
|
||||
) -> TResult<TResponse<JwtInfo>, TStatus> {
|
||||
let slack_token = request.into_inner().slack_token;
|
||||
let slack_user = match fetch_slack_user(&slack_token).await {
|
||||
Ok(user) => user,
|
||||
Err(e) => {
|
||||
log::error!("Failed to fetch Slack user: {:?}", e);
|
||||
return Err(TStatus::unauthenticated("Failed to fetch slack user"));
|
||||
}
|
||||
};
|
||||
|
||||
log::info!(
|
||||
"Slack user {} ({}) logged in.",
|
||||
slack_user.name,
|
||||
slack_user.sub
|
||||
);
|
||||
|
||||
let _conn = match store_connection(&slack_user, "slack", &slack_token, None) {
|
||||
Ok(user) => user,
|
||||
Err(e) => {
|
||||
log::error!("Failed to fetch Slack user: {:?}", e);
|
||||
return Err(TStatus::unauthenticated("Failed to store connection"));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(TResponse::new(
|
||||
JwtInfo::try_from_oid(slack_user, &key_pair)
|
||||
.map_err(|e| TStatus::unauthenticated(format!("JWT Generation failed: {e}")))?,
|
||||
))
|
||||
}
|
||||
|
||||
async fn fetch_slack_user(access_token: &str) -> Result<super::OpenIdUser> {
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.get("https://slack.com/api/openid.connect.userInfo")
|
||||
.header(AUTHORIZATION, format!("Bearer {}", access_token))
|
||||
.send()
|
||||
.await?
|
||||
.json::<serde_json::Value>()
|
||||
.await?;
|
||||
|
||||
let res_ok = res
|
||||
.get("ok")
|
||||
.and_then(|v| v.as_bool())
|
||||
.ok_or(anyhow::anyhow!("Slack user object not ok!"))?;
|
||||
|
||||
if !res_ok {
|
||||
return Err(anyhow::anyhow!("Slack user object not ok!"));
|
||||
}
|
||||
|
||||
Ok(serde_json::from_value(res)?)
|
||||
}
|
||||
|
||||
// async fn fetch_save_slack_user_data(query: Query<CallbackQuery>) -> anyhow::Result<()> {
|
||||
// let client = reqwest::Client::new();
|
||||
// log::trace!("Code was {}", &query.code);
|
||||
// let mut url = Url::parse("https://slack.com/api/openid.connect.token")?;
|
||||
|
||||
// {
|
||||
// let mut q = url.query_pairs_mut();
|
||||
// q.append_pair("client_id", &var("CLIENT_ID")?);
|
||||
// q.append_pair("client_secret", &var("CLIENT_SECRET")?);
|
||||
// q.append_pair("code", &query.code);
|
||||
// q.append_pair("grant_type", "authorization_code");
|
||||
// q.append_pair("redirect_uri", "https://burrow.rs/callback");
|
||||
// }
|
||||
|
||||
// let data = client
|
||||
// .post(url)
|
||||
// .send()
|
||||
// .await?
|
||||
// .json::<slack::CodeExchangeResponse>()
|
||||
// .await?;
|
||||
|
||||
// if !data.ok {
|
||||
// return Err(anyhow::anyhow!("Slack code exchange response not ok!"));
|
||||
// }
|
||||
|
||||
// if let Some(access_token) = data.access_token {
|
||||
// log::trace!("Access token is {access_token}");
|
||||
// let user = slack::fetch_slack_user(&access_token)
|
||||
// .await
|
||||
// .map_err(|err| anyhow::anyhow!("Failed to fetch Slack user info {:#?}", err))?;
|
||||
|
||||
// db::store_user(user, access_token, String::new())
|
||||
// .map_err(|_| anyhow::anyhow!("Failed to store user in db"))?;
|
||||
|
||||
// Ok(())
|
||||
// } else {
|
||||
// Err(anyhow::anyhow!("Access token not found in response"))
|
||||
// }
|
||||
// }
|
||||
23
server/src/server/settings.rs
Normal file
23
server/src/server/settings.rs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
use config::{Config, ConfigError, Environment};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct BurrowAuthServerConfig {
|
||||
jwt_secret_key: String,
|
||||
jwt_public_key: String,
|
||||
}
|
||||
|
||||
impl BurrowAuthServerConfig {
|
||||
pub fn new() -> Result<Self, ConfigError> {
|
||||
let s = Config::builder()
|
||||
.add_source(Environment::default())
|
||||
.build()?;
|
||||
s.try_deserialize()
|
||||
}
|
||||
|
||||
/// Creates a new config that includes the dotenv
|
||||
pub fn new_dotenv() -> Result<Self, ConfigError> {
|
||||
dotenvy::dotenv().ok();
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue