mirror of
https://github.com/redstrate/Kawari.git
synced 2025-04-21 23:17:45 +00:00
Add barebones account management page
This also moves the login pages to the login server, which makes the code slightly a bit more contained. The account management page doesn't allow you to do anything yet, but for future usage.
This commit is contained in:
parent
90e5e191e9
commit
09c347178c
9 changed files with 229 additions and 27 deletions
93
Cargo.lock
generated
93
Cargo.lock
generated
|
@ -96,6 +96,29 @@ dependencies = [
|
||||||
"tower-service",
|
"tower-service",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "axum-extra"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "45bf463831f5131b7d3c756525b305d40f1185b688565648a92e1392ca35713d"
|
||||||
|
dependencies = [
|
||||||
|
"axum",
|
||||||
|
"axum-core",
|
||||||
|
"bytes",
|
||||||
|
"cookie",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"mime",
|
||||||
|
"pin-project-lite",
|
||||||
|
"rustversion",
|
||||||
|
"serde",
|
||||||
|
"tower",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "backtrace"
|
name = "backtrace"
|
||||||
version = "0.3.74"
|
version = "0.3.74"
|
||||||
|
@ -220,6 +243,17 @@ version = "1.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
|
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cookie"
|
||||||
|
version = "0.18.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
|
||||||
|
dependencies = [
|
||||||
|
"percent-encoding",
|
||||||
|
"time",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.9.4"
|
version = "0.9.4"
|
||||||
|
@ -272,6 +306,15 @@ version = "0.2.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929"
|
checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deranged"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e"
|
||||||
|
dependencies = [
|
||||||
|
"powerfmt",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "derive_arbitrary"
|
name = "derive_arbitrary"
|
||||||
version = "1.4.1"
|
version = "1.4.1"
|
||||||
|
@ -807,6 +850,7 @@ name = "kawari"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
|
"axum-extra",
|
||||||
"binrw",
|
"binrw",
|
||||||
"bitflags 1.3.2",
|
"bitflags 1.3.2",
|
||||||
"md5",
|
"md5",
|
||||||
|
@ -1024,6 +1068,12 @@ dependencies = [
|
||||||
"tempfile",
|
"tempfile",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-conv"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
|
@ -1151,6 +1201,12 @@ version = "0.3.32"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "powerfmt"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ppv-lite86"
|
name = "ppv-lite86"
|
||||||
version = "0.2.21"
|
version = "0.2.21"
|
||||||
|
@ -1633,6 +1689,37 @@ dependencies = [
|
||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time"
|
||||||
|
version = "0.3.41"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40"
|
||||||
|
dependencies = [
|
||||||
|
"deranged",
|
||||||
|
"itoa",
|
||||||
|
"num-conv",
|
||||||
|
"powerfmt",
|
||||||
|
"serde",
|
||||||
|
"time-core",
|
||||||
|
"time-macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time-core"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time-macros"
|
||||||
|
version = "0.2.22"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49"
|
||||||
|
dependencies = [
|
||||||
|
"num-conv",
|
||||||
|
"time-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinystr"
|
name = "tinystr"
|
||||||
version = "0.7.6"
|
version = "0.7.6"
|
||||||
|
@ -1813,6 +1900,12 @@ version = "0.2.15"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "version_check"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "want"
|
name = "want"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
|
|
|
@ -50,6 +50,7 @@ serde_json = { version = "1.0", features = ["std"], default-features = false }
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# Used for the web servers
|
# Used for the web servers
|
||||||
axum = { version = "0.8", features = ["json", "tokio", "http1", "form", "query"], default-features = false }
|
axum = { version = "0.8", features = ["json", "tokio", "http1", "form", "query"], default-features = false }
|
||||||
|
axum-extra = { version = "0.10", features = ["cookie"], default-features = false }
|
||||||
|
|
||||||
# Serialization used in almost every server
|
# Serialization used in almost every server
|
||||||
serde = { version = "1.0", features = ["derive"], default-features = false }
|
serde = { version = "1.0", features = ["derive"], default-features = false }
|
||||||
|
|
|
@ -4,10 +4,28 @@ use axum::extract::{Query, State};
|
||||||
use axum::response::{Html, Redirect};
|
use axum::response::{Html, Redirect};
|
||||||
use axum::routing::post;
|
use axum::routing::post;
|
||||||
use axum::{Form, Router, routing::get};
|
use axum::{Form, Router, routing::get};
|
||||||
|
use axum_extra::extract::CookieJar;
|
||||||
|
use axum_extra::extract::cookie::{Cookie, Expiration};
|
||||||
use kawari::config::get_config;
|
use kawari::config::get_config;
|
||||||
use kawari::login::{LoginDatabase, LoginError};
|
use kawari::login::{LoginDatabase, LoginError};
|
||||||
|
use minijinja::{Environment, context};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
fn setup_default_environment() -> Environment<'static> {
|
||||||
|
let mut env = Environment::new();
|
||||||
|
env.add_template("login.html", include_str!("../../templates/login.html"))
|
||||||
|
.unwrap();
|
||||||
|
env.add_template(
|
||||||
|
"register.html",
|
||||||
|
include_str!("../../templates/register.html"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
env.add_template("account.html", include_str!("../../templates/account.html"))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
env
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct LoginServerState {
|
struct LoginServerState {
|
||||||
database: Arc<LoginDatabase>,
|
database: Arc<LoginDatabase>,
|
||||||
|
@ -109,6 +127,72 @@ async fn check_session(
|
||||||
serde_json::to_string(&accounts).unwrap_or(String::new())
|
serde_json::to_string(&accounts).unwrap_or(String::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn login() -> Html<String> {
|
||||||
|
let environment = setup_default_environment();
|
||||||
|
let template = environment.get_template("login.html").unwrap();
|
||||||
|
Html(template.render(context! {}).unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn register() -> Html<String> {
|
||||||
|
let environment = setup_default_environment();
|
||||||
|
let template = environment.get_template("register.html").unwrap();
|
||||||
|
Html(template.render(context! {}).unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
struct LoginInput {
|
||||||
|
username: Option<String>,
|
||||||
|
password: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn do_login(
|
||||||
|
State(state): State<LoginServerState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Form(input): Form<LoginInput>,
|
||||||
|
) -> (CookieJar, Redirect) {
|
||||||
|
tracing::info!("{:#?} logging in!", input.username,);
|
||||||
|
|
||||||
|
let Some(username) = input.username else {
|
||||||
|
panic!("Expected username!");
|
||||||
|
};
|
||||||
|
let Some(password) = input.password else {
|
||||||
|
panic!("Expected password!");
|
||||||
|
};
|
||||||
|
|
||||||
|
let sid = state.database.login_user(&username, &password).unwrap();
|
||||||
|
|
||||||
|
let cookie = Cookie::build(("cis_sessid", sid))
|
||||||
|
.path("/")
|
||||||
|
.secure(false)
|
||||||
|
.expires(Expiration::Session)
|
||||||
|
.http_only(true);
|
||||||
|
|
||||||
|
(jar.add(cookie), Redirect::to("/account/app/svc/manage"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn account(State(state): State<LoginServerState>, jar: CookieJar) -> Html<String> {
|
||||||
|
if let Some(session_id) = jar.get("cis_sessid") {
|
||||||
|
let user_id = state.database.get_user_id(session_id.value());
|
||||||
|
let username = state.database.get_username(user_id);
|
||||||
|
|
||||||
|
let environment = setup_default_environment();
|
||||||
|
let template = environment.get_template("account.html").unwrap();
|
||||||
|
Html(template.render(context! { username => username }).unwrap())
|
||||||
|
} else {
|
||||||
|
Html("You need to be logged in!".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn logout(State(state): State<LoginServerState>, jar: CookieJar) -> (CookieJar, Redirect) {
|
||||||
|
let config = get_config();
|
||||||
|
// TODO: remove session from database
|
||||||
|
(
|
||||||
|
jar.remove("cis_sessid"),
|
||||||
|
Redirect::to(&format!("http://{}/", config.web.server_name)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
tracing_subscriber::fmt::init();
|
tracing_subscriber::fmt::init();
|
||||||
|
@ -118,11 +202,19 @@ async fn main() {
|
||||||
};
|
};
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
|
// retail API
|
||||||
.route("/oauth/ffxivarr/login/top", get(top))
|
.route("/oauth/ffxivarr/login/top", get(top))
|
||||||
.route("/oauth/ffxivarr/login/login.send", post(login_send))
|
.route("/oauth/ffxivarr/login/login.send", post(login_send))
|
||||||
.route("/register", post(do_register))
|
// private server<->server API
|
||||||
// TODO: make these actually private
|
// TODO: make these actually private
|
||||||
.route("/_private/service_accounts", get(check_session))
|
.route("/_private/service_accounts", get(check_session))
|
||||||
|
// public website
|
||||||
|
.route("/oauth/oa/oauthlogin", get(login))
|
||||||
|
.route("/oauth/oa/oauthlogin", post(do_login))
|
||||||
|
.route("/oauth/oa/registligt", get(register))
|
||||||
|
.route("/oauth/oa/registlist", post(do_register))
|
||||||
|
.route("/account/app/svc/manage", get(account))
|
||||||
|
.route("/account/app/svc/logout", get(logout))
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
let config = get_config();
|
let config = get_config();
|
||||||
|
|
|
@ -9,18 +9,13 @@ fn setup_default_environment() -> Environment<'static> {
|
||||||
let mut env = Environment::new();
|
let mut env = Environment::new();
|
||||||
env.add_template("web.html", include_str!("../../templates/web.html"))
|
env.add_template("web.html", include_str!("../../templates/web.html"))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
env.add_template("login.html", include_str!("../../templates/login.html"))
|
|
||||||
.unwrap();
|
|
||||||
env.add_template(
|
|
||||||
"register.html",
|
|
||||||
include_str!("../../templates/register.html"),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
env.add_template(
|
env.add_template(
|
||||||
"worldstatus.html",
|
"worldstatus.html",
|
||||||
include_str!("../../templates/worldstatus.html"),
|
include_str!("../../templates/worldstatus.html"),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
env.add_template("account.html", include_str!("../../templates/account.html"))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
env
|
env
|
||||||
}
|
}
|
||||||
|
@ -31,21 +26,15 @@ struct GateStatus {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn root() -> Html<String> {
|
async fn root() -> Html<String> {
|
||||||
|
let config = get_config();
|
||||||
|
|
||||||
let environment = setup_default_environment();
|
let environment = setup_default_environment();
|
||||||
let template = environment.get_template("web.html").unwrap();
|
let template = environment.get_template("web.html").unwrap();
|
||||||
Html(template.render(context! {}).unwrap())
|
Html(
|
||||||
}
|
template
|
||||||
|
.render(context! { login_server => config.login.server_name })
|
||||||
async fn login() -> Html<String> {
|
.unwrap(),
|
||||||
let environment = setup_default_environment();
|
)
|
||||||
let template = environment.get_template("login.html").unwrap();
|
|
||||||
Html(template.render(context! {}).unwrap())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn register() -> Html<String> {
|
|
||||||
let environment = setup_default_environment();
|
|
||||||
let template = environment.get_template("register.html").unwrap();
|
|
||||||
Html(template.render(context! {}).unwrap())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn world_status() -> Html<String> {
|
async fn world_status() -> Html<String> {
|
||||||
|
@ -66,8 +55,6 @@ async fn main() {
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/", get(root))
|
.route("/", get(root))
|
||||||
.route("/login", get(login))
|
|
||||||
.route("/register", get(register))
|
|
||||||
.route("/worldstatus", get(world_status));
|
.route("/worldstatus", get(world_status));
|
||||||
|
|
||||||
let config = get_config();
|
let config = get_config();
|
||||||
|
|
|
@ -92,6 +92,8 @@ impl LobbyConfig {
|
||||||
pub struct LoginConfig {
|
pub struct LoginConfig {
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
pub listen_address: String,
|
pub listen_address: String,
|
||||||
|
/// Public-facing domain of the server.
|
||||||
|
pub server_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for LoginConfig {
|
impl Default for LoginConfig {
|
||||||
|
@ -99,6 +101,7 @@ impl Default for LoginConfig {
|
||||||
Self {
|
Self {
|
||||||
port: 6700,
|
port: 6700,
|
||||||
listen_address: "127.0.0.1".to_string(),
|
listen_address: "127.0.0.1".to_string(),
|
||||||
|
server_name: "ffxiv-login.square.localhost".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -157,6 +160,8 @@ impl PatchConfig {
|
||||||
pub struct WebConfig {
|
pub struct WebConfig {
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
pub listen_address: String,
|
pub listen_address: String,
|
||||||
|
/// Public-facing domain of the server.
|
||||||
|
pub server_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for WebConfig {
|
impl Default for WebConfig {
|
||||||
|
@ -164,6 +169,7 @@ impl Default for WebConfig {
|
||||||
Self {
|
Self {
|
||||||
port: 5801,
|
port: 5801,
|
||||||
listen_address: "127.0.0.1".to_string(),
|
listen_address: "127.0.0.1".to_string(),
|
||||||
|
server_name: "ffxiv.localhost".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,7 @@ pub struct LoginDatabase {
|
||||||
connection: Mutex<Connection>,
|
connection: Mutex<Connection>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
pub enum LoginError {
|
pub enum LoginError {
|
||||||
WrongUsername,
|
WrongUsername,
|
||||||
WrongPassword,
|
WrongPassword,
|
||||||
|
@ -190,4 +191,24 @@ impl LoginDatabase {
|
||||||
|
|
||||||
selected_row.is_ok()
|
selected_row.is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_user_id(&self, sid: &str) -> u32 {
|
||||||
|
let connection = self.connection.lock().unwrap();
|
||||||
|
|
||||||
|
let mut stmt = connection
|
||||||
|
.prepare("SELECT user_id FROM sessions WHERE sid = ?1")
|
||||||
|
.ok()
|
||||||
|
.unwrap();
|
||||||
|
stmt.query_row((sid,), |row| Ok(row.get(0)?)).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_username(&self, user_id: u32) -> String {
|
||||||
|
let connection = self.connection.lock().unwrap();
|
||||||
|
|
||||||
|
let mut stmt = connection
|
||||||
|
.prepare("SELECT username FROM users WHERE id = ?1")
|
||||||
|
.ok()
|
||||||
|
.unwrap();
|
||||||
|
stmt.query_row((user_id,), |row| Ok(row.get(0)?)).unwrap()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
2
templates/account.html
Normal file
2
templates/account.html
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
<p>Managing account {{ username }}</p>
|
||||||
|
<a href="/account/app/svc/logout">Logout</a>
|
|
@ -6,7 +6,7 @@
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<form action='http://ffxiv-login.square.localhost/register' method='post'>
|
<form action='/oauth/oa/registlist' method='post'>
|
||||||
<label for="username">Username:</label><br>
|
<label for="username">Username:</label><br>
|
||||||
<input type='text' id='username' name='username'/><br>
|
<input type='text' id='username' name='username'/><br>
|
||||||
<label for="password">Password:</label><br>
|
<label for="password">Password:</label><br>
|
||||||
|
|
|
@ -8,8 +8,8 @@
|
||||||
|
|
||||||
<p>Welcome to Kawari!</p>
|
<p>Welcome to Kawari!</p>
|
||||||
|
|
||||||
<a href="/login">Login</a>
|
<a href="http://{{ login_server }}/oauth/oa/oauthlogin">Login</a>
|
||||||
<a href="/register">Signup</a>
|
<a href="http://{{ login_server }}/oauth/oa/registligt">Signup</a>
|
||||||
<a href="/worldstatus">World Status</a>
|
<a href="/worldstatus">World Status</a>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|
Loading…
Add table
Reference in a new issue