From 340b8db1b911a9407fc676cb4a3bf333a9880303 Mon Sep 17 00:00:00 2001 From: Bommels05 <69975756+Bommels05@users.noreply.github.com> Date: Tue, 4 Feb 2025 20:24:30 +0100 Subject: [PATCH] Initial Commit --- .gitignore | 4 + Cargo.toml | 22 ++ bankcli/Cargo.toml | 22 ++ bankcli/build.rs | 7 + bankcli/src/main.rs | 603 ++++++++++++++++++++++++++++++++++++++ banklib/Cargo.toml | 24 ++ banklib/src/config.rs | 74 +++++ banklib/src/extended.rs | 291 +++++++++++++++++++ banklib/src/lib.rs | 625 ++++++++++++++++++++++++++++++++++++++++ src/main.rs | 0 10 files changed, 1672 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 bankcli/Cargo.toml create mode 100644 bankcli/build.rs create mode 100644 bankcli/src/main.rs create mode 100644 banklib/Cargo.toml create mode 100644 banklib/src/config.rs create mode 100644 banklib/src/extended.rs create mode 100644 banklib/src/lib.rs create mode 100644 src/main.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2f0a032 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target +.idea +*.lock +config.json \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..4b59302 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,22 @@ +[workspace] +members = [ "bankcli","banklib"] + +[workspace.package] +version = "0.0.1" +edition = "2021" + +[workspace.dependencies] +async-trait = "0.1.85" +clap = { version = "4.5.27", features = ["derive"] } +ezsockets = { version = "0.6.4", features = ["client"] } +serde = { version = "1.0.217", features = ["derive"] } +serde_json = "1.0.137" +tokio = { version = "1.43.0", features = ["full"] } +tokio-util = "0.7.13" +tracing = "0.1.41" +tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } +rand = "0.9.0" +url = "2.5.4" +flume = "0.11.1" +error-stack = "0.5.0" +thiserror = "2.0.11" \ No newline at end of file diff --git a/bankcli/Cargo.toml b/bankcli/Cargo.toml new file mode 100644 index 0000000..8146260 --- /dev/null +++ b/bankcli/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "bankcli" +version.workspace = true +edition.workspace = true + +[dependencies.banklib] +path = "../banklib" +features = [] + +[dependencies] +tokio.workspace = true +serde.workspace = true +serde_json.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +clap.workspace = true +url.workspace = true +lalrpop-util = "0.22.1" +error-stack.workspace = true + +[build-dependencies] +lalrpop = "0.22.1" \ No newline at end of file diff --git a/bankcli/build.rs b/bankcli/build.rs new file mode 100644 index 0000000..4260019 --- /dev/null +++ b/bankcli/build.rs @@ -0,0 +1,7 @@ +fn main() { + lalrpop::Configuration::new() + .emit_rerun_directives(true) + .set_in_dir("./src") + .process() + .unwrap(); +} \ No newline at end of file diff --git a/bankcli/src/main.rs b/bankcli/src/main.rs new file mode 100644 index 0000000..8010094 --- /dev/null +++ b/bankcli/src/main.rs @@ -0,0 +1,603 @@ +use std::future::pending; +use banklib::{AuthenticatedMessage, BankClient, BankError, BankResult, Response}; +use clap::Parser; +use std::ops::{Add, Deref}; +use std::time::Duration; +use std::io; +use std::io::BufRead; +use std::thread::yield_now; +use error_stack::{report, Report}; +use tokio::io::BufReader; +use tokio::select; +use tokio::task::JoinHandle; +use tracing::metadata::LevelFilter; +use tracing::{error, info, warn}; +use tracing_subscriber::EnvFilter; +use url::Url; +use banklib::config::{load_config, save_config}; +use banklib::extended::{Client, Credentials, State}; + +#[derive(Debug, Parser)] +struct Args { + #[arg(short, long)] + url: Option, + #[arg(short, long)] + debug: bool, +} + +#[tokio::main] +async fn main() { + println!("Befator Inc™️ grüßt Sie!"); + println!( + r#" + + /$$$$$$$ /$$$$$$ /$$ /$$$$$$ +| $$__ $$ /$$__ $$ | $$ |_ $$_/ +| $$ \ $$ /$$$$$$ | $$ \__//$$$$$$ /$$$$$$ /$$$$$$ /$$$$$$ | $$ /$$$$$$$ /$$$$$$$ +| $$$$$$$ /$$__ $$| $$$$ |____ $$|_ $$_/ /$$__ $$ /$$__ $$ | $$ | $$__ $$ /$$_____/ +| $$__ $$| $$$$$$$$| $$_/ /$$$$$$$ | $$ | $$ \ $$| $$ \__/ | $$ | $$ \ $$| $$ +| $$ \ $$| $$_____/| $$ /$$__ $$ | $$ /$$| $$ | $$| $$ | $$ | $$ | $$| $$ +| $$$$$$$/| $$$$$$$| $$ | $$$$$$$ | $$$$/| $$$$$$/| $$ /$$$$$$| $$ | $$| $$$$$$$ +|_______/ \_______/|__/ \_______/ \___/ \______/ |__/ |______/|__/ |__/ \_______/ + + "# + ); + let args = Args::parse(); + + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::builder() + .with_default_directive(if args.debug { + LevelFilter::DEBUG.into() + } else { + LevelFilter::INFO.into() + }) + .from_env_lossy(), + ) + .init(); + + let config = load_config(); + let url: Url; + if let Some(arg) = args.url { + url = arg; + } else { + if !config.server.last_server.is_empty() && config.general.use_last_server { + url = Url::parse(&config.server.last_server).expect("Invalid last server url in config"); + } else { + error!("Cannot use last server url - You need to provide one using --url"); + return; + } + } + let client = BankClient::connect(url.clone()).await; + + let mut client = Client { client, config, state: State::default() }; + client.config.server.last_server = url.to_string(); + save_config(&client.config); + + if client.config.general.use_default_account { + let password = client.config.accounts.accounts.get(&client.config.accounts.default_account); + if let Some(password) = password { + let username = client.config.accounts.default_account.clone(); + let password = password.clone(); + try_login(&mut client, Credentials { password, username }).await; + } + } else if client.config.general.use_last_account { + let password = client.config.accounts.accounts.get(&client.config.accounts.last_account); + if let Some(password) = password { + let username = client.config.accounts.last_account.clone(); + let password = password.clone(); + try_login(&mut client, Credentials { password, username }).await; + } + } + + loop { + let line = read_line(Some(&mut client)).await; + let mut split = line.split(' '); + let command: &str = split.next().unwrap(); + + match command { + "account" => { + let argument = split.next().or(Some("")).unwrap(); + if argument == "add" { + let credentials = expect_credentials(split, &mut client, false).await; + if let Some(credentials) = credentials { + client.config.accounts.accounts.insert(credentials.username.clone(), credentials.password.clone()); + if client.config.accounts.default_account.is_empty() { + client.config.accounts.default_account = credentials.username.clone(); + } + save_config(&client.config); + if ask_confirmation("Do you want to test the account now?").await { + let old_credentials = client.state.last_credentials.clone(); + let success = try_login(&mut client, credentials.clone()).await; + if success { + setup_new_account(&mut client, credentials.clone(), old_credentials).await; + } else { + if ask_confirmation("The login was not successful - Do you want to register this account instead?").await { + let result = client.register(credentials.clone()).await; + if result.is_ok() { + let result = client.login(credentials.clone()).await; + if result.is_ok() { + setup_new_account(&mut client, credentials, old_credentials).await; + } else { + handle_error(&result); + warn!("The login was not successful but the account was most likely still registered - Please try to login again") + } + } else { + handle_error(&result); + } + } + } + } + } else { + println!("You need to enter a username and password"); + } + } else if argument == "use" { + let username: &str = split.next().or(Some("")).unwrap(); + if client.config.accounts.accounts.contains_key(username) { + let password = client.config.accounts.accounts.get(username).unwrap().clone(); + try_login(&mut client, Credentials { username: username.into(), password }).await; + } else { + println!("You need to enter a valid username") + } + } else if argument == "list" { + println!("--- Accounts ---"); + let i: u8 = 0; + for (name, _) in &client.config.accounts.accounts { + if client.config.accounts.default_account.eq(name) { + println!("{}. {} (Default)", i + 1, name); + } else { + println!("{}. {}", i + 1, name); + } + } + println!("--- Accounts ---"); + } else if argument == "remove" { + let username: &str = split.next().or(Some("")).unwrap(); + if client.config.accounts.accounts.contains_key(username) { + client.config.accounts.accounts.remove(username); + println!("Account removed"); + } else { + println!("You need to enter a valid username") + } + } else if argument == "help" { + println!("--- Available Commands ---"); + println!("account add - Add a new account"); + println!("account use [username] - Login with the default or the specified account"); + println!("account remove - Remove the account from the list"); + println!("account default - Make the account the default account"); + println!("account list - List available accounts"); + } else { + println!("Invalid subcommand! Use account help for a list of valid subcommands"); + } + } + "settings" => { + let argument = split.next().or(Some("")).unwrap(); + if argument == "set" { + let setting = split.next(); + let value = split.next(); + if setting.is_some() && value.is_some() { + { + match setting.unwrap() { + "show_motd_after_login" => { + if let Ok(value) = try_parse_bool(value) { + client.config.general.show_motd_after_login = value; + } else { + break; + } + } + "show_inbox_count_after_login" => { + if let Ok(value) = try_parse_bool(value) { + client.config.general.show_inbox_count_after_login = value; + } else { + break; + } + } + "show_pm_inbox_count_after_login" => { + if let Ok(value) = try_parse_bool(value) { + client.config.general.show_pm_inbox_count_after_login = value; + } else { + break; + } + } + "show_balance_after_payment" => { + if let Ok(value) = try_parse_bool(value) { + client.config.general.show_balance_after_payment = value; + } else { + break; + } + } + "use_default_account" => { + if let Ok(value) = try_parse_bool(value) { + client.config.general.use_default_account = value; + if value { + client.config.general.use_last_account = false; + } + } else { + break; + } + } + "use_last_account" => { + if let Ok(value) = try_parse_bool(value) { + client.config.general.use_last_account = value; + if value { + client.config.general.use_default_account = false; + } + } else { + break; + } + } + "use_last_server" => { + if let Ok(value) = try_parse_bool(value) { + client.config.general.use_last_server = value; + } else { + break; + } + } + _ => { + error!("Invalid setting") + } + } + info!("Setting changed"); + save_config(&client.config); + } + } else { + println!("You need to enter a setting and value"); + } + } else if argument == "list" { + println!("--- Settings ---"); + println!( + "show_motd_after_login: {}", + client.config.general.show_motd_after_login + ); + println!( + "show_inbox_count_after_login: {}", + client.config.general.show_inbox_count_after_login + ); + println!( + "show_pm_inbox_count_after_login: {}", + client.config.general.show_pm_inbox_count_after_login + ); + println!( + "show_balance_after_payment: {}", + client.config.general.show_balance_after_payment + ); + println!( + "use_default_account: {}", + client.config.general.use_default_account + ); + println!( + "use_last_account: {}", + client.config.general.use_last_account + ); + println!( + "use_last_server: {}", + client.config.general.use_last_server + ); + } else if argument == "help" { + println!("--- Available Commands ---"); + println!( + "settings set - Set a setting to the specified value" + ); + println!("settings list - List all settings"); + } else { + println!("Invalid subcommand! Use settings help for a list of valid subcommands"); + } + } + "register" => { + let credentials = expect_credentials(split, &mut client, true).await; + if let Some(credentials) = credentials { + let result = client.register(credentials.clone()).await; + if let Some(last_credentials) = &client.state.last_credentials { + handle_error(&client.client.set_credentials(last_credentials.username.clone(), last_credentials.password.clone())); + } + if let Err(error) = result { + match error.current_context() { + BankError::AuthenticationError => { + error!("Username is already taken"); + if ask_confirmation("Do you want to login instead?").await { + try_login(&mut client, credentials).await; + } + } + _ => print_error(&error) + } + } else { + if ask_confirmation("Do you want to login with your new account?").await { + try_login(&mut client, credentials).await; + } + } + } + } + "login" => { + let credentials = expect_credentials(split, &mut client, true).await; + if let Some(credentials) = credentials { + try_login(&mut client, credentials).await; + } + } + "balance" => { + let balance = client.get_balance().await; + if let Ok(balance) = balance { + info!("You currently have a balance of {}₿", balance); + } else { + handle_error(&balance); + } + } + "pay" => { + let destination = split.next(); + let amount = split.next(); + if destination.is_none() || amount.is_none() || amount.unwrap().parse::().is_err() || amount.unwrap().parse::().unwrap() <= 0 { + error!("You need to enter a destination username and amount greater than 0"); + } else { + let result = client.pay(destination.unwrap().into(), amount.unwrap().parse::().unwrap()).await; + handle_error(&result); + } + } + "inbox" => { + let size = client.get_inbox_size().await; + if let Ok(size) = size { + info!("--- {} Message{} ---", size, if size == 1 { "" } else { "s" }); + for i in 0..size { + let msg = client.get_inbox_msg((i + 1) as i8).await; + if let Ok((id, msg)) = msg { + info!("{id}. {msg}"); + } else { + handle_error(&msg); + } + } + info!("--- {} Message{} ---", size, if size == 1 { "" } else { "s" }); + } else { + handle_error(&size); + } + } + "pm" => { + let username = split.next(); + let message = split.next(); + if username.is_some() && message.is_some() { + let mut message: String = message.unwrap().into(); + let mut segment = split.next(); + while segment.is_some() { + message = message.add(" "); + message = message.add(segment.unwrap().into()); + segment = split.next(); + } + + let result = client.send_pm(username.unwrap().into(), message).await; + handle_error(&result); + } else if username.is_none() && username.is_none() { + let size = client.get_pm_size().await; + if let Ok(size) = size { + info!("--- {} Private Message{} ---", size, if size == 1 { "" } else { "s" }); + for i in 0..size { + let msg = client.get_pm_msg((i + 1) as i8).await; + if let Ok((id, msg)) = msg { + info!("{}. {}", id, msg); + } else { + handle_error(&msg); + } + } + info!("--- {} Private Message{} ---", size, if size == 1 { "" } else { "s" }); + } else { + handle_error(&size); + } + } else { + error!("You need to enter a username and message or nothing to view your PMs") + } + } + "delete" => { + let argument = split.next().or(Some("")).unwrap(); + let id = split.next().or(Some("")).unwrap().parse(); + if argument == "inbox" && id.is_ok() { + let result = client.delete_inbox_msg(id.unwrap()).await; + handle_error(&result); + } else if argument == "pm" && id.is_ok() { + let result = client.delete_pm_msg(id.unwrap()).await; + handle_error(&result); + } else { + println!("You need to specify inbox or pm and a message id"); + } + } + "motd" => { + let motd = client.get_motd().await; + if let Ok(motd) = motd { + println!("--- {} ---", motd) + } else { + handle_error(&motd); + } + } + "logout" => { + if !client.state.logged_in { + warn!("You are already logged out") + } else { + let result = client.logout().await; + handle_error(&result); + } + } + "exit" => { + if client.state.logged_in { + let result = client.logout().await; + handle_error(&result); + } + client.client.close("\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nBefator verabschiedet sich!\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n".into()); + save_config(&client.config); + break; + } + "spam" => { + for _ in 0..100 { + for i in 0..5 { + let action: AuthenticatedMessage = match i { + 0 => AuthenticatedMessage::Authenticate, + 1 => AuthenticatedMessage::Motd, + 2 => AuthenticatedMessage::GetBalance, + 3 => AuthenticatedMessage::Register, + 4 => AuthenticatedMessage::Pay { + destination: "thc".into(), + amount: 0, + }, + _ => AuthenticatedMessage::Logout, + }; + handle_error(&client.client.send_authenticated(action, |_| true).await); + } + } + } + "help" => { + let argument = split.next().or(Some("")).unwrap(); + if argument.eq_ignore_ascii_case("extended") { + println!("--- Available extended Commands ---"); + println!("register - Register a new account and log into that account"); + println!("login [username] [password] - Log into the specified account or the account you were last logged into"); + println!("spam - Sends various requests to the Server"); + } else { + println!("--- Available Commands ---"); + println!("account - Use account help for a description of all subcommands"); + println!("balance - View your current balance"); + println!("pay - Pay someone Steam Coins"); + println!("inbox - View your inbox"); + println!("pm [destination] [message]- View your private messages or send one"); + println!("delete - Delete a message from your inbox or PMs"); + println!("motd - View the current server motd"); + println!("logout - Logout of your current account"); + println!("exit - Logout from the server and exit"); + println!("settings - Manage settings"); + println!("help [extended] - See a (extended) list of commands"); + } + } + _ => { + println!("Unknown command - Use help for a list of commands") + } + } + } + + tokio::time::sleep(Duration::from_secs(3)).await; +} + +async fn try_login(client: &mut Client, credentials: Credentials) -> bool { + let result = client.login(credentials).await; + if let Err(error) = &result { + match error.current_context() { + BankError::AuthenticationError => { + if let Some(last_credentials) = &client.state.last_credentials { + let result = client.login(last_credentials.clone()).await; + if result.is_err() { + info!("Error login in with old credentials - You may need to enter a new username and password"); + } + } + } + _ => handle_error(&result) + } + } else { + return true; + } + false +} + +async fn setup_new_account(client: &mut Client, credentials: Credentials, old_credentials: Option) { + if !client.config.accounts.default_account.eq(&credentials.username) { + if ask_confirmation("Do you want to make this account your default account?").await { + client.config.accounts.default_account = credentials.username.clone(); + } + } + if !ask_confirmation("Do you want to use this account now?").await { + if let Some(old_credentials) = old_credentials { + if !old_credentials.eq(&credentials) { + let result = client.login(old_credentials).await; + if result.is_err() { + info!("Error logging in with old credentials - You may need to enter a new username and password"); + } + } else { + handle_error(&client.logout().await); + } + } else { + handle_error(&client.logout().await); + } + } +} + + +async fn read_line(client: Option<&Client>) -> String { + let mut thread: Option> = None; + if let Some(client) = client { + thread = Some(tokio::spawn(async move { + /*loop { + if let Ok(message) = client.client.receiver().recv() { + match message.message { + Response::DisplayMessage { message, id, .. } => { + match id.as_str() { + "pay.received" => { + println!("{}", message); + if client.config.general.show_balance_after_payment { + let balance = client.get_balance().await; + if let Ok(balance) = balance { + info!("You now have a balance of {}₿", balance); + } else { + handle_error(&balance); + } + } + } + "pm_inbox.received" => { + println!("{}", message); + } + _ => { print_error(&report!(BankError::UnexpectedMessage)); } + } + } + _ => { print_error(&report!(BankError::UnexpectedMessage)); } + } + } + }*/ + })); + } + let mut line = String::new(); + io::stdin().read_line(&mut line).unwrap(); + let result = line.replace("\n", "").replace("\r", ""); + if let Some(thread) = thread { + thread.abort(); + } + result +} + +async fn ask_confirmation(prompt: &str) -> bool { + println!("{} (y/n): ", prompt); + let answer = read_line(None).await; + answer.as_str() == "y" +} + +fn try_parse_bool(value: Option<&str>) -> Result { + let parsed = value.unwrap().parse(); + if !parsed.is_ok() { + println!("The value needs to be true or false"); + } + parsed.map_err(|_| ()) +} + +async fn expect_credentials( + mut split: std::str::Split<'_, char>, + client: &mut Client, + try_last_login: bool, +) -> Option { + let username = split.next(); + let password = split.next(); + if username.is_none() || password.is_none() { + if try_last_login { + if let Some(last_credentials) = &client.state.last_credentials { + let result = client.login(last_credentials.clone()).await; + if result.is_err() { + info!("Error login in with old credentials - You may need to enter a new username and password"); + } + } else { + info!("You need to enter a username and password"); + } + } + None + } else { + let username: String = username.unwrap().into(); + let password: String = password.unwrap().into(); + Some(Credentials { username, password }) + } +} + +fn handle_error(result: &BankResult) { + if let Err(error) = result { + print_error(error) + } +} + +fn print_error(error: &Report) { + error!("\n{:?}", error); +} diff --git a/banklib/Cargo.toml b/banklib/Cargo.toml new file mode 100644 index 0000000..747bb7a --- /dev/null +++ b/banklib/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "banklib" +version.workspace = true +edition.workspace = true + +[features] +default = ["extended", "config"] +extended = [] +config = [] + +[dependencies] +async-trait.workspace = true +clap.workspace = true +ezsockets.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +tokio-util.workspace = true +tracing.workspace = true +rand.workspace = true +url.workspace = true +flume.workspace =true +error-stack.workspace = true +thiserror.workspace = true diff --git a/banklib/src/config.rs b/banklib/src/config.rs new file mode 100644 index 0000000..e331324 --- /dev/null +++ b/banklib/src/config.rs @@ -0,0 +1,74 @@ +#![cfg(feature = "config")] + +use std::collections::HashMap; +use std::fs; +use serde::{Deserialize, Serialize}; +use tracing::error; + +#[derive(Serialize, Deserialize)] +pub struct Config { + pub accounts: AccountConfig, + pub server: ServerConfig, + pub general: GeneralConfig, +} + +#[derive(Serialize, Deserialize)] +pub struct AccountConfig { + pub default_account: String, + pub last_account: String, + pub accounts: HashMap, +} + +#[derive(Serialize, Deserialize)] +pub struct ServerConfig { + pub last_server: String, +} + +#[derive(Serialize, Deserialize)] +pub struct GeneralConfig { + pub use_last_server: bool, + pub use_last_account: bool, + pub use_default_account: bool, + pub show_motd_after_login: bool, + pub show_inbox_count_after_login: bool, + pub show_pm_inbox_count_after_login: bool, + pub show_balance_after_payment: bool, +} + +pub fn load_config() -> Config { + if fs::exists("config.json").unwrap_or_else(|_| false) { + let content = fs::read_to_string("config.json"); + if content.is_ok() { + let json: serde_json::Result = serde_json::from_str(content.unwrap().as_str()); + if json.is_ok() { + return json.unwrap(); + } + } else { + error!("Config invalid") + } + } + Config { + accounts: AccountConfig { + default_account: "".to_string(), + last_account: "".to_string(), + accounts: HashMap::new(), + }, + server: ServerConfig { + last_server: "".to_string(), + }, + general: GeneralConfig { + use_last_server: true, + use_last_account: true, + use_default_account: false, + show_motd_after_login: true, + show_inbox_count_after_login: true, + show_pm_inbox_count_after_login: true, + show_balance_after_payment: true, + }, + } +} + +pub fn save_config(config: &Config) { + let string = serde_json::to_string_pretty(config).expect("Error serializing config"); + fs::write("config.json", string).expect("Error saving config"); +} diff --git a/banklib/src/extended.rs b/banklib/src/extended.rs new file mode 100644 index 0000000..f40d5dc --- /dev/null +++ b/banklib/src/extended.rs @@ -0,0 +1,291 @@ +#![cfg(feature = "extended")] + +use error_stack::report; +use tracing::info; +use crate::{AuthenticatedMessage, BankClient, BankError, BankResult, Response, ResponseMessage}; +use crate::config::{save_config, Config}; + +#[derive(Clone, Eq, PartialEq)] +pub struct Credentials { + pub username: String, + pub password: String, +} + +#[derive(Default)] +pub struct State { + pub last_credentials: Option, + pub logged_in: bool, +} + +pub struct Client { + pub client: BankClient, + pub config: Config, + pub state: State, +} + +macro_rules! check_message_id { + ($pattern:pat) => {{ + fn check_message_id(message: &ResponseMessage) -> bool { + match &message.message { + Response::DisplayMessage { + message: _, + id, + .. + } => { + matches!(id.as_str(), $pattern) + } + _ => false, + } + } + check_message_id + }}; +} + +fn display_message(message: Response) -> (String, String, u32) { + match message { + Response::DisplayMessage { message, id, value, .. } => return (message, id, value), + _ => panic!("Unexpected message type"), + } +} + +impl Client { + pub async fn logout(&mut self) -> BankResult<(), BankError> { + let (message, id, _) = display_message( + self.client + .send_authenticated( + AuthenticatedMessage::Logout, + check_message_id!( + "logout.success" | "logout.fail.notloggedin" | "logout.fail.credentials" + ), + ) + .await?, + ); + info!("{}", message); + match id.as_str() { + "logout.success" | "logout.fail.notloggedin" => { + self.state.logged_in = false; + } + "logout.fail.credentials" => return Err(report!(BankError::AuthenticationError)), + _ => panic!("Unexpected message"), + } + Ok(()) + } + + pub async fn login(&mut self, credentials: Credentials) -> BankResult<(), BankError> { + if self.state.logged_in { + self.logout().await?; + } + self.client.set_credentials(credentials.username.clone(), credentials.password.clone())?; + let (message, id, value) = display_message( + self.client + .send_authenticated( + AuthenticatedMessage::Authenticate, + check_message_id!("auth.success" | "auth.fail.credentials"), + ) + .await?, + ); + info!("{}", message); + match id.as_str() { + "auth.success" => { + self.state.last_credentials = Some(credentials.clone()); + self.config.accounts.last_account = credentials.username.clone(); + save_config(&self.config); + self.state.logged_in = true; + info!("(Client Id: {})", value); + + if self.config.general.show_motd_after_login { + let motd = self.get_motd().await; + if let Ok(motd) = motd { + info!("--- {} ---", motd); + } + } + if self.config.general.show_inbox_count_after_login { + let size = self.get_inbox_size().await; + if let Ok(size) = size { + info!( + "You have {} unread message{}", + size, + if size == 1 { "" } else { "s" } + ); + } + } + if self.config.general.show_pm_inbox_count_after_login { + let size = self.get_pm_size().await; + if let Ok(size) = size { + info!( + "You have {} unread private message{}", + size, + if size == 1 { "" } else { "s" } + ); + } + } + } + "auth.fail.credentials" => return Err(report!(BankError::AuthenticationError)), + _ => panic!("Unexpected message"), + } + Ok(()) + } + + pub async fn register(&mut self, credentials: Credentials) -> BankResult<(), BankError> { + self.client.set_credentials(credentials.username.clone(), credentials.password.clone())?; + let (_, id, _) = display_message(self.client.send_authenticated( + AuthenticatedMessage::Register, check_message_id!("register.success" | "register.fail.usernameTaken")).await?); + match id.as_str() { + "register.success" => Ok(()), + "register.fail.usernameTaken" => Err(report!(BankError::AuthenticationError)), + _ => panic!("Unexpected message"), + } + } + + pub async fn get_motd(&mut self) -> BankResult { + let message = self + .client + .send_authenticated(AuthenticatedMessage::Motd, |response| { + match &response.message { + Response::Motd { .. } => true, + _ => false, + } + }) + .await?; + match message { + Response::Motd { motd } => Ok(motd), + _ => panic!("Unexpected message type"), + } + } + + pub async fn get_inbox_size(&mut self) -> BankResult { + let (_, id, value) = display_message( + self.client + .send_authenticated( + AuthenticatedMessage::GetInbox { message_id: -1 }, + check_message_id!("message_count" | "inbox.fail.credentials"), + ) + .await?, + ); + match id.as_str() { + "message_count" => Ok(value as u8), + "inbox.fail.credentials" => Err(report!(BankError::AuthenticationError)), + _ => panic!("Unexpected message"), + } + } + + pub async fn get_inbox_msg(&mut self, id: i8) -> BankResult<(u8, String), BankError> { + let message = self.client.send_authenticated(AuthenticatedMessage::GetInbox { message_id: id }, |response| { + match &response.message { + Response::Inbox { .. } => true, + Response::DisplayMessage { message: _, id, .. } => { + id.as_str().eq("inbox.fail.credentials") + } + _ => false, + } + }).await?; + match message { + Response::Inbox { message_id, message } => Ok((message_id, message)), + Response::DisplayMessage { message: _, id, .. } => { + match id.as_str() { + "inbox.fail.credentials" => Err(report!(BankError::AuthenticationError)), + _ => panic!("Unexpected message"), + } + } + _ => panic!("Unexpected message type"), + } + } + + pub async fn delete_inbox_msg(&mut self, message_id: u8) -> BankResult<(), BankError> { + let (message, id, _) = display_message(self.client.send_authenticated( + AuthenticatedMessage::ReadInbox { message_id: message_id as i8 }, check_message_id!("read_inbox.success" | "read_inbox.fail.credentials")).await?); + info!("{}", message); + match id.as_str() { + "read_inbox.success" => Ok(()), + "read_inbox.fail.credentials" => Err(report!(BankError::AuthenticationError)), + _ => panic!("Unexpected message"), + } + } + + pub async fn get_pm_size(&mut self) -> BankResult { + let (_, id, value) = display_message( + self.client + .send_authenticated( + AuthenticatedMessage::GetPmInbox { message_id: -1 }, + check_message_id!("pm_message_count" | "pm.fail.credentials"), + ) + .await?); + match id.as_str() { + "pm_message_count" => Ok(value as u8), + "pm.fail.credentials" => Err(report!(BankError::AuthenticationError)), + _ => panic!("Unexpected message"), + } + } + + pub async fn get_pm_msg(&mut self, id: i8) -> BankResult<(u8, String), BankError> { + let message = self.client.send_authenticated(AuthenticatedMessage::GetPmInbox { message_id: id }, |response| { + match &response.message { + Response::PmInbox { .. } => true, + Response::DisplayMessage { message: _, id, .. } => { + id.as_str().eq("pm.fail.credentials") + } + _ => false, + } + }).await?; + match message { + Response::PmInbox { message_id, message } => Ok((message_id, message)), + Response::DisplayMessage { message: _, id, .. } => { + match id.as_str() { + "pm.fail.credentials" => Err(report!(BankError::AuthenticationError)), + _ => panic!("Unexpected message"), + } + } + _ => panic!("Unexpected message type"), + } + } + + pub async fn delete_pm_msg(&mut self, message_id: u8) -> BankResult<(), BankError> { + let (message, id, _) = display_message(self.client.send_authenticated( + AuthenticatedMessage::ReadPmInbox { message_id: message_id as i8 }, check_message_id!("read_pm.success" | "read_pm.fail.credentials")).await?); + info!("{}", message); + match id.as_str() { + "read_pm.success" => Ok(()), + "read_pm.fail.credentials" => Err(report!(BankError::AuthenticationError)), + _ => panic!("Unexpected message"), + } + } + + pub async fn get_balance(&mut self) -> BankResult { + let (_, id, value) = display_message(self.client.send_authenticated( + AuthenticatedMessage::GetBalance, check_message_id!("balance.success" | "balance.fail.credentials")).await?); + match id.as_str() { + "balance.success" => Ok(value), + "balance.fail.credentials" => Err(report!(BankError::AuthenticationError)), + _ => panic!("Unexpected message"), + } + } + + pub async fn pay(&mut self, destination: String, amount: u32) -> BankResult<(), BankError> { + let (message, id, _) = display_message(self.client.send_authenticated( + AuthenticatedMessage::Pay { destination, amount }, check_message_id!("pay.success" | "pay.fail.unknown_error" | + "pay.fail.not_enough_money" | "pay.fail.credentials" | "pay.fail.unknown_dest" | "pay.fail.orig_user_unknown" | "pay.fail.negative_amount")).await?); + info!("{}", message); + match id.as_str() { + "pay.success" => Ok(()), + "pay.fail.not_enough_money" => Err(report!(BankError::NotEnoughMoney)), + "pay.fail.unknown_dest" => Err(report!(BankError::DestinationUnknown)), + "pay.fail.credentials" => Err(report!(BankError::AuthenticationError)), + "pay.fail.orig_user_unknown" => Err(report!(BankError::AuthenticationError)), + "pay.fail.negative_amount" => Err(report!(BankError::InternalError)), + "pay.fail.unknown_error" => Err(report!(BankError::InternalError)), + _ => panic!("Unexpected message"), + } + } + + pub async fn send_pm(&mut self, destination: String, message: String) -> BankResult<(), BankError> { + let (message, id, _) = display_message(self.client.send_authenticated( + AuthenticatedMessage::SendPm { destination, message }, check_message_id!("pm_inbox.send.success" | "pm_inbox.dest.unknown" | "pm_inbox.fail.credentials")).await?); + info!("{}", message); + match id.as_str() { + "pm_inbox.send.success" => Ok(()), + "pm_inbox.dest.unknown" => Err(report!(BankError::DestinationUnknown)), + "pm_inbox.fail.credentials" => Err(report!(BankError::AuthenticationError)), + _ => panic!("Unexpected message"), + } + } +} \ No newline at end of file diff --git a/banklib/src/lib.rs b/banklib/src/lib.rs new file mode 100644 index 0000000..f625f84 --- /dev/null +++ b/banklib/src/lib.rs @@ -0,0 +1,625 @@ +use async_trait::async_trait; +use ezsockets::{ + client::ClientCloseMode, Client, ClientConfig, CloseCode, CloseFrame, Error, WSError, +}; +use rand::random; +use serde::{Deserialize, Serialize}; +use std::collections::VecDeque; +use std::future::IntoFuture; +use std::time::Duration; +use error_stack::report; +use flume::{Receiver, Sender}; +use tokio::sync::oneshot; +use tokio::task::JoinHandle; +use tracing::{debug, info, warn}; +use thiserror::Error; +use url::Url; + +#[cfg(feature = "extended")] +pub mod extended; +#[cfg(feature = "config")] +pub mod config; + + +pub struct BankClient { + client: Client, + rx: Receiver, + handle: JoinHandle>> +} + +#[derive(Error, Debug)] +pub enum BankError { + #[error("Request timed out")] + TimedOut, + #[error("Disconnected from Server")] + Disconnected, + #[error("Unexpected internal error")] + InternalError, + #[error("Authentication error while logged in - Did the password change?")] + AuthenticationError, + #[error("Destination user does not exist")] + DestinationUnknown, + #[error("You don't have enough Steam Coins")] + NotEnoughMoney, + #[error("Unknown unexpected message received")] + UnexpectedMessage +} + +pub type BankResult = error_stack::Result; + +impl BankClient { + pub async fn connect(url: impl Into) -> Self { + let url = url.into(); + let client_config = ClientConfig::new(url); + let random: u8 = random(); + let (tx, rx) = flume::unbounded(); + let (client, future) = ezsockets::connect( + move |client| Handler { + client, + tx, + username: String::new(), + password: String::new(), + id: random as i32, + receivers: VecDeque::new(), + }, + client_config, + ).await; + let handle = tokio::spawn(future); + Self { + client, + rx, + handle, + } + } + pub async fn send_authenticated( + &self, + message: AuthenticatedMessage, + accept: fn(&ResponseMessage) -> bool, + ) -> BankResult { + self.check_running()?; + let (tx, rx) = oneshot::channel(); + self.client.call(ClientCommand::SendAuthenticatedNew {message, accept, tx}).map_err(|_| report!(BankError::InternalError))?; + tokio::time::timeout(Duration::from_secs(2), rx.into_future()).await.map_err(|_| report!(BankError::TimedOut))?.map_err(|_| report!(BankError::InternalError))?.map_err(|err| report!(err)) + } + + pub fn set_credentials(&self, username: String, password: String) -> BankResult<()> { + self.client.call(ClientCommand::ChangeCredentials {username, password}).map_err(|_| report!(BankError::InternalError)) + } + + pub fn receiver(&self) -> &Receiver { + &self.rx + } + + fn check_running(&self) -> BankResult<(), BankError>{ + if !self.handle.is_finished() { + Ok(()) + } else { + Err(report!(BankError::InternalError)) + } + } + + pub fn close(&self, reason: String) { + self.client.close(Some(CloseFrame { + code: CloseCode::Normal, + reason, + })).expect("Error closing websocket"); + } +} + +struct Handler { + client: Client, + //config: Arc>, + tx: Sender, + username: String, + password: String, + //last_username: String, + //last_password: String, + id: i32, + //logged_in: bool, + /*message_queue: VecDeque<( + AuthenticatedMessage, + Box + Send + Unpin>, + )>, + send_time: SystemTime, + retry_count: u8, + inbox_message_count: u8, + pm_message_count: u8,*/ + receivers: VecDeque<(fn(&ResponseMessage) -> bool, oneshot::Sender>)> +} + +impl Handler { + fn credentials(&self) -> Credentials { + Credentials { + username: &self.username, + password: &self.password, + } + } + + /*fn last_credentials(&self) -> Credentials { + Credentials { + username: &self.last_username, + password: &self.last_password, + } + }*/ + + /*fn send_authenticated(&mut self, message: AuthenticatedMessage) { + self.send_authenticated_callback(message); + }*/ + + /*fn send_authenticated_callback( + &mut self, + message: AuthenticatedMessage, + callback: Box + Send + Unpin>, + ) { + if self.message_queue.is_empty() { + self.send_authenticated_now(message, callback); + } else { + self.message_queue.push_back((message, callback)); + } + }*/ + + fn send_authenticated( + &mut self, + message: AuthenticatedMessage, + //callback: Box + Send + Unpin>, + ) { + #[derive(Serialize)] + struct WithCredentials<'a> { + #[serde(flatten)] + credentials: Credentials<'a>, + #[serde(flatten)] + message: AuthenticatedMessage, + id: i32, + } + + /*if message == AuthenticatedMessage::Authenticate && self.logged_in { + self.send_authenticated_now(AuthenticatedMessage::Logout, Box::new(Box::pin(async {}))); + self.send_authenticated(message); + return; + }*/ + + //self.send_time = SystemTime::now(); + //self.message_queue.push_front((message.clone(), callback)); + + debug!("Sending: {message:?}"); + let credentials: Credentials = self.credentials(); + self.client + .text( + serde_json::to_string(&WithCredentials { + credentials, + message, + id: self.id, + }) + .unwrap(), + ) + .expect("Could not send authenticated message"); + } + + /*fn backup_auth(&mut self) { + if !self.last_username.is_empty() && !self.last_password.is_empty() { + self.username = self.last_username.clone(); + self.password = self.last_password.clone(); + self.send_authenticated(AuthenticatedMessage::Authenticate) + } + } + + fn auth_error(&self) { + warn!("Authentication error while logged in. Did the password change?"); + }*/ +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Credentials<'a> { + #[serde(rename = "value1")] + username: &'a str, + #[serde(rename = "value2")] + password: &'a str, +} + +#[derive(Debug, Serialize, PartialEq, Clone)] +#[serde(tag = "action", rename_all = "snake_case")] +pub enum AuthenticatedMessage { + Authenticate, + Register, + Logout, + GetBalance, + Pay { + #[serde(rename = "value3")] + destination: String, + #[serde(rename = "value4")] + amount: u32, + }, + GetInbox { + #[serde(rename = "value3")] + message_id: i8, + }, + GetPmInbox { + #[serde(rename = "value3")] + message_id: i8, + }, + ReadInbox { + #[serde(rename = "value3")] + message_id: i8, + }, + ReadPmInbox { + #[serde(rename = "value3")] + message_id: i8, + }, + SendPm { + #[serde(rename = "value3")] + destination: String, + #[serde(rename = "value4")] + message: String, + }, + Motd, +} + +#[derive(Deserialize)] +#[serde(tag = "action", rename_all = "camelCase")] +pub enum Response { + DisplayMessage { + #[serde(rename = "value1")] + message: String, + #[serde(rename = "value2")] + id: String, + #[serde(rename = "value3", default = "default_u32")] + value: u32, + #[serde(rename = "value4", default = "String::new")] + value2: String, + }, + Inbox { + #[serde(rename = "value1")] + message_id: u8, + #[serde(rename = "value2")] + message: String, + }, + #[serde(rename = "pm_inbox")] + PmInbox { + #[serde(rename = "value1")] + message_id: u8, + #[serde(rename = "value2")] + message: String, + }, + Motd { + #[serde(rename = "value1")] + motd: String, + }, +} + +#[derive(Deserialize)] +pub struct ResponseMessage { + #[serde(flatten)] + pub message: Response, + #[serde(default = "default_i32")] + id: i32, +} + +fn default_i32() -> i32 { + -1 +} + +fn default_u32() -> u32 { + 0 +} + +#[async_trait] +impl ezsockets::ClientExt for Handler { + type Call = ClientCommand; + + async fn on_text(&mut self, text: String) -> Result<(), Error> { + debug!("Received: {text}"); + + + let message: ResponseMessage = + serde_json::from_str(text.as_str()).expect("Error decoding message"); + if message.id == self.id || message.id == -1 { + match &message.message { + Response::DisplayMessage { message: _, id, value, .. } => { + match id.as_str() { + "auth.success" => { + self.id = value.clone() as i32; + } + _ => {} + } + } + _ => {} + } + + if let Some((index, (_, _))) = self.receivers.iter().enumerate().find(|(_, (accept, _))| accept(&message)) { + let (_, tx) = self.receivers.remove(index).unwrap(); + if let Err(_) = tx.send(Ok(message.message)) { + warn!("Message receiver dropped"); + } + return Ok(()); + } + let _ = self.tx.send(message); + + /*if self.message_queue.is_empty() { + warn!("Received unexpected message from server"); + } + + match message.message { + Response::DisplayMessage { message, id, value } => { + if !message.is_empty() { + println!("{message}"); + } + + let config = self.config.lock().unwrap(); + let show_motd_after_login = config.general.show_motd_after_login; + let show_inbox_count_after_login = config.general.show_inbox_count_after_login; + let show_pm_inbox_count_after_login = + config.general.show_pm_inbox_count_after_login; + let show_balance_after_payment = config.general.show_balance_after_payment; + drop(config); + + match id.as_str() { + "auth.success" => { + self.id = value as i32; + self.last_username = self.username.clone(); + self.last_password = self.password.clone(); + self.logged_in = true; + + if show_motd_after_login { + self.send_authenticated(AuthenticatedMessage::Motd); + } + if show_inbox_count_after_login { + self.send_authenticated(AuthenticatedMessage::GetInbox { + message_id: -1, + }) + } + if show_pm_inbox_count_after_login { + self.send_authenticated(AuthenticatedMessage::GetPmInbox { + message_id: -1, + }) + } + + println!("(Client Id: {})", self.id); + } + "auth.fail.credentials" => { + self.backup_auth(); + } + "register.success" => { + self.send_authenticated(AuthenticatedMessage::Authenticate); + } + "register.fail.usernameTaken" => { + self.backup_auth(); + } + "logout.success" => { + self.logged_in = false; + } + "logout.fail.credentials" => { + self.auth_error(); + } + "logout.fail.notloggedin" => { + self.logged_in = false; + } + "balance.success" => {} + "balance.fail.credentials" => { + self.auth_error(); + } + "pay.fail.negative_amount" => { + panic!("Should not be able to send a negative amount") + } + "pay.fail.orig_user_unknown" => { + self.auth_error(); + } + "pay.fail.credentials" => { + self.auth_error(); + } + "pay.fail.unknown_dest" => {} + "pay.fail.not_enough_money" => { + if show_balance_after_payment { + self.send_authenticated(AuthenticatedMessage::GetBalance); + } + } + "pay.fail.unknown_error" => {} + "pay.success" => { + if show_balance_after_payment { + self.send_authenticated(AuthenticatedMessage::GetBalance); + } + } + "pay.received" => { + if show_balance_after_payment { + self.send_authenticated(AuthenticatedMessage::GetBalance); + } + } + "message_count" => { + self.inbox_message_count = value as u8; + println!( + "You have {} unread message{}", + self.inbox_message_count, + if self.inbox_message_count == 1 { + "" + } else { + "s" + } + ) + } + "pm_message_count" => { + self.pm_message_count = value as u8; + println!( + "You have {} unread private message{}", + self.pm_message_count, + if self.pm_message_count == 1 { "" } else { "s" } + ) + } + "pm_inbox.send.success" => {} + "pm_inbox.dest.unkown" => {} + "read_inbox.success" => {} + "read_inbox.fail.credentials" => { + self.auth_error(); + } + "read_pm.success" => {} + "read_pm.fail.credentials" => { + self.auth_error(); + } + _ => { + warn!("Unknown message id: {}", id) + } + } + } + Response::Inbox { + message_id, + message, + } => { + println!("{}. {}", message_id, message); + } + Response::PmInbox { + message_id, + message, + } => { + println!("{}. {}", message_id, message); + } + Response::Motd { motd } => { + println!("--- {motd} ---"); + } + } + + if !self.message_queue.is_empty() { + self.message_queue.pop_front().unwrap().1.await; + if !self.message_queue.is_empty() { + let queued = self.message_queue.pop_front().unwrap(); + self.send_authenticated_now(queued.0, queued.1); + } + }*/ + } else { + debug!("ignoring message for other id") + } + + Ok(()) + } + + async fn on_binary(&mut self, _: Vec) -> Result<(), Error> { + panic!("binary?? what is that?") + } + + async fn on_call(&mut self, call: Self::Call) -> Result<(), Error> { + match call { + ClientCommand::SendAuthenticatedNew { + message, + accept, + tx, + } => { + self.send_authenticated(message); + self.receivers.push_back((accept, tx)); + } + ClientCommand::SendAuthenticated(message) => { + self.send_authenticated(message); + } + ClientCommand::ChangeCredentials { username, password } => { + self.username = username; + self.password = password; + } + /*ClientCommand::TryLastLogin => { + if self.last_username.is_empty() || self.last_password.is_empty() { + println!("You need to enter a username and password"); + } else { + self.send_authenticated(AuthenticatedMessage::Authenticate); + } + } + ClientCommand::CheckTimeout => { + if !self.message_queue.is_empty() && self.send_time.elapsed().unwrap().as_secs() > 5 + { + if self.retry_count >= 3 { + self.message_queue.pop_front(); + self.retry_count = 0; + println!("Request Timeout - All Retries failed") + } else { + self.retry_count += 1; + println!("Request Timeout - Retrying {}/3", self.retry_count); + } + if !self.message_queue.is_empty() { + let queued = self.message_queue.pop_front().unwrap(); + self.send_authenticated_now(queued.0, queued.1); + } + } + } + ClientCommand::SendWithCallback(message, callback) => { + self.send_authenticated_callback(message, callback); + } + ClientCommand::PrintInbox => { + for i in 0..self.inbox_message_count { + self.send_authenticated(AuthenticatedMessage::GetInbox { + message_id: (i + 1) as i8, + }) + } + } + ClientCommand::PrintPmInbox => { + for i in 0..self.pm_message_count { + self.send_authenticated(AuthenticatedMessage::GetPmInbox { + message_id: (i + 1) as i8, + }) + } + } + ClientCommand::TryAccount(success, error) => { + //let last_username = self.last_username.clone(); + //let last_password = self.last_password.clone(); + self.last_username = String::new(); + self.last_password = String::new(); + /*self.send_authenticated_callback(AuthenticatedMessage::Authenticate, Box::new(Box::pin(async move { + if !self.logged_in { + self.send_authenticated_callback(AuthenticatedMessage::Register, Box::new(Box::pin(async move { + if !self.logged_in { + error.await; + } else { + success.await; + } + }))); + } else { + success.await; + } + }))); + */ + }*/ + } + Ok(()) + } + + async fn on_connect(&mut self) -> Result<(), Error> { + info!("Successfully connected to the Server"); + if !self.username.is_empty() && !self.password.is_empty() { + self.send_authenticated(AuthenticatedMessage::Authenticate); + } + Ok(()) + } + + async fn on_connect_fail(&mut self, _error: WSError) -> Result { + info!("Disconnected, reconnecting..."); + Ok(ClientCloseMode::Reconnect) + } + + async fn on_close(&mut self, _frame: Option) -> Result { + info!("Disconnected, reconnecting..."); + Ok(ClientCloseMode::Reconnect) + } + + async fn on_disconnect(&mut self) -> Result { + info!("Disconnected, reconnecting..."); + Ok(ClientCloseMode::Reconnect) + } +} + +pub enum ClientCommand { + SendAuthenticatedNew { + message: AuthenticatedMessage, + accept: fn(&ResponseMessage) -> bool, + tx: tokio::sync::oneshot::Sender>, + }, + SendAuthenticated(AuthenticatedMessage), + /*SendWithCallback( + AuthenticatedMessage, + Box + Send + Unpin>, + ), + PrintInbox, + PrintPmInbox,*/ + ChangeCredentials { + username: String, + password: String, + }, + /*TryLastLogin, + CheckTimeout, + TryAccount( + Box + Send + Unpin>, + Box + Send + Unpin>, + ),*/ +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..e69de29