diff --git a/Cargo.toml b/Cargo.toml index 39e08db..9753c46 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,14 +6,25 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +actix-governor = "0.5" actix-web = "4.4" -cached = { version = "0.49", features = ["async"] } +base64 = "0.22" +cached = { version = "0.52", features = ["async"] } +chacha20poly1305 = "0.10" confy = "0.6" +deadpool-postgres = "0.14" +deku = "0.17" env_logger = "0.11" futures = "0.3" octocrab = "0.38" +rand_core = "0.6" reqwest = { version = "0.12", features = ["charset", "http2", "macos-system-configuration", "rustls-tls"], default-features = false } secure-string = { version = "0.3", features = ["serde"] } semver = "1.0" serde = { version = "1.0", features = ["derive"] } +serde_with = { version = "3.8", features = ["base64", "time_0_3"] } +strum = { version = "0.26", features = ["derive"] } +tokio = "1.37" +tokio-postgres = { version = "0.7", features = ["with-uuid-1"] } url = "2.5" +uuid = { version = "1.8", features = ["v4", "macro-diagnostics"] } diff --git a/src/app_data.rs b/src/app_data.rs new file mode 100644 index 0000000..b973898 --- /dev/null +++ b/src/app_data.rs @@ -0,0 +1,12 @@ +use cached::TimedCache; +use std::sync::Mutex; + +use crate::config::ApiConfig; +use crate::fetcher::Fetcher; +use crate::version::CachedReleased; + +pub struct AppData { + pub cache: Mutex>, + pub config: ApiConfig, + pub fetcher: Fetcher, +} diff --git a/src/config.rs b/src/config.rs index 8e4857d..b55a25e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,12 @@ +use std::time::Duration; + use secure_string::SecureString; use serde::{Deserialize, Serialize}; +use serde_with::base64::Base64; +use serde_with::serde_as; +use serde_with::DurationSeconds; +#[serde_as] #[derive(Serialize, Deserialize)] pub struct ApiConfig { pub listen_address: String, @@ -9,8 +15,23 @@ pub struct ApiConfig { pub game_repository: String, pub updater_repository: String, pub updater_filename: String, - pub cache_lifespan: u64, + #[serde_as(as = "DurationSeconds")] + pub cache_lifespan: Duration, pub github_pat: Option, + pub db_host: String, + pub db_user: String, + pub db_password: SecureString, + pub db_database: String, + pub player_nickname_maxlength: usize, + pub player_allow_non_ascii: bool, + pub game_api_token: String, + pub game_api_url: String, + pub game_server_address: String, + pub game_server_port: u16, + #[serde_as(as = "DurationSeconds")] + pub game_api_token_duration: Duration, + #[serde_as(as = "Base64")] + pub connection_token_key: [u8; 32], } impl Default for ApiConfig { @@ -22,8 +43,23 @@ impl Default for ApiConfig { game_repository: "ThisSpaceOfMine".to_string(), updater_filename: "this_updater_of_mine".to_string(), updater_repository: "ThisUpdaterOfMine".to_string(), - cache_lifespan: 5 * 60, + cache_lifespan: Duration::from_secs(5 * 60), github_pat: None, + db_host: "localhost".to_string(), + db_user: "api".to_string(), + db_password: "password".into(), + db_database: "tsom_db".to_string(), + player_nickname_maxlength: 16, + player_allow_non_ascii: false, + game_api_token: "".into(), + game_api_url: "http://localhost".to_string(), + game_server_address: "localhost".to_string(), + game_server_port: 29536, + game_api_token_duration: Duration::from_secs(5 * 60), + connection_token_key: [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, + 23, 24, 25, 26, 27, 28, 29, 30, 31, + ], } } } diff --git a/src/errors/api.rs b/src/errors/api.rs new file mode 100644 index 0000000..f0cba42 --- /dev/null +++ b/src/errors/api.rs @@ -0,0 +1,96 @@ +use actix_web::body::BoxBody; +use actix_web::http::StatusCode; +use actix_web::{HttpResponse, ResponseError}; +use serde::{Serialize, Serializer}; +use std::fmt; +use strum::AsRefStr; + +use crate::error_from; + +#[derive(Debug)] +pub enum ErrorCause { + Database, + Internal, +} + +#[derive(Debug, AsRefStr)] +#[strum(serialize_all = "snake_case")] +pub enum ErrorCode { + AuthenticationInvalidToken, + NicknameEmpty, + NicknameToolong, + NicknameForbiddenCharacters, + + #[strum(to_string = "{0}")] + External(String), +} + +#[derive(Debug, Serialize)] +pub struct RequestError { + err_code: ErrorCode, + err_desc: String, +} + +#[derive(Debug)] +pub enum RouteError { + ServerError(ErrorCause, ErrorCode), + InvalidRequest(RequestError), +} + +impl Serialize for ErrorCode { + fn serialize(&self, serializer: S) -> Result { + self.as_ref().serialize(serializer) + } +} + +impl RequestError { + pub fn new(err_code: ErrorCode, err_desc: String) -> Self { + Self { err_code, err_desc } + } +} + +impl fmt::Display for RouteError { + fn fmt(&self, _f: &mut fmt::Formatter<'_>) -> fmt::Result { + unimplemented!() + } +} + +error_from! { transform_io rand_core::Error, RouteError } +error_from! { transform std::io::Error, RouteError, |value| { + RouteError::ServerError( + ErrorCause::Internal, + ErrorCode::External(value.to_string()) + ) +} } +error_from! { transform tokio_postgres::Error, RouteError, |value| { + RouteError::ServerError( + ErrorCause::Database, + ErrorCode::External(value.to_string()) + ) +} } + +error_from! { transform deadpool_postgres::PoolError, RouteError, |value| { + RouteError::ServerError( + ErrorCause::Database, + ErrorCode::External(value.to_string()) + ) +} } + +impl ResponseError for RouteError { + fn status_code(&self) -> StatusCode { + match self { + RouteError::ServerError(..) => StatusCode::INTERNAL_SERVER_ERROR, + RouteError::InvalidRequest(_) => StatusCode::BAD_REQUEST, + } + } + + fn error_response(&self) -> HttpResponse { + match self { + RouteError::ServerError(cause, err_code) => { + eprintln!("{cause:?} error: {}", err_code.as_ref()); + HttpResponse::InternalServerError().finish() + } + RouteError::InvalidRequest(err) => HttpResponse::BadRequest().json(err), + } + } +} diff --git a/src/errors/fetcher.rs b/src/errors/fetcher.rs new file mode 100644 index 0000000..6c31c99 --- /dev/null +++ b/src/errors/fetcher.rs @@ -0,0 +1,17 @@ +use crate::error_from; + +pub type FetchResult = std::result::Result; + +#[derive(Debug)] +pub enum FetcherError { + OctoError(octocrab::Error), + ReqwestError(reqwest::Error), + InvalidSha256(usize), + WrongChecksum, + NoReleaseFound, + InvalidVersion, +} + +error_from! { move octocrab::Error, FetcherError, FetcherError::OctoError } +error_from! { move reqwest::Error, FetcherError, FetcherError::ReqwestError } +error_from! { replace semver::Error, FetcherError, FetcherError::InvalidVersion } diff --git a/src/errors/mod.rs b/src/errors/mod.rs new file mode 100644 index 0000000..f37f4a4 --- /dev/null +++ b/src/errors/mod.rs @@ -0,0 +1,35 @@ +pub mod api; +pub mod fetcher; + +// to delete '$into_type:path' you need to use proc macros and further manipulation of the AST +#[macro_export] +macro_rules! error_from { + (move $from:path, $into_type:path, $into:path) => { + impl From<$from> for $into_type { + fn from(err: $from) -> Self { + $into(err) + } + } + }; + (replace $from:path, $into_type:path, $into:path) => { + impl From<$from> for $into_type { + fn from(_: $from) -> Self { + $into + } + } + }; + (transform $from:path, $into_type:path, |$err_name:ident| $blk:block) => { + impl From<$from> for $into_type { + fn from($err_name: $from) -> Self { + $blk + } + } + }; + (transform_io $from:path, $into_type:path) => { + impl From<$from> for $into_type { + fn from(err: $from) -> Self { + std::io::Error::from(err).into() + } + } + }; +} diff --git a/src/fetcher.rs b/src/fetcher.rs index 325e219..3bd97f5 100644 --- a/src/fetcher.rs +++ b/src/fetcher.rs @@ -5,10 +5,9 @@ use octocrab::{Octocrab, OctocrabBuilder}; use semver::Version; use crate::config::ApiConfig; +use crate::errors::fetcher::{FetchResult, FetcherError}; use crate::game_data::{Asset, Assets, GameRelease, Repo}; -type Result = std::result::Result; - pub struct Fetcher { octocrab: Octocrab, game_repo: Repo, @@ -19,18 +18,8 @@ pub struct Fetcher { struct ChecksumFetcher(reqwest::Client); -#[derive(Debug)] -pub enum FetcherError { - OctoError(octocrab::Error), - ReqwestError(reqwest::Error), - InvalidSha256(usize), - WrongChecksum, - NoReleaseFound, - InvalidVersion, -} - impl Fetcher { - pub fn from_config(config: &ApiConfig) -> Result { + pub fn from_config(config: &ApiConfig) -> FetchResult { let mut octocrab = OctocrabBuilder::default(); if let Some(github_pat) = &config.github_pat { octocrab = octocrab.personal_token(github_pat.unsecure().to_string()); @@ -49,7 +38,7 @@ impl Fetcher { self.octocrab.repos(repo.owner(), repo.repository()) } - pub async fn get_latest_game_release(&self) -> Result { + pub async fn get_latest_game_release(&self) -> FetchResult { let releases = self .on_repo(&self.game_repo) .releases() @@ -78,7 +67,7 @@ impl Fetcher { Ok((platform.to_string(), asset)) }) - .collect::>()?; + .collect::>()?; for (version, release) in versions_released { for ((platform, mut asset), sha256) in self @@ -108,7 +97,7 @@ impl Fetcher { } } - pub async fn get_latest_updater_release(&self) -> Result { + pub async fn get_latest_updater_release(&self) -> FetchResult { let last_release = self .on_repo(&self.updater_repo) .releases() @@ -128,7 +117,7 @@ impl Fetcher { Ok((platform.to_string(), asset)) }) - .collect::>() + .collect::>() } async fn get_assets_and_checksums<'a: 'b, 'b, A>( @@ -136,7 +125,7 @@ impl Fetcher { assets: A, version: &Version, binaries: Option<&Assets>, - ) -> impl Iterator)> + ) -> impl Iterator)> where A: IntoIterator, { @@ -169,7 +158,7 @@ impl ChecksumFetcher { Self(reqwest::Client::new()) } - async fn resolve(&self, asset: &Asset) -> Result { + async fn resolve(&self, asset: &Asset) -> FetchResult { let response = self .0 .get(format!("{}.sha256", asset.download_url)) @@ -180,7 +169,7 @@ impl ChecksumFetcher { self.parse_response(asset.name.as_str(), response.as_str()) } - fn parse_response(&self, asset_name: &str, response: &str) -> Result { + fn parse_response(&self, asset_name: &str, response: &str) -> FetchResult { let parts: Vec<_> = response.split_whitespace().collect(); if parts.len() != 2 { return Err(FetcherError::InvalidSha256(parts.len())); @@ -194,24 +183,6 @@ impl ChecksumFetcher { } } -impl From for FetcherError { - fn from(err: octocrab::Error) -> Self { - FetcherError::OctoError(err) - } -} - -impl From for FetcherError { - fn from(err: reqwest::Error) -> Self { - FetcherError::ReqwestError(err) - } -} - -impl From for FetcherError { - fn from(_: semver::Error) -> Self { - FetcherError::InvalidVersion - } -} - fn remove_game_suffix(asset_name: &str) -> &str { let platform = asset_name .find('.') diff --git a/src/game_connection.rs b/src/game_connection.rs new file mode 100644 index 0000000..df38a72 --- /dev/null +++ b/src/game_connection.rs @@ -0,0 +1,73 @@ +use actix_web::{post, web, HttpResponse, Responder}; +use deadpool_postgres::tokio_postgres::types::Type; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + app_data::AppData, + errors::api::{ErrorCode, RequestError, RouteError}, + game_connection_token::{ + GameConnectionToken, GameConnectionTokenPrivate, GamePlayerData, GameServerAddress, + }, + players::validate_player_token, +}; + +#[derive(Deserialize)] +struct GameConnectionParams { + token: String, +} + +#[derive(Serialize)] +struct GameConnectionResponse { + uuid: String, + nickname: String, +} + +#[post("/v1/game/connect")] +async fn route_game_connect( + app_data: web::Data, + pg_pool: web::Data, + params: web::Json, +) -> Result { + let pg_client = pg_pool.get().await?; + let player_id = validate_player_token(&pg_client, ¶ms.token).await?; + + let find_player_info = pg_client + .prepare_typed_cached( + "SELECT uuid, nickname FROM players WHERE id = $1", + &[Type::INT4], + ) + .await?; + + let player_result = pg_client.query(&find_player_info, &[&player_id]).await?; + if player_result.is_empty() { + return Err(RouteError::InvalidRequest(RequestError::new( + ErrorCode::AuthenticationInvalidToken, + "Invalid token".to_string(), + ))); + } + + let uuid: Uuid = player_result[0].get(0); + let nickname: String = player_result[0].get(1); + + let player_data = GamePlayerData::generate(uuid, nickname); + + let server_address = GameServerAddress { + address: app_data.config.game_server_address.clone(), + port: app_data.config.game_server_port, + }; + + let private_token = GameConnectionTokenPrivate::generate( + app_data.config.game_api_url.clone(), + app_data.config.game_api_token.clone(), + player_data, + ); + let token = GameConnectionToken::generate( + app_data.config.connection_token_key.into(), + app_data.config.game_api_token_duration, + server_address, + private_token, + ); + + Ok(HttpResponse::Ok().json(token)) +} diff --git a/src/game_connection_token.rs b/src/game_connection_token.rs new file mode 100644 index 0000000..8fcb873 --- /dev/null +++ b/src/game_connection_token.rs @@ -0,0 +1,174 @@ +use chacha20poly1305::{ + aead::{AeadCore, AeadMutInPlace, KeyInit, OsRng}, + XChaCha20Poly1305, +}; +use deku::prelude::*; +use serde::Serialize; +use serde_with::{base64::Base64, serde_as}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use uuid::Uuid; + +const XCHACHA20POLY1305_IETF_ABYTES: usize = 16; //< todo: Get it from chacha20poly1305::Tag + +#[serde_as] +#[derive(Debug, Serialize)] +pub struct GameEncryptionKeys { + #[serde_as(as = "Base64")] + pub client_to_server: chacha20poly1305::Key, + #[serde_as(as = "Base64")] + pub server_to_client: chacha20poly1305::Key, +} + +impl GameEncryptionKeys { + pub fn generate() -> Self { + Self { + client_to_server: XChaCha20Poly1305::generate_key(&mut OsRng), + server_to_client: XChaCha20Poly1305::generate_key(&mut OsRng), + } + } +} + +#[derive(Debug, Serialize)] +pub struct GameServerAddress { + pub address: String, + pub port: u16, +} + +#[derive(Debug, DekuWrite)] +#[deku(endian = "little")] +pub struct GameConnectionTokenAdditionalData { + pub token_version: u32, + pub expire_timestamp: u64, + #[deku(writer = "deku_helper_write_key(deku::writer, &self.client_to_server_key)")] + pub client_to_server_key: chacha20poly1305::Key, + #[deku(writer = "deku_helper_write_key(deku::writer, &self.server_to_client_key)")] + pub server_to_client_key: chacha20poly1305::Key, +} + +#[serde_as] +#[derive(Debug, Serialize)] +pub struct GameConnectionToken { + token_version: u32, + #[serde_as(as = "Base64")] + token_nonce: chacha20poly1305::XNonce, + creation_timestamp: u64, + expire_timestamp: u64, + encryption_keys: GameEncryptionKeys, + game_server: GameServerAddress, + #[serde_as(as = "Base64")] + private_token_data: Vec, +} + +impl GameConnectionToken { + pub fn generate( + token_key: chacha20poly1305::Key, + duration: Duration, + server_address: GameServerAddress, + private_token: GameConnectionTokenPrivate, + ) -> Self { + let timestamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap(); + + let encryption_keys = GameEncryptionKeys::generate(); + + let token_version = 1u32; + let expire_timestamp = (timestamp + duration).as_secs(); + + let additional_data = GameConnectionTokenAdditionalData { + token_version, + expire_timestamp, + client_to_server_key: encryption_keys.client_to_server, + server_to_client_key: encryption_keys.server_to_client, + }; + + let additional_data_bytes = additional_data.to_bytes().unwrap(); + + let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng); + + let mut private_token_bytes = private_token.to_bytes().unwrap(); + private_token_bytes.resize(private_token_bytes.len() + XCHACHA20POLY1305_IETF_ABYTES, 0); + + let mut cipher = XChaCha20Poly1305::new(&token_key); + cipher + .encrypt_in_place( + &nonce, + additional_data_bytes.as_slice(), + &mut private_token_bytes, + ) + .unwrap(); + + Self { + token_version, + token_nonce: nonce, + creation_timestamp: timestamp.as_secs(), + expire_timestamp: (timestamp + duration).as_secs(), + encryption_keys, + game_server: server_address, + private_token_data: private_token_bytes, + } + } +} + +#[derive(Debug, DekuWrite)] +#[deku(endian = "endian", ctx = "endian: deku::ctx::Endian")] +pub struct GamePlayerData { + #[deku(writer = "deku_helper_write_uuid(deku::writer, &self.uuid)")] + uuid: Uuid, + #[deku(writer = "deku_helper_write_str(deku::writer, &self.nickname)")] + nickname: String, +} + +impl GamePlayerData { + pub fn generate(uuid: Uuid, nickname: String) -> Self { + Self { uuid, nickname } + } +} + +#[derive(Debug, DekuWrite)] +#[deku(endian = "little")] +pub struct GameConnectionTokenPrivate { + #[deku(writer = "deku_helper_write_str(deku::writer, &self.api_token)")] + api_token: String, + #[deku(writer = "deku_helper_write_str(deku::writer, &self.api_url)")] + api_url: String, + player_data: GamePlayerData, +} + +impl GameConnectionTokenPrivate { + pub fn generate( + game_api_url: String, + game_api_token: String, + player_data: GamePlayerData, + ) -> Self { + Self { + api_token: game_api_token, + api_url: game_api_url, + player_data, + } + } +} + +fn deku_helper_write_key( + writer: &mut Writer, + value: &chacha20poly1305::Key, +) -> Result<(), DekuError> { + let str_bytes = value.as_slice(); + str_bytes.to_writer(writer, ()) +} + +fn deku_helper_write_str( + writer: &mut Writer, + value: &str, +) -> Result<(), DekuError> { + let str_bytes = value.as_bytes(); + let str_len = str_bytes.len() as u32; + str_len.to_writer(writer, ())?; + str_bytes.to_writer(writer, ()) +} + +fn deku_helper_write_uuid( + writer: &mut Writer, + value: &Uuid, +) -> Result<(), DekuError> { + let str = value.to_bytes_le(); + str.to_writer(writer, ()) +} diff --git a/src/main.rs b/src/main.rs index 8ca638c..ef60df4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,117 +1,101 @@ -use std::collections::HashMap; +use actix_governor::{Governor, GovernorConfig, GovernorConfigBuilder}; +use actix_web::{middleware, web, App, HttpServer}; +use cached::TimedCache; +use confy::ConfyError; +use game_connection::route_game_connect; +use players::route_player_auth; use std::sync::Mutex; -use actix_web::{get, middleware, web, App, HttpServer}; -use actix_web::{HttpResponse, Responder}; -use cached::{CachedAsync, TimedCache}; -use game_data::{Asset, GameRelease}; -use serde::Deserialize; - +use crate::app_data::AppData; use crate::config::ApiConfig; use crate::fetcher::Fetcher; -use crate::game_data::GameVersion; +use crate::players::route_player_create; +use crate::version::route_game_version; +mod app_data; mod config; +mod errors; mod fetcher; +mod game_connection; +mod game_connection_token; mod game_data; +mod players; +mod version; -#[derive(Deserialize)] -struct VersionQuery { - platform: String, -} - -struct AppData { - cache: Mutex>, - config: ApiConfig, - fetcher: Fetcher, -} - -#[derive(Clone)] -enum CachedReleased { - Updater(HashMap), - Game(GameRelease), -} - -#[get("/game_version")] -async fn game_version( - app_data: web::Data, - ver_query: web::Query, -) -> impl Responder { - let AppData { - cache, - config, - fetcher, - } = app_data.as_ref(); - let mut cache = cache.lock().unwrap(); - - // TODO: remove .cloned - let Ok(CachedReleased::Updater(updater_release)) = cache - .try_get_or_set_with("latest_updater_release", || async { - fetcher - .get_latest_updater_release() - .await - .map(CachedReleased::Updater) - }) - .await - .cloned() - else { - return HttpResponse::InternalServerError().finish(); - }; - - // TODO: remove .cloned - let Ok(CachedReleased::Game(game_release)) = cache - .try_get_or_set_with("latest_game_release", || async { - fetcher - .get_latest_game_release() - .await - .map(CachedReleased::Game) - }) - .await - .cloned() - else { - return HttpResponse::InternalServerError().finish(); - }; +use tokio_postgres::NoTls; - let updater_filename = format!("{}_{}", ver_query.platform, config.updater_filename); +fn setup_pg_pool(api_config: &ApiConfig) -> deadpool_postgres::Pool { + use deadpool_postgres::{Config, ManagerConfig, RecyclingMethod, Runtime}; - let (Some(updater), Some(binary)) = (updater_release.get(&updater_filename), game_release.binaries.get(&ver_query.platform)) else { - eprintln!( - "no updater or game binary release found for platform {}", - ver_query.platform - ); - return HttpResponse::NotFound().finish(); - }; + let mut pg_config = Config::new(); + pg_config.host = Some(api_config.db_host.clone()); + pg_config.password = Some(api_config.db_password.unsecure().to_string()); + pg_config.user = Some(api_config.db_user.clone()); + pg_config.dbname = Some(api_config.db_database.clone()); + pg_config.manager = Some(ManagerConfig { + recycling_method: RecyclingMethod::Fast, + }); - HttpResponse::Ok().json(web::Json(GameVersion { - assets: game_release.assets, - assets_version: game_release.assets_version.to_string(), - binaries: binary.clone(), - updater: updater.clone(), - version: game_release.version.to_string(), - })) + pg_config.create_pool(Some(Runtime::Tokio1), NoTls).unwrap() } #[actix_web::main] async fn main() -> Result<(), std::io::Error> { - let config: ApiConfig = confy::load_path("tsom_api_config.toml").unwrap(); + let config = match confy::load_path("tsom_api_config.toml") { + Ok(config) => config, + Err(ConfyError::BadTomlData(err)) => panic!( + "an error occured on the parsing of the file tsom_api_config.toml:\n{}", + err.message() + ), + Err(ConfyError::GeneralLoadError(err)) => panic!( + "an error occured on the loading of the file tsom_api_config.toml:\n{}", + err.kind() + ), + Err(_) => panic!( + "wrong data in the file, failed to load config, please check tsom_api_config.toml" + ), + }; let fetcher = Fetcher::from_config(&config).unwrap(); + let pg_pool = web::Data::new(setup_pg_pool(&config)); + + // Try to connect to database + let test_client = pg_pool.get().await.expect("failed to connect to database"); + drop(test_client); + std::env::set_var("RUST_LOG", "info,actix_web=info"); env_logger::init(); let bind_address = format!("{}:{}", config.listen_address, config.listen_port); let data_config = web::Data::new(AppData { - cache: Mutex::new(TimedCache::with_lifespan(config.cache_lifespan)), // 5min + cache: Mutex::new(TimedCache::with_lifespan(config.cache_lifespan.as_secs())), // 5min config, fetcher, }); + let governor_conf = GovernorConfig::default(); + + let player_create_governor_conf = GovernorConfigBuilder::default() + .per_second(10) + .burst_size(1) + .finish() + .unwrap(); + HttpServer::new(move || { App::new() .wrap(middleware::Logger::default()) + .wrap(Governor::new(&governor_conf)) .app_data(data_config.clone()) - .service(game_version) + .app_data(pg_pool.clone()) + .service(route_game_version) + .service(route_player_auth) + .service(route_game_connect) + .service( + web::scope("") + .wrap(Governor::new(&player_create_governor_conf)) + .service(route_player_create), + ) }) .bind(bind_address)? .run() diff --git a/src/players.rs b/src/players.rs new file mode 100644 index 0000000..dea3b4f --- /dev/null +++ b/src/players.rs @@ -0,0 +1,193 @@ +use actix_web::{post, web, HttpResponse, Responder}; +use base64::prelude::*; +use base64::Engine; +use deadpool_postgres::tokio_postgres::types::Type; + +use rand_core::{OsRng, RngCore}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::app_data::AppData; +use crate::errors::api::{ErrorCode, RequestError, RouteError}; + +pub async fn validate_player_token( + pg_client: &deadpool_postgres::Client, + token: &str, +) -> Result { + if token.is_empty() || token.len() > 64 { + return Err(RouteError::InvalidRequest(RequestError::new( + ErrorCode::AuthenticationInvalidToken, + "Invalid token".to_string(), + ))); + } + + let find_token_statement = pg_client + .prepare_typed_cached( + "SELECT player_id FROM player_tokens WHERE token = $1", + &[Type::VARCHAR], + ) + .await?; + + let token_result = pg_client.query(&find_token_statement, &[&token]).await?; + if token_result.is_empty() { + return Err(RouteError::InvalidRequest(RequestError::new( + ErrorCode::AuthenticationInvalidToken, + "Invalid token".to_string(), + ))); + } + + Ok(token_result[0].get(0)) +} + +#[derive(Deserialize)] +struct CreatePlayerParams { + nickname: String, +} + +#[derive(Serialize)] +struct CreatePlayerResponse { + uuid: String, + token: String, +} + +#[post("/v1/players")] +async fn route_player_create( + app_data: web::Data, + pg_pool: web::Data, + params: web::Json, +) -> Result { + let nickname = params.nickname.trim(); + + if nickname.is_empty() { + return Err(RouteError::InvalidRequest(RequestError::new( + ErrorCode::NicknameEmpty, + "Nickname cannot be empty".to_string(), + ))); + } + + if nickname.len() > app_data.config.player_nickname_maxlength { + return Err(RouteError::InvalidRequest(RequestError::new( + ErrorCode::NicknameToolong, + format!( + "Nickname size exceeds maximum size of {}", + app_data.config.player_nickname_maxlength + ), + ))); + } + + if !app_data.config.player_allow_non_ascii + && !nickname + .chars() + .all(|x| x.is_ascii_alphanumeric() || x == ' ' || x == '_') + { + return Err(RouteError::InvalidRequest(RequestError::new( + ErrorCode::NicknameForbiddenCharacters, + "Nickname can only have ascii characters".to_string(), + ))); + } + + let uuid = Uuid::new_v4(); + + let mut pg_client = pg_pool.get().await?; + + let create_player_statement = pg_client + .prepare_typed_cached( + "INSERT INTO players(uuid, creation_time, nickname) VALUES($1, NOW(), $2) RETURNING id", + &[Type::UUID, Type::VARCHAR], + ) + .await?; + let create_token_statement = pg_client + .prepare_typed_cached( + "INSERT INTO player_tokens(token, player_id) VALUES($1, $2)", + &[Type::VARCHAR, Type::INT4], + ) + .await?; + + let mut key = [0u8; 32]; + OsRng.try_fill_bytes(&mut key)?; + + let token = BASE64_STANDARD.encode(key); + + let transaction = pg_client.transaction().await?; + let result = transaction + .query(&create_player_statement, &[&uuid, &nickname]) + .await?; + let player_id: i32 = result[0].get(0); + transaction + .query(&create_token_statement, &[&token, &player_id]) + .await?; + transaction.commit().await?; + + pg_client + .query(&create_player_statement, &[&uuid, &nickname]) + .await?; + + Ok(HttpResponse::Ok().json(CreatePlayerResponse { + uuid: uuid.to_string(), + token: token.to_string(), + })) +} + +#[derive(Deserialize)] +struct AuthenticationParams { + token: String, +} + +#[derive(Serialize)] +struct AuthenticationResponse { + uuid: String, + nickname: String, +} + +#[post("/v1/player/auth")] +async fn route_player_auth( + pg_pool: web::Data, + params: web::Json, +) -> Result { + let pg_client = pg_pool.get().await?; + let player_id = validate_player_token(&pg_client, ¶ms.token).await?; + + let find_player_info = pg_client + .prepare_typed_cached( + "SELECT uuid, nickname FROM players WHERE id = $1", + &[Type::INT4], + ) + .await?; + + let player_result = pg_client.query(&find_player_info, &[&player_id]).await?; + if player_result.is_empty() { + return Err(RouteError::InvalidRequest(RequestError::new( + ErrorCode::AuthenticationInvalidToken, + "Invalid token".to_string(), + ))); + } + + // Update last connection time in a separate task as its result won't affect the route + tokio::spawn(async move { + match pg_client + .prepare_typed_cached( + "UPDATE players SET last_connection_time = NOW() WHERE id = $1", + &[Type::INT4], + ) + .await + { + Ok(statement) => { + let res = pg_client.query(&statement, &[&player_id]).await; + if let Err(err) = res { + eprintln!("failed to update player {player_id} connection time: {err}"); + } + } + Err(err) => { + eprintln!("failed to update player {player_id} connection time (failed to prepare query): {err}"); + } + } + }); + + let uuid: Uuid = player_result[0].get(0); + let nickname: String = player_result[0].get(1); + + Ok(HttpResponse::Ok().json(AuthenticationResponse { + uuid: uuid.to_string(), + nickname, + })) +} diff --git a/src/version.rs b/src/version.rs new file mode 100644 index 0000000..b8714ab --- /dev/null +++ b/src/version.rs @@ -0,0 +1,82 @@ +use std::collections::HashMap; + +use actix_web::{get, web}; +use actix_web::{HttpResponse, Responder}; +use cached::CachedAsync; +use serde::Deserialize; + +use crate::app_data::AppData; +use crate::game_data::{Asset, GameRelease, GameVersion}; + +#[derive(Deserialize)] +struct VersionQuery { + platform: String, +} + +#[derive(Clone)] +pub(crate) enum CachedReleased { + Updater(HashMap), + Game(GameRelease), +} + +#[get("/game_version")] +async fn route_game_version( + app_data: web::Data, + ver_query: web::Query, +) -> impl Responder { + let AppData { + cache, + config, + fetcher, + } = app_data.as_ref(); + let mut cache = cache.lock().unwrap(); + + // TODO: remove .cloned + let Ok(CachedReleased::Updater(updater_release)) = cache + .try_get_or_set_with("latest_updater_release", || async { + fetcher + .get_latest_updater_release() + .await + .map(CachedReleased::Updater) + }) + .await + .cloned() + else { + return HttpResponse::InternalServerError().finish(); + }; + + // TODO: remove .cloned + let Ok(CachedReleased::Game(game_release)) = cache + .try_get_or_set_with("latest_game_release", || async { + fetcher + .get_latest_game_release() + .await + .map(CachedReleased::Game) + }) + .await + .cloned() + else { + return HttpResponse::InternalServerError().finish(); + }; + + let updater_filename = format!("{}_{}", ver_query.platform, config.updater_filename); + + let (Some(updater), Some(binary)) = ( + updater_release.get(&updater_filename), + game_release.binaries.get(&ver_query.platform), + ) else { + eprintln!( + "no updater or game binary release found for platform {}", + ver_query.platform + ); + return HttpResponse::NotFound().finish(); + }; + + HttpResponse::Ok().json(web::Json(GameVersion { + assets: game_release.assets, + assets_version: game_release.assets_version.to_string(), + binaries: binary.clone(), + updater: updater.clone(), + version: game_release.version.to_string(), + })) +} diff --git a/tsom_api_config.toml.default b/tsom_api_config.toml.default index b8101f3..9127519 100644 --- a/tsom_api_config.toml.default +++ b/tsom_api_config.toml.default @@ -4,5 +4,20 @@ repo_owner = "DigitalPulseSoftware" game_repository = "ThisSpaceOfMine" updater_repository = "ThisUpdaterOfMine" updater_filename = "this_updater_of_mine" -cache_lifespan = 300 # duration from second +cache_lifespan = 300 # duration in seconds # github_pat = "***" +db_host = "localhost" +db_user = "tsom" +db_password = "" +db_database = "tsom" +player_nickname_maxlength = 16 +player_allow_non_ascii = false + +connection_token_key = "123456" + +game_api_url = "http://localhost:14770" +game_api_token = "123456" +game_api_token_duration = 300 # duration in seconds + +game_server_address = "::1" +game_server_port = 29536