From b3347a6e533ac2a83ec32d55ba2c09cfd2f8328a Mon Sep 17 00:00:00 2001 From: Neshura Date: Tue, 12 Dec 2023 20:39:15 +0100 Subject: [PATCH] Add new "v1" API, rename "v2" and "v3" to legacy --- src/main.rs | 75 ++++++++-- src/v1/mod.rs | 360 ++++++++++++++++++++++++++++++++++++++++++++++ src/v1/schemas.rs | 65 +++++++++ 3 files changed, 487 insertions(+), 13 deletions(-) create mode 100644 src/v1/mod.rs create mode 100644 src/v1/schemas.rs diff --git a/src/main.rs b/src/main.rs index 239a703..23b583d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,15 +25,21 @@ macro_rules! api_base { }; } +macro_rules! api_base_1 { + () => { + "/api/v1" + }; +} + macro_rules! api_base_2 { () => { - "/api/v2" + "/api/v2-l" }; } macro_rules! api_base_3 { () => { - "/api/v3" + "/api/v3-l" }; } @@ -208,9 +214,27 @@ async fn main() -> Result<()> { )] struct ApiDocV3; + #[derive(OpenApi)] + #[openapi( + paths( + v1::get_user, + v1::create_user, + v1::update_user, + v1::delete_user + ), + components(schemas( + v1::schemas::GetUserParams, + v1::schemas::UpdateUserParams, + v1::schemas::DeleteUserParams, + v1::schemas::User + )) + )] + struct ApiDocV1; + let openapi_urls = vec![ - Url::new("v2", concat!(api_base_2!(), "/openapi.json")), - Url::new("v3", concat!(api_base_3!(), "/openapi.json")), + Url::new("v1", concat!(api_base_1!(), "/openapi.json")), + Url::new("v2-L", concat!(api_base_2!(), "/openapi.json")), + Url::new("v3-L", concat!(api_base_3!(), "/openapi.json")), ]; let is_alive: Arc = Arc::new(AtomicBool::new(true)); @@ -218,15 +242,31 @@ async fn main() -> Result<()> { loop { let db_auth_tokens = config.auth.clone(); let pool = PgPool::connect(dotenv!("DATABASE_URL")).await.unwrap(); + let pool_copy = pool.clone(); + let shutdown_clone = shutdown.clone(); let swagger_config = Config::new(openapi_urls.clone()); - let mut openapi_v2 = ApiDocV2::openapi(); - openapi_v2.info.title = "Chellaris Rust API".to_string(); - - let mut openapi_v3 = ApiDocV3::openapi(); - openapi_v3.info.title = "Chellaris Rust API".to_string(); + let mut openapi_v1 = ApiDocV1::openapi(); + openapi_v1.info.title = "Chellaris Rust API v1".to_string(); + + let mut openapi_v2_l = ApiDocV2::openapi(); + openapi_v2_l.info.title = "Legacy Chellaris Rust API v2".to_string(); + + let mut openapi_v3_l = ApiDocV3::openapi(); + openapi_v3_l.info.title = "Legacy Chellaris Rust API v3".to_string(); + + println!("Serving API on: "); + println!(" -> http://[{}]:{}", Ipv6Addr::UNSPECIFIED, 8080); + println!(" -> http://{}:{}", Ipv4Addr::UNSPECIFIED, 8080); + + let watchdog_thread = tokio::spawn(async move { postgres_watchdog(pool_copy, shutdown_clone) }); + tokio::spawn(async move { + actix_web::rt::signal::ctrl_c().await.unwrap(); + println!("Ctrl-C received, killing Server"); + abort() + }); let server = HttpServer::new(move || { App::new() @@ -257,17 +297,26 @@ async fn main() -> Result<()> { .service(v3::delete_empire) .service(v3::get_ethics) .service(v3::get_phenotypes) + // API v1 Endpoints + .service(v1::get_user) + .service(v1::create_user) + .service(v1::update_user) + .service(v1::delete_user) // Swagger UI .service( SwaggerUi::new(concat!(api_base!(), "/swagger/{_:.*}")) .urls(vec![ ( - Url::new("v2", concat!(api_base_2!(), "/openapi.json")), - openapi_v2.clone(), + Url::new("v1", concat!(api_base_1!(), "/openapi.json")), + openapi_v1.clone(), ), ( - Url::new("v3", concat!(api_base_3!(), "/openapi.json")), - openapi_v3.clone(), + Url::new("v2-l", concat!(api_base_2!(), "/openapi.json")), + openapi_v2_l.clone(), + ), + ( + Url::new("v3-l", concat!(api_base_3!(), "/openapi.json")), + openapi_v3_l.clone(), ), ]) .config(swagger_config.clone()), diff --git a/src/v1/mod.rs b/src/v1/mod.rs new file mode 100644 index 0000000..ad7c47a --- /dev/null +++ b/src/v1/mod.rs @@ -0,0 +1,360 @@ +use crate::{db, AppState}; +use actix_web::web::Json; +use actix_web::{delete, get, post, put, web, HttpRequest, HttpResponse, Responder}; +use std::collections::HashMap; +use std::ops::Deref; +use rand::distributions::Alphanumeric; +use rand::{Rng, thread_rng}; +use sqlx::QueryBuilder; + +pub(crate) mod schemas; + +fn get_auth_header<'a>(req: &'a HttpRequest) -> Option<&'a str> { + req.headers().get("x-api-key")?.to_str().ok() +} + +async fn verify_user_auth(data: &web::Data, auth_token: &str, user_token: &str, table: schemas::TablePermission, permissions_only: bool) -> bool { + let user: db::schemas::User = match sqlx::query_as!( + db::schemas::User, + "SELECT * FROM public.users WHERE token = $1", + auth_token + ) + .fetch_one(&data.db) + .await + { + Ok(data) => data, + Err(_) => return false, + }; + + if user.token == user_token && !permissions_only { + return true; + } + else { + match table { + schemas::TablePermission::Game => { + return user.data_permissions; + }, + schemas::TablePermission::Empire => { + return user.empire_permissions; + }, + schemas::TablePermission::Data => { + return user.data_permissions; + }, + schemas::TablePermission::User => { + return user.user_permissions; + }, + } + } +} + +// User Endpoints +#[utoipa::path( + request_body = schemas::GetUserParams, + responses( + (status = 200, description = "OK", body = User), + (status = 403, description = "Unauthorized"), + (status = 500, description = "Internal Server Error") + ), + security( + ("api_key" = []) + ), +)] +#[get("/api/v1/user")] +async fn get_user( + data: web::Data, + params: web::Json, + req: HttpRequest, +) -> impl Responder { + let auth_header = get_auth_header(&req); + let params = params.into_inner(); + + let auth_token: String; + + match auth_header { + Some(token) => auth_token = token.to_string(), + None => return HttpResponse::Unauthorized().finish(), + }; + + let auth = verify_user_auth(&data, &auth_token, ¶ms.user_token, schemas::TablePermission::User, false).await; + + if auth { + let user: db::schemas::User = match sqlx::query_as!( + db::schemas::User, + "SELECT * FROM public.users WHERE token = $1", + params.user_token + ) + .fetch_one(&data.db) + .await + { + Ok(data) => data, + Err(_) => return HttpResponse::InternalServerError().finish(), + }; + + let mut permissions: HashMap = HashMap::new(); + + permissions.insert("game_permissions".to_string(), user.game_permissions); + permissions.insert("empire_permissions".to_string(), user.empire_permissions); + permissions.insert("data_permissions".to_string(), user.data_permissions); + permissions.insert("user_permissions".to_string(), user.user_permissions); + + let return_data = schemas::User { + user_token: user.token, + discord_handle: user.discord_id, + profile_picture: user.picture_url, + permissions: permissions + }; + + return HttpResponse::Ok().json(return_data); + } + else { + return HttpResponse::Unauthorized().finish(); + } +} + +#[utoipa::path( + responses( + (status = 200, description = "OK", body = User), + (status = 500, description = "Internal Server Error") + ), +)] +#[post("/api/v1/user")] +pub(crate) async fn create_user( + data: web::Data, +) -> impl Responder { + let user: db::schemas::User; + + let mut rng = thread_rng(); + + let user_tokens = match sqlx::query_scalar!( + "SELECT token FROM public.users" + ) + .fetch_all(&data.db) + .await + { + Ok(data) => data, + Err(_) => return HttpResponse::InternalServerError().finish(), + }; + + let new_token: String; + + loop { + let mut chars: String = (0..6).map(|_| rng.sample(Alphanumeric) as char).collect(); + chars = chars.to_uppercase(); + if !user_tokens.contains(&chars) { + new_token = chars; + break; + } + else { + println!("looping"); + } + } + + user = match sqlx::query_as!( + db::schemas::User, + "INSERT INTO public.users(token, game_permissions, empire_permissions, data_permissions, user_permissions) VALUES ($1, $2, $3, $4, $5) RETURNING * ", + new_token, + false, + false, + false, + false + + ) + .fetch_one(&data.db) + .await + { + Ok(data) => data, + Err(_) => return HttpResponse::InternalServerError().finish(), + }; + + return HttpResponse::Ok().json(user) +} + +#[utoipa::path( + request_body = UpdateUserParams, + responses( + (status = 200, description = "OK"), + (status = 403, description = "Unauthorized"), + (status = 500, description = "Internal Server Error") + ), + security( + ("api_key" = []) + ), +)] +#[put("/api/v1/user")] +pub(crate) async fn update_user( + data: web::Data, + params: web::Json, + req: HttpRequest, +) -> impl Responder { + let auth_header = get_auth_header(&req); + let params = params.into_inner(); + + let auth_token: String; + + match auth_header { + Some(token) => auth_token = token.to_string(), + None => return HttpResponse::Unauthorized().finish(), + }; + + let mut elevated_auth = false; + if params.permissions["game_permissions"] || params.permissions["empire_permissions"] || params.permissions["data_permissions"] || params.permissions["user_permissions"] { + elevated_auth = true; + } + + let auth = verify_user_auth(&data, &auth_token, ¶ms.user_token, schemas::TablePermission::User, elevated_auth).await; + + // SQL Queries + // TODO: Optimize by utilizing some SQL magic, for now this has to do + if auth { + let user: db::schemas::User; + + let mut user_query = QueryBuilder::::new("UPDATE public.users SET "); + let mut user_query_separated = user_query.separated(", "); + let mut any_param_present = false; + + if let Some(discord_handle) = params.discord_handle { + user_query_separated.push(" discord_id = ").push_bind_unseparated(discord_handle); + any_param_present = true; + } + + if let Some(profile_picture) = params.profile_picture { + user_query_separated.push(" picture_url = "); + match any_param_present { + true => user_query_separated.push_bind(profile_picture), + false => user_query_separated.push_bind_unseparated(profile_picture) + }; + any_param_present = true; + } + + for (entry, value) in params.permissions.iter() { + match entry.deref() { + "game_permissions" => { + user_query_separated.push( " game_permissions = "); + match any_param_present { + true => user_query_separated.push_bind(value), + false => user_query_separated.push_bind_unseparated(value) + }; + any_param_present = true; + }, + "empire_permissions" => { + user_query_separated.push( " empire_permissions = "); + match any_param_present { + true => user_query_separated.push_bind(value), + false => user_query_separated.push_bind_unseparated(value) + }; + any_param_present = true; + }, + "data_permissions" => { + user_query_separated.push( " data_permissions = "); + match any_param_present { + true => user_query_separated.push_bind(value), + false => user_query_separated.push_bind_unseparated(value) + }; + any_param_present = true; + }, + "user_permissions" => { + user_query_separated.push( " user_permissions = "); + match any_param_present { + true => user_query_separated.push_bind(value), + false => user_query_separated.push_bind_unseparated(value) + }; + any_param_present = true; + }, + _ => {} + } + } + + if any_param_present { + user_query_separated.push_unseparated(" WHERE token = ").push_bind_unseparated(params.user_token); + user_query_separated.push_unseparated(" RETURNING *"); + + user = match user_query + .build_query_as::() + .fetch_one(&data.db) + .await + { + Ok(data) => data, + Err(_) => return HttpResponse::InternalServerError().finish(), + }; + } else { + user = match sqlx::query_as!( + db::schemas::User, + "SELECT * FROM public.users WHERE token = $1", + params.user_token + ) + .fetch_one(&data.db) + .await + { + Ok(data) => data, + Err(_) => return HttpResponse::InternalServerError().finish(), + }; + } + + let mut permissions: HashMap = HashMap::new(); + permissions.insert("game_permissions".to_string(), user.game_permissions); + permissions.insert("empire_permissions".to_string(), user.empire_permissions); + permissions.insert("data_permissions".to_string(), user.data_permissions); + permissions.insert("user_permissions".to_string(), user.user_permissions); + + let return_data = schemas::User { + user_token: user.token, + discord_handle: user.discord_id, + profile_picture: user.picture_url, + permissions: permissions + }; + return HttpResponse::Ok().json(return_data); + } else { + return HttpResponse::Unauthorized().finish(); + } +} + +#[utoipa::path( + request_body = schemas::DeleteUserParams, + responses( + (status = 200, description = "OK"), + (status = 403, description = "Unauthorized"), + (status = 500, description = "Internal Server Error") + ), + security( + ("api_key" = []) + ), +)] +#[delete("/api/v1/user")] +pub(crate) async fn delete_user( + data: web::Data, + params: web::Query, + req: HttpRequest, +) -> impl Responder { + let auth_header = get_auth_header(&req); + let params = params.into_inner(); + + let auth_token: String; + + match auth_header { + Some(token) => auth_token = token.to_string(), + None => return HttpResponse::Unauthorized().finish(), + }; + + let auth = verify_user_auth(&data, &auth_token, ¶ms.user_token, schemas::TablePermission::User, false).await; + + // SQL Queries + // TODO: Optimize by utilizing some SQL magic, for now this has to do + if auth { + match sqlx::query!( + "DELETE FROM public.users WHERE token = $1", + params.user_token + ) + .execute(&data.db) + .await + { + Ok(_) => {} + Err(e) => { + return HttpResponse::InternalServerError().finish(); + } + }; + + return HttpResponse::Ok().into(); + } else { + return HttpResponse::Unauthorized().finish(); + } +} diff --git a/src/v1/schemas.rs b/src/v1/schemas.rs new file mode 100644 index 0000000..e561cce --- /dev/null +++ b/src/v1/schemas.rs @@ -0,0 +1,65 @@ +use std::collections::HashMap; +use serde::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; +use crate::v3::schemas::ChellarisGameLegacy; + +// DB Permission Enums + +pub enum TablePermission { + Game, + Empire, + Data, + User +} + +// User Structs + +#[derive(Serialize, ToSchema, Debug)] +pub struct User { + #[schema(example = "abcdef")] + pub user_token: String, + #[schema(example = "discorduser")] + pub discord_handle: Option, + #[schema(example = "/assets/avatars/124677612.png")] + pub profile_picture: Option, + #[schema(example = "\ + {\ + [\"game_permissions\"]: true, + [\"empire_permissions\"]: true, + [\"data_permissions\"]: false, + [\"user_permissions\"]: false, + }\ + ")] + pub permissions: HashMap, +} + +#[derive(Serialize, Deserialize, ToSchema, Debug)] +pub struct GetUserParams { + #[schema(example = "abcdef")] + pub user_token: String, +} + +#[derive(Serialize, Deserialize, ToSchema, Debug)] +pub struct UpdateUserParams { + #[schema(example = "abcdef")] + pub user_token: String, + #[schema(example = "discorduser")] + pub discord_handle: Option, + #[schema(example = "/assets/avatars/124677612.png")] + pub profile_picture: Option, + #[schema(example = "\ + {\ + [\"game_permissions\"]: true, + [\"empire_permissions\"]: true, + [\"data_permissions\"]: false, + [\"user_permissions\"]: false, + }\ + ")] + pub permissions: HashMap, +} + +#[derive(Serialize, Deserialize, ToSchema, Debug)] +pub struct DeleteUserParams { + #[schema(example = "abcdef")] + pub user_token: String, +} \ No newline at end of file