diff --git a/Cargo.lock b/Cargo.lock index 3e6dfb5..37e2d7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -190,6 +190,8 @@ dependencies = [ "http", "http-body", "http-body-util", + "hyper", + "hyper-util", "itoa", "matchit", "memchr", @@ -198,10 +200,15 @@ dependencies = [ "pin-project-lite", "rustversion", "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", "sync_wrapper 1.0.1", + "tokio", "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -222,6 +229,30 @@ dependencies = [ "sync_wrapper 0.1.2", "tower-layer", "tower-service", + "tracing", +] + +[[package]] +name = "axum-extra" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be6ea09c9b96cb5076af0de2e383bd2bc0c18f827cf1967bdd353e0b910d733" +dependencies = [ + "axum", + "axum-core", + "bytes", + "futures-util", + "headers", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "serde", + "tower", + "tower-layer", + "tower-service", + "tracing", ] [[package]] @@ -285,7 +316,7 @@ dependencies = [ "bitflags 2.6.0", "cexpr", "clang-sys", - "itertools", + "itertools 0.12.1", "lazy_static", "lazycell", "proc-macro2", @@ -1193,6 +1224,30 @@ dependencies = [ "num-traits", ] +[[package]] +name = "headers" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "322106e6bd0cba2d5ead589ddb8150a13d7c4217cf80d7c4f682ca994ccc6aa9" +dependencies = [ + "base64 0.21.7", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + [[package]] name = "heck" version = "0.4.1" @@ -1382,9 +1437,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ab92f4f49ee4fb4f997c784b7a2e0fa70050211e0b6a287f898c3c9785ca956" +checksum = "cde7055719c54e36e95e8719f95883f22072a48ede39db7fc17a4e1d5281e9b9" dependencies = [ "bytes", "futures-channel", @@ -1525,6 +1580,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.11" @@ -2238,7 +2302,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "18bec9b0adc4eba778b33684b7ba3e7137789434769ee3ce3930463ef904cfca" dependencies = [ "anyhow", - "itertools", + "itertools 0.13.0", "proc-macro2", "quote", "syn 2.0.72", @@ -2468,6 +2532,8 @@ version = "1.4.2" dependencies = [ "anyhow", "argon2", + "axum", + "axum-extra", "bytes", "chrono", "config", @@ -2479,6 +2545,7 @@ dependencies = [ "evalexpr", "futures", "glob", + "headers", "lazy_static", "log", "mail-builder", @@ -2725,9 +2792,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.7.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "976295e77ce332211c0d24d92c0e83e50f5c5f046d11082cea19f3df13a3562d" +checksum = "fc0a2ce646f8655401bb81e7927b812614bd5d91dbc968696be50603510fcaf0" [[package]] name = "rustls-webpki" @@ -2822,6 +2889,16 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af99884400da37c88f5e9146b7f1fd0fbcae8f6eec4e9da38b67d05486f814a6" +dependencies = [ + "itoa", + "serde", +] + [[package]] name = "serde_spanned" version = "0.6.7" @@ -3325,6 +3402,7 @@ version = "0.1.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", diff --git a/Cargo.toml b/Cargo.toml index c7fd4b1..acce47c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,9 @@ duration-str = "0.11.2" version-compare = "0.2" mail-send = "0.4.7" mail-builder = "0.3.2" +axum = "0.7.5" +axum-extra = {version = "0.9.3", features = ["typed-header"]} +headers = "0.4.0" [target.'cfg(windows)'.build-dependencies] winres = "0.1" diff --git a/config/Config.toml b/config/Config.toml index bfcb399..498ece5 100644 --- a/config/Config.toml +++ b/config/Config.toml @@ -51,6 +51,19 @@ generate_pub = false # Server language. Should match a ron file in the lang directory lang = "en" +[api] +# Set to true to enable the REOSERV HTTP API +enabled = false + +# Host IP the API will listen for incoming requests on +host = "0.0.0.0" + +# Host Port the API will listen for incoming requests on +port = "3000" + +# Minutes a user access token is considered valid +access_token_ttl = 20 + [database] host = "127.0.0.1" port = "3306" diff --git a/db-init/1-init.sql b/db-init/1-init.sql index 4d2559f..8ddde6f 100644 --- a/db-init/1-init.sql +++ b/db-init/1-init.sql @@ -304,6 +304,19 @@ CREATE TABLE `QuestProgress` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; /*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `AccessToken`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `AccessToken` ( + `id` INT NOT NULL AUTO_INCREMENT, + `account_id` INT NOT NULL, + `token` VARCHAR(32) NOT NULL, + `ttl` TINYINT(3) NOT NULL DEFAULT '20', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + CONSTRAINT `access_token_account_id` FOREIGN KEY (`account_id`) REFERENCES `Account` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET SQL_MODE=@OLD_SQL_MODE */; /*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; diff --git a/src/api/account.rs b/src/api/account.rs new file mode 100644 index 0000000..9d2c580 --- /dev/null +++ b/src/api/account.rs @@ -0,0 +1,8 @@ +#[derive(Debug, Serialize)] +pub struct Account { + pub id: i32, + pub username: String, + pub email: String, + pub real_name: String, + pub location: String, +} diff --git a/src/api/app_error.rs b/src/api/app_error.rs new file mode 100644 index 0000000..0936dd6 --- /dev/null +++ b/src/api/app_error.rs @@ -0,0 +1,27 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, +}; + +#[derive(Debug)] +pub struct AppError(anyhow::Error); + +// Tell axum how to convert `AppError` into a response. +impl IntoResponse for AppError { + fn into_response(self) -> Response { + error!("Application error: {:#}", self.0); + + (StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong").into_response() + } +} + +// This enables using `?` on functions that return `Result<_, anyhow::Error>` to turn them into +// `Result<_, AppError>`. That way you don't need to do that manually. +impl From for AppError +where + E: Into, +{ + fn from(err: E) -> Self { + Self(err.into()) + } +} diff --git a/src/api/app_state.rs b/src/api/app_state.rs new file mode 100644 index 0000000..c9f336d --- /dev/null +++ b/src/api/app_state.rs @@ -0,0 +1,22 @@ +use axum::extract::FromRef; +use mysql_async::Pool; + +use crate::world::WorldHandle; + +#[derive(Clone)] +pub struct AppState { + pub pool: Pool, + pub world: WorldHandle, +} + +impl FromRef for Pool { + fn from_ref(state: &AppState) -> Self { + state.pool.clone() + } +} + +impl FromRef for WorldHandle { + fn from_ref(state: &AppState) -> Self { + state.world.clone() + } +} diff --git a/src/api/generate_access_token.rs b/src/api/generate_access_token.rs new file mode 100644 index 0000000..d057987 --- /dev/null +++ b/src/api/generate_access_token.rs @@ -0,0 +1,7 @@ +use rand::distributions::Alphanumeric; +use rand::{thread_rng, Rng}; + +pub fn generate_access_token() -> String { + let mut rng = thread_rng(); + (0..32).map(|_| rng.sample(Alphanumeric) as char).collect() +} diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..9980e38 --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,11 @@ +mod account; +mod app_state; +mod generate_access_token; +mod run_api; +use app_state::AppState; +mod user; +pub use run_api::run_api; +use user::User; +mod app_error; +mod routes; +use app_error::AppError; diff --git a/src/api/routes/account.rs b/src/api/routes/account.rs new file mode 100644 index 0000000..27341b6 --- /dev/null +++ b/src/api/routes/account.rs @@ -0,0 +1,49 @@ +use axum::{extract::State, response::IntoResponse, Json}; +use mysql_async::{params, prelude::Queryable, Params, Pool, Row}; + +use crate::api::{ + account::Account, + user::{AuthError, User}, +}; + +pub async fn get_account( + user: User, + State(pool): State, +) -> Result { + let mut conn = match pool.get_conn().await { + Ok(conn) => conn, + Err(e) => { + error!("Failed to get database connection: {}", e); + return Err(AuthError); + } + }; + + let row = match conn + .exec_first::( + include_str!("../../sql/get_account.sql"), + params! { + "id" => &user.id + }, + ) + .await + { + Ok(Some(row)) => row, + Ok(None) => { + return Err(AuthError); + } + Err(e) => { + error!("Error getting account: {}", e); + return Err(AuthError); + } + }; + + let account = Account { + id: row.get::("id").unwrap(), + username: row.get::("name").unwrap(), + email: row.get::("email").unwrap(), + real_name: row.get::("real_name").unwrap(), + location: row.get::("location").unwrap(), + }; + + Ok(Json(account)) +} diff --git a/src/api/routes/classes.rs b/src/api/routes/classes.rs new file mode 100644 index 0000000..b6dc2d7 --- /dev/null +++ b/src/api/routes/classes.rs @@ -0,0 +1,30 @@ +use axum::{extract::Path, http::StatusCode, response::IntoResponse, Json}; + +use crate::CLASS_DB; + +pub async fn get_class_list() -> impl IntoResponse { + let classes = CLASS_DB + .classes + .iter() + .take_while(|class| class.name != "eof") + .enumerate() + .map(|(index, class)| ClassListClass { + id: index as i32 + 1, + name: class.name.clone(), + }) + .collect::>(); + Json(classes).into_response() +} + +pub async fn get_class(Path(id): Path) -> impl IntoResponse { + match CLASS_DB.classes.get(id as usize - 1) { + Some(class) => Json(class).into_response(), + None => (StatusCode::NOT_FOUND).into_response(), + } +} + +#[derive(Serialize)] +struct ClassListClass { + id: i32, + name: String, +} diff --git a/src/api/routes/items.rs b/src/api/routes/items.rs new file mode 100644 index 0000000..cee36dc --- /dev/null +++ b/src/api/routes/items.rs @@ -0,0 +1,30 @@ +use axum::{extract::Path, http::StatusCode, response::IntoResponse, Json}; + +use crate::ITEM_DB; + +pub async fn get_item_list() -> impl IntoResponse { + let items = ITEM_DB + .items + .iter() + .take_while(|item| item.name != "eof") + .enumerate() + .map(|(index, item)| ItemListItem { + id: index as i32 + 1, + name: item.name.clone(), + }) + .collect::>(); + Json(items).into_response() +} + +pub async fn get_item(Path(id): Path) -> impl IntoResponse { + match ITEM_DB.items.get(id as usize - 1) { + Some(item) => Json(item).into_response(), + None => (StatusCode::NOT_FOUND).into_response(), + } +} + +#[derive(Serialize)] +struct ItemListItem { + id: i32, + name: String, +} diff --git a/src/api/routes/login.rs b/src/api/routes/login.rs new file mode 100644 index 0000000..a683dcc --- /dev/null +++ b/src/api/routes/login.rs @@ -0,0 +1,75 @@ +use axum::{ + extract::State, + http::{header::SET_COOKIE, StatusCode}, + response::{AppendHeaders, IntoResponse}, + Json, +}; +use mysql_async::{params, prelude::Queryable, Params, Pool, Row}; + +use crate::{ + api::{generate_access_token::generate_access_token, AppError}, + utils::validate_password, + SETTINGS, +}; + +pub async fn login( + State(pool): State, + Json(payload): Json, +) -> Result { + let mut conn = pool.get_conn().await?; + + let row = match conn + .exec_first::( + include_str!("../../sql/get_password_hash.sql"), + params! { + "name" => &payload.username, + }, + ) + .await? + { + Some(row) => row, + None => { + // Check a hash anyway + validate_password(&payload.username, &payload.password, "$argon2id$v=19$m=19456,t=2,p=1$2fxYwlgtiSkaQwpuTsFLUg$G43qDEoUMmXRtZX2GBSAD9pVI5wDtSxohb0LgsqgWR0"); + return Ok((StatusCode::FORBIDDEN, "Unauthorized").into_response()); + } + }; + + let account_id: i32 = row.get("id").unwrap(); + let password_hash: String = row.get("password_hash").unwrap(); + if !validate_password(&payload.username, &payload.password, &password_hash) { + return Ok((StatusCode::FORBIDDEN, "Unauthorized").into_response()); + } + + let access_token = generate_access_token(); + + conn.exec_drop( + include_str!("../../sql/create_access_token.sql"), + params! { + "account_id" => &account_id, + "token" => &access_token, + "ttl" => &SETTINGS.api.access_token_ttl, + }, + ) + .await?; + + Ok(( + StatusCode::OK, + AppendHeaders([( + SET_COOKIE, + format!( + "access_token={}; Max-Age={}; Secure; HttpOnly; SameSite=Lax", + access_token, + SETTINGS.api.access_token_ttl * 60, + ), + )]), + String::from("authenticated"), + ) + .into_response()) +} + +#[derive(Deserialize, Debug)] +pub struct LoginRequest { + username: String, + password: String, +} diff --git a/src/api/routes/logout.rs b/src/api/routes/logout.rs new file mode 100644 index 0000000..20e0fe2 --- /dev/null +++ b/src/api/routes/logout.rs @@ -0,0 +1,15 @@ +use axum::{ + http::{header::SET_COOKIE, StatusCode}, + response::{AppendHeaders, IntoResponse}, +}; + +pub async fn logout() -> impl IntoResponse { + ( + StatusCode::OK, + AppendHeaders([( + SET_COOKIE, + format!("access_token=; Max-Age=1; Secure; HttpOnly; SameSite=Lax"), + )]), + "logged out", + ) +} diff --git a/src/api/routes/maps.rs b/src/api/routes/maps.rs new file mode 100644 index 0000000..dfa0a00 --- /dev/null +++ b/src/api/routes/maps.rs @@ -0,0 +1,20 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; + +use crate::world::WorldHandle; + +pub async fn get_map_list(State(world): State) -> impl IntoResponse { + let maps = world.get_map_list().await; + Json(maps).into_response() +} + +pub async fn get_map(Path(id): Path, State(world): State) -> impl IntoResponse { + match world.get_map(id).await { + Ok(map) => Json(map.get_state().await).into_response(), + Err(_) => (StatusCode::NOT_FOUND).into_response(), + } +} diff --git a/src/api/routes/mod.rs b/src/api/routes/mod.rs new file mode 100644 index 0000000..1467807 --- /dev/null +++ b/src/api/routes/mod.rs @@ -0,0 +1,18 @@ +mod account; +pub(super) use account::get_account; +mod login; +pub(super) use login::login; +mod logout; +pub(super) use logout::logout; +mod root; +pub(super) use root::root; +mod user; +pub(super) use user::user; +mod items; +pub(super) use items::{get_item, get_item_list}; +mod maps; +pub(super) use maps::{get_map, get_map_list}; +mod npcs; +pub(super) use npcs::{get_npc, get_npc_list}; +mod classes; +pub(super) use classes::{get_class, get_class_list}; diff --git a/src/api/routes/npcs.rs b/src/api/routes/npcs.rs new file mode 100644 index 0000000..e2eb860 --- /dev/null +++ b/src/api/routes/npcs.rs @@ -0,0 +1,30 @@ +use axum::{extract::Path, http::StatusCode, response::IntoResponse, Json}; + +use crate::NPC_DB; + +pub async fn get_npc_list() -> impl IntoResponse { + let npcs = NPC_DB + .npcs + .iter() + .take_while(|npc| npc.name != "eof") + .enumerate() + .map(|(index, npc)| NpcListNpc { + id: index as i32 + 1, + name: npc.name.clone(), + }) + .collect::>(); + Json(npcs).into_response() +} + +pub async fn get_npc(Path(id): Path) -> impl IntoResponse { + match NPC_DB.npcs.get(id as usize - 1) { + Some(npc) => Json(npc).into_response(), + None => (StatusCode::NOT_FOUND).into_response(), + } +} + +#[derive(Serialize)] +struct NpcListNpc { + id: i32, + name: String, +} diff --git a/src/api/routes/root.rs b/src/api/routes/root.rs new file mode 100644 index 0000000..b5fdea0 --- /dev/null +++ b/src/api/routes/root.rs @@ -0,0 +1,3 @@ +pub async fn root() -> &'static str { + "Hello, world!" +} diff --git a/src/api/routes/user.rs b/src/api/routes/user.rs new file mode 100644 index 0000000..52d30af --- /dev/null +++ b/src/api/routes/user.rs @@ -0,0 +1,7 @@ +use axum::{response::IntoResponse, Json}; + +use crate::api::User; + +pub async fn user(user: User) -> impl IntoResponse { + Json(user) +} diff --git a/src/api/run_api.rs b/src/api/run_api.rs new file mode 100644 index 0000000..41fcb12 --- /dev/null +++ b/src/api/run_api.rs @@ -0,0 +1,56 @@ +use axum::{ + routing::{get, post}, + Router, +}; +use mysql_async::Pool; +use tokio::net::TcpListener; + +use crate::{ + api::{ + routes::{ + get_account, get_class, get_class_list, get_item, get_item_list, get_map, get_map_list, + get_npc, get_npc_list, login, logout, root, user, + }, + AppState, + }, + world::WorldHandle, + SETTINGS, +}; + +pub async fn run_api(pool: Pool, world: WorldHandle) { + let app_state = AppState { pool, world }; + + let app = Router::new() + .route("/", get(root)) + .route("/login", post(login)) + .route("/logout", post(logout)) + .route("/account", get(get_account)) + .route("/user", get(user)) + .route("/items/list", get(get_item_list)) + .route("/items/:id", get(get_item)) + .route("/npcs/list", get(get_npc_list)) + .route("/npcs/:id", get(get_npc)) + .route("/classes/list", get(get_class_list)) + .route("/classes/:id", get(get_class)) + .route("/maps/list", get(get_map_list)) + .route("/maps/:id", get(get_map)) + .with_state(app_state); + + let listener = + match TcpListener::bind(format!("{}:{}", SETTINGS.api.host, SETTINGS.api.port)).await { + Ok(listener) => listener, + Err(e) => { + error!("Failed to bind api listener: {}", e); + return; + } + }; + + info!( + "API Listening at http://{}:{}", + SETTINGS.api.host, SETTINGS.api.port + ); + + if let Err(e) = axum::serve(listener, app).await { + error!("Failed to start axum serve: {}", e); + } +} diff --git a/src/api/user.rs b/src/api/user.rs new file mode 100644 index 0000000..d3997a0 --- /dev/null +++ b/src/api/user.rs @@ -0,0 +1,107 @@ +use axum::{ + async_trait, + extract::{FromRef, FromRequestParts}, + http::{header, request::Parts, StatusCode}, + response::{IntoResponse, Response}, + RequestPartsExt, +}; +use axum_extra::{typed_header::TypedHeaderRejectionReason, TypedHeader}; +use eolib::protocol::AdminLevel; +use mysql_async::{params, prelude::Queryable, Pool, Row}; + +#[derive(Default, Debug, Serialize, Deserialize)] +pub struct User { + pub id: i32, + username: String, + admin_level: AdminLevel, + characters: Vec, +} + +#[derive(Default, Debug, Serialize, Deserialize)] +pub struct UserCharacter { + id: i32, + name: String, +} + +pub struct AuthError; + +impl IntoResponse for AuthError { + fn into_response(self) -> Response { + (StatusCode::UNAUTHORIZED, "Unauthorized").into_response() + } +} + +#[async_trait] +impl FromRequestParts for User +where + Pool: FromRef, + S: Send + Sync, +{ + type Rejection = AuthError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let pool = Pool::from_ref(state); + + let cookies = parts + .extract::>() + .await + .map_err(|e| match *e.name() { + header::COOKIE => match e.reason() { + TypedHeaderRejectionReason::Missing => AuthError, + _ => panic!("unexpected error getting Cookie header(s): {e}"), + }, + _ => panic!("unexpected error getting cookies: {e}"), + })?; + + let access_token = cookies.get("access_token").ok_or(AuthError)?; + + let mut conn = match pool.get_conn().await { + Ok(conn) => conn, + Err(e) => { + error!("Failed to get database connection: {}", e); + return Err(AuthError); + } + }; + + let mut user = User::default(); + + let characters = match conn + .exec_map( + include_str!("../sql/get_user_from_access_token.sql"), + params! { + "access_token" => &access_token, + }, + |row: Row| { + if user.id == 0 { + user.id = row.get::("account_id").unwrap(); + user.username = row.get::("account_name").unwrap(); + } + let admin_level = row.get::("admin_level").unwrap(); + if admin_level > user.admin_level.into() { + user.admin_level = AdminLevel::from(admin_level); + } + + UserCharacter { + id: row.get::("character_id").unwrap(), + name: row.get::("character_name").unwrap(), + } + }, + ) + .await + { + Ok(characters) => characters, + Err(e) => { + error!("Error getting user: {}", e); + return Err(AuthError); + } + }; + + if user.id == 0 { + return Err(AuthError); + } + + user.characters = characters; + + Ok(user) + } +} diff --git a/src/main.rs b/src/main.rs index b83d737..7898f3e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,6 +26,7 @@ use commands::Commands; mod connection_log; mod formulas; use formulas::Formulas; +mod api; mod emails; mod errors; mod lang; @@ -186,6 +187,7 @@ async fn main() -> Result<(), Box> { ); let mut server_world = world.clone(); + let player_pool = pool.clone(); tokio::spawn(async move { while server_world.is_alive { let (socket, addr) = tcp_listener.accept().await.unwrap(); @@ -227,8 +229,13 @@ async fn main() -> Result<(), Box> { let player_id = server_world.get_next_player_id().await.unwrap(); - let player = - PlayerHandle::new(player_id, socket, now, server_world.clone(), pool.clone()); + let player = PlayerHandle::new( + player_id, + socket, + now, + server_world.clone(), + player_pool.clone(), + ); server_world.add_player(player_id, player).await.unwrap(); info!( @@ -240,6 +247,10 @@ async fn main() -> Result<(), Box> { } }); + if SETTINGS.api.enabled { + tokio::spawn(api::run_api(pool.clone(), world.clone())); + } + tokio::select! { ctrl_c = signal::ctrl_c() => match ctrl_c { Ok(()) => {}, diff --git a/src/map/command.rs b/src/map/command.rs index a521717..d440307 100644 --- a/src/map/command.rs +++ b/src/map/command.rs @@ -13,8 +13,11 @@ use tokio::sync::oneshot; use crate::{ character::{Character, SpellTarget}, player::PartyRequest, + world::MapListItem, }; +use super::MapState; + #[derive(Debug)] pub enum Command { AcceptGuildCreationRequest { @@ -166,6 +169,9 @@ pub enum Command { player_id: i32, respond_to: oneshot::Sender, }, + GetState { + respond_to: oneshot::Sender, + }, GetNpcIdForIndex { npc_index: i32, respond_to: oneshot::Sender>, @@ -523,6 +529,9 @@ pub enum Command { speed: i32, }, SpawnNpcs, + ToMapListItem { + respond_to: oneshot::Sender, + }, ActNpcs, Quake { magnitude: i32, diff --git a/src/map/map.rs b/src/map/map.rs index 63070e2..3d3ef27 100644 --- a/src/map/map.rs +++ b/src/map/map.rs @@ -7,7 +7,11 @@ use eolib::protocol::{ use mysql_async::Pool; use tokio::sync::mpsc::UnboundedReceiver; -use crate::{character::Character, world::WorldHandle, SETTINGS}; +use crate::{ + character::Character, + world::{MapListItem, WorldHandle}, + SETTINGS, +}; use super::{Chest, Command, Door, Item, Npc, Wedding}; @@ -287,6 +291,10 @@ impl Map { self.get_item(target_player_id, item_index); } + Command::GetState { respond_to } => { + let _ = respond_to.send(self.get_state()); + } + Command::GetNearbyInfo { player_id: target_player_id, respond_to, @@ -724,6 +732,15 @@ impl Map { } => self.request_players_and_npcs(player_id, player_ids, npc_indexes), Command::RequestRefresh { player_id } => self.request_refresh(player_id), Command::Quake { magnitude } => self.quake(magnitude), + Command::ToMapListItem { respond_to } => { + let _ = respond_to.send(MapListItem { + id: self.id, + name: self.file.name.clone(), + players: self.characters.len() as i32, + npcs: self.npcs.values().filter(|npc| npc.alive).count() as i32, + items: self.items.len() as i32, + }); + } } } } diff --git a/src/map/map/utils/get_state.rs b/src/map/map/utils/get_state.rs new file mode 100644 index 0000000..b3dd25b --- /dev/null +++ b/src/map/map/utils/get_state.rs @@ -0,0 +1,63 @@ +use eolib::protocol::net::Item; + +use crate::map::{MapState, MapStateCharacter, MapStateChest, MapStateItem, MapStateNpc}; + +use super::super::Map; + +impl Map { + pub fn get_state(&self) -> MapState { + MapState { + name: self.file.name.to_owned(), + chests: self + .chests + .iter() + .map(|chest| MapStateChest { + coords: chest.coords, + items: chest + .items + .iter() + .map(|item| Item { + id: item.item_id, + amount: item.amount, + }) + .collect::>(), + }) + .collect::>(), + npcs: self + .npcs + .iter() + .map(|(index, npc)| MapStateNpc { + index: *index, + id: npc.id, + coords: npc.coords, + alive: npc.alive, + }) + .collect::>(), + characters: self + .characters + .iter() + .map(|(_, character)| MapStateCharacter { + id: character.id, + name: character.name.clone(), + coords: character.coords, + level: character.level, + class: character.class, + guild: match character.guild_tag.as_ref() { + Some(tag) => tag.to_owned(), + None => "".to_string(), + }, + }) + .collect::>(), + items: self + .items + .iter() + .map(|(index, item)| MapStateItem { + index: *index, + coords: item.coords, + id: item.id, + amount: item.amount, + }) + .collect::>(), + } + } +} diff --git a/src/map/map/utils/mod.rs b/src/map/map/utils/mod.rs index f14b550..faccf99 100644 --- a/src/map/map/utils/mod.rs +++ b/src/map/map/utils/mod.rs @@ -7,6 +7,7 @@ mod get_dimensions; mod get_nearby_info; mod get_next_item_index; mod get_rid_and_size; +mod get_state; mod get_tile; mod get_warp; mod give_experience; diff --git a/src/map/map_handle.rs b/src/map/map_handle.rs index f1ae3da..03a0f7d 100644 --- a/src/map/map_handle.rs +++ b/src/map/map_handle.rs @@ -17,10 +17,10 @@ use tokio::sync::{ use crate::{ character::{Character, SpellTarget}, player::PartyRequest, - world::WorldHandle, + world::{MapListItem, WorldHandle}, }; -use super::{Command, Map}; +use super::{Command, Map, MapState}; #[derive(Debug, Clone)] pub struct MapHandle { @@ -272,6 +272,12 @@ impl MapHandle { rx.await.unwrap() } + pub async fn get_state(&self) -> MapState { + let (tx, rx) = oneshot::channel(); + let _ = self.tx.send(Command::GetState { respond_to: tx }); + rx.await.unwrap() + } + pub async fn get_npc_id_for_index(&self, npc_index: i32) -> Option { let (tx, rx) = oneshot::channel(); let _ = self.tx.send(Command::GetNpcIdForIndex { @@ -902,6 +908,12 @@ impl MapHandle { pub fn quake(&self, magnitude: i32) { let _ = self.tx.send(Command::Quake { magnitude }); } + + pub async fn to_map_list_item(&self) -> MapListItem { + let (tx, rx) = oneshot::channel(); + let _ = self.tx.send(Command::ToMapListItem { respond_to: tx }); + rx.await.unwrap() + } } async fn run_map(mut map: Map) { diff --git a/src/map/map_state.rs b/src/map/map_state.rs new file mode 100644 index 0000000..3d6ce77 --- /dev/null +++ b/src/map/map_state.rs @@ -0,0 +1,42 @@ +use eolib::protocol::{net::Item, Coords}; + +#[derive(Debug, Serialize)] +pub struct MapState { + pub name: String, + pub chests: Vec, + pub npcs: Vec, + pub characters: Vec, + pub items: Vec, +} + +#[derive(Debug, Serialize)] +pub struct MapStateChest { + pub coords: Coords, + pub items: Vec, +} + +#[derive(Debug, Serialize)] +pub struct MapStateItem { + pub coords: Coords, + pub index: i32, + pub id: i32, + pub amount: i32, +} + +#[derive(Debug, Serialize)] +pub struct MapStateNpc { + pub index: i32, + pub id: i32, + pub coords: Coords, + pub alive: bool, +} + +#[derive(Debug, Serialize)] +pub struct MapStateCharacter { + pub id: i32, + pub name: String, + pub coords: Coords, + pub level: i32, + pub class: i32, + pub guild: String, +} diff --git a/src/map/mod.rs b/src/map/mod.rs index 14d3426..e7a48fb 100644 --- a/src/map/mod.rs +++ b/src/map/mod.rs @@ -15,3 +15,5 @@ mod map_handle; pub use map_handle::MapHandle; mod wedding; pub use wedding::{Wedding, WeddingState}; +mod map_state; +pub(crate) use map_state::*; diff --git a/src/player/player/account/mod.rs b/src/player/player/account/mod.rs index 5c6687f..9e75f14 100644 --- a/src/player/player/account/mod.rs +++ b/src/player/player/account/mod.rs @@ -6,8 +6,6 @@ mod get_character_list; pub(super) use get_character_list::get_character_list; mod get_num_of_characters; pub(super) use get_num_of_characters::get_num_of_characters; -mod password_hash; -pub(super) use password_hash::{generate_password_hash, validate_password}; mod select_character; mod update_last_login_ip; pub(super) use update_last_login_ip::update_last_login_ip; diff --git a/src/player/player/handlers/account.rs b/src/player/player/handlers/account.rs index 7c573e9..eaf92be 100644 --- a/src/player/player/handlers/account.rs +++ b/src/player/player/handlers/account.rs @@ -21,11 +21,8 @@ use crate::{ ACCOUNT_REPLY_WRONG_PIN, ACTION_CONFIG, }, errors::WrongSessionIdError, - player::{ - player::account::{account_exists, generate_password_hash, validate_password}, - ClientState, - }, - utils::{is_deep, send_email}, + player::{player::account::account_exists, ClientState}, + utils::{generate_password_hash, is_deep, send_email, validate_password}, EMAILS, SETTINGS, }; diff --git a/src/player/player/handlers/login.rs b/src/player/player/handlers/login.rs index 58d5068..604475e 100644 --- a/src/player/player/handlers/login.rs +++ b/src/player/player/handlers/login.rs @@ -23,12 +23,11 @@ use crate::{ }, player::{ player::account::{ - account_banned, account_exists, generate_password_hash, get_character_list, - update_last_login_ip, validate_password, + account_banned, account_exists, get_character_list, update_last_login_ip, }, ClientState, }, - utils::{is_deep, mask_email, send_email}, + utils::{generate_password_hash, is_deep, mask_email, send_email, validate_password}, EMAILS, SETTINGS, }; diff --git a/src/settings.rs b/src/settings.rs index ba53cad..98637c5 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -20,6 +20,14 @@ pub struct Server { pub lang: String, } +#[derive(Debug, Deserialize)] +pub struct API { + pub enabled: bool, + pub host: String, + pub port: String, + pub access_token_ttl: i32, +} + #[derive(Debug, Deserialize)] pub struct Database { pub host: String, @@ -286,6 +294,7 @@ pub struct Settings { pub items: Items, pub bard: Bard, pub smtp: Smtp, + pub api: API, } impl Settings { diff --git a/src/sql/create_access_token.sql b/src/sql/create_access_token.sql new file mode 100644 index 0000000..771ce99 --- /dev/null +++ b/src/sql/create_access_token.sql @@ -0,0 +1,12 @@ +INSERT INTO + `AccessToken` ( + `account_id`, + `token`, + `ttl` + ) +VALUES + ( + :account_id, + :token, + :ttl + ); diff --git a/src/sql/get_account.sql b/src/sql/get_account.sql new file mode 100644 index 0000000..2aca85e --- /dev/null +++ b/src/sql/get_account.sql @@ -0,0 +1,7 @@ +SELECT `Account`.`id`, + `Account`.`name`, + `Account`.`real_name`, + `Account`.`location`, + `Account`.`email` +FROM `Account` +WHERE `Account`.`id` = :id diff --git a/src/sql/get_user_from_access_token.sql b/src/sql/get_user_from_access_token.sql new file mode 100644 index 0000000..6f36f37 --- /dev/null +++ b/src/sql/get_user_from_access_token.sql @@ -0,0 +1,12 @@ +SELECT `Account`.`id` 'account_id', + `Account`.`name` 'account_name', + `Character`.`id` 'character_id', + `Character`.`name` 'character_name', + `Character`.`admin_level` 'admin_level' +FROM `AccessToken` +INNER JOIN `Account` + ON `Account`.`id` = `AccessToken`.`account_id` +LEFT JOIN `Character` + ON `Character`.`account_id` = `Account`.`id` +WHERE `token` = :access_token +AND TIMESTAMPDIFF(MINUTE, `AccessToken`.`created_at`, NOW()) < `ttl` diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 3c0edc1..fd9a734 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -46,3 +46,5 @@ mod send_email; pub use send_email::send_email; mod mask_email; pub use mask_email::mask_email; +mod password_hash; +pub use password_hash::{generate_password_hash, validate_password}; diff --git a/src/player/player/account/password_hash.rs b/src/utils/password_hash.rs similarity index 100% rename from src/player/player/account/password_hash.rs rename to src/utils/password_hash.rs diff --git a/src/world/command.rs b/src/world/command.rs index d6898c6..cc1b00c 100644 --- a/src/world/command.rs +++ b/src/world/command.rs @@ -4,7 +4,7 @@ use tokio::sync::oneshot; use crate::{character::Character, map::MapHandle, player::PlayerHandle}; -use super::{Party, WorldHandle}; +use super::{MapListItem, Party, WorldHandle}; #[derive(Debug)] pub enum Command { @@ -105,6 +105,9 @@ pub enum Command { map_id: i32, respond_to: oneshot::Sender>>, }, + GetMapList { + respond_to: oneshot::Sender>, + }, GetNextPlayerId { respond_to: oneshot::Sender, }, diff --git a/src/world/event.rs b/src/world/event.rs new file mode 100644 index 0000000..8787efd --- /dev/null +++ b/src/world/event.rs @@ -0,0 +1,20 @@ +use chrono::Utc; +use mail_send::mail_auth::zip::DateTime; + +pub struct Event { + pub character_id: i32, + pub event_type: EventType, +} + +pub enum EventType { + LoggedIn, + Disconnected, + DroppedItem, + PickedUpItem, + JunkedItem, + DepositedItem, + WithdrewItem, + TookChestItem, + AddedChestItem, + EnteredMap, +} diff --git a/src/world/map_list_item.rs b/src/world/map_list_item.rs new file mode 100644 index 0000000..5274a46 --- /dev/null +++ b/src/world/map_list_item.rs @@ -0,0 +1,8 @@ +#[derive(Debug, Serialize)] +pub struct MapListItem { + pub id: i32, + pub name: String, + pub players: i32, + pub npcs: i32, + pub items: i32, +} diff --git a/src/world/mod.rs b/src/world/mod.rs index 8b41842..7ea4093 100644 --- a/src/world/mod.rs +++ b/src/world/mod.rs @@ -1,5 +1,6 @@ mod command; pub use command::Command; +mod event; mod load_maps; #[allow(clippy::module_inception)] mod world; @@ -7,3 +8,5 @@ mod world_handle; pub use world_handle::WorldHandle; mod party; pub use party::Party; +mod map_list_item; +pub(crate) use map_list_item::MapListItem; diff --git a/src/world/world.rs b/src/world/world.rs index 9d48048..fa5f04b 100644 --- a/src/world/world.rs +++ b/src/world/world.rs @@ -217,6 +217,29 @@ impl World { } } + Command::GetMapList { respond_to } => match self.maps.as_ref() { + Some(maps) => { + let maps = maps + .iter() + .map(|(_, map)| map.to_owned()) + .collect::>(); + + tokio::spawn(async move { + let mut list = Vec::with_capacity(maps.len()); + for i in 0..maps.len() { + let item = maps[i].to_map_list_item().await; + list.push(item); + } + list.sort_by(|a, b| a.id.cmp(&b.id)); + + let _ = respond_to.send(list); + }); + } + None => { + let _ = respond_to.send(Vec::new()); + } + }, + Command::GetNextPlayerId { respond_to } => { let _ = respond_to.send(self.get_next_player_id(300)); } diff --git a/src/world/world_handle.rs b/src/world/world_handle.rs index 1c87c53..3c22ed7 100644 --- a/src/world/world_handle.rs +++ b/src/world/world_handle.rs @@ -5,7 +5,7 @@ use tokio::sync::{mpsc, oneshot}; use crate::{character::Character, map::MapHandle, player::PlayerHandle}; -use super::{world::World, Command, Party}; +use super::{world::World, Command, MapListItem, Party}; #[derive(Debug, Clone)] pub struct WorldHandle { @@ -199,6 +199,12 @@ impl WorldHandle { rx.await.unwrap() } + pub async fn get_map_list(&self) -> Vec { + let (tx, rx) = oneshot::channel(); + let _ = self.tx.send(Command::GetMapList { respond_to: tx }); + rx.await.unwrap() + } + pub async fn get_next_player_id( &self, ) -> Result> {