From e62ff9aa7a418faa1f5c3e56238a8c784168be45 Mon Sep 17 00:00:00 2001 From: Savanni D'Gerinel Date: Sun, 22 Dec 2024 09:17:00 -0500 Subject: [PATCH 01/23] Check username and password --- Cargo.lock | 59 +++++++++++++++++++ visions/server/Cargo.toml | 1 + visions/server/src/core.rs | 9 +++ visions/server/src/database.rs | 55 +++++++++++++++++ visions/server/src/handlers.rs | 22 +++++++ visions/server/src/main.rs | 55 ++++++++++++----- visions/server/src/types.rs | 3 + visions/ui/src/App.tsx | 2 +- visions/ui/src/client.ts | 6 ++ .../providers/StateProvider/StateProvider.tsx | 9 +++ 10 files changed, 204 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4ac8da4..075a08b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -932,6 +932,19 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "env_logger" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580" +dependencies = [ + "humantime", + "is-terminal", + "log", + "regex", + "termcolor", +] + [[package]] name = "equivalent" version = "1.0.1" @@ -1882,6 +1895,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + [[package]] name = "hyper" version = "0.14.30" @@ -2049,6 +2068,17 @@ version = "2.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddc24109865250148c2e0f3d25d4f0f479571723792d3802153c60922a4fb708" +[[package]] +name = "is-terminal" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261f68e344040fbd0edea105bef17c66edf46f984ddb1115b775ce31be948f4b" +dependencies = [ + "hermit-abi 0.4.0", + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -2824,6 +2854,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "pretty_env_logger" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "865724d4dbe39d9f3dd3b52b88d859d66bcb2d6a0acfd5ea68a65fb66d4bdc1c" +dependencies = [ + "env_logger", + "log", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -3813,6 +3853,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "1.0.64" @@ -4294,6 +4343,7 @@ dependencies = [ "lazy_static", "mime", "mime_guess", + "pretty_env_logger", "result-extended", "rusqlite", "rusqlite_migration", @@ -4476,6 +4526,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/visions/server/Cargo.toml b/visions/server/Cargo.toml index 0d02396..c9339a8 100644 --- a/visions/server/Cargo.toml +++ b/visions/server/Cargo.toml @@ -15,6 +15,7 @@ include_dir = { version = "0.7.4" } lazy_static = { version = "1.5.0" } mime = { version = "0.3.17" } mime_guess = { version = "2.0.5" } +pretty_env_logger = "0.5.0" result-extended = { path = "../../result-extended" } rusqlite = { version = "0.32.1" } rusqlite_migration = { version = "1.3.1", features = ["from-directory"] } diff --git a/visions/server/src/core.rs b/visions/server/src/core.rs index 79fbda9..2593128 100644 --- a/visions/server/src/core.rs +++ b/visions/server/src/core.rs @@ -220,6 +220,15 @@ impl Core { Err(err) => fatal(err), } } + + pub async fn auth(&self, username: String, password: String) -> ResultExt { + let state = self.0.write().await; + match state.db.user_by_username(username).await { + Ok(Some(row)) if (row.password == password) => ok(row.id), + Ok(_) => error(AppError::AuthFailed), + Err(err) => fatal(err), + } + } } #[cfg(test)] diff --git a/visions/server/src/database.rs b/visions/server/src/database.rs index 25b27b7..9e3502e 100644 --- a/visions/server/src/database.rs +++ b/visions/server/src/database.rs @@ -26,6 +26,7 @@ enum Request { Charsheet(CharacterId), Games, User(UserId), + UserByUsername(String), Users, SaveUser(Option, String, String, bool, bool), } @@ -180,6 +181,8 @@ pub struct CharsheetRow { pub trait Database: Send + Sync { async fn user(&mut self, _: UserId) -> Result, FatalError>; + async fn user_by_username(&self, _: String) -> Result, FatalError>; + async fn save_user( &mut self, user_id: Option, @@ -290,6 +293,31 @@ impl DiskDb { } } + fn user_by_username(&self, username: String) -> Result, FatalError> { + let mut stmt = self + .conn + .prepare("SELECT uuid, name, password, admin, enabled FROM users WHERE name=?") + .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; + let items: Vec = stmt + .query_map([username.as_str()], |row| { + Ok(UserRow { + id: row.get(0).unwrap(), + name: row.get(1).unwrap(), + password: row.get(2).unwrap(), + admin: row.get(3).unwrap(), + enabled: row.get(4).unwrap(), + }) + }) + .unwrap() + .collect::, rusqlite::Error>>() + .unwrap(); + match &items[..] { + [] => Ok(None), + [item] => Ok(Some(item.clone())), + _ => Err(FatalError::NonUniqueDatabaseKey(username.to_owned())), + } + } + fn users(&self) -> Result, FatalError> { let mut stmt = self .conn @@ -448,6 +476,13 @@ async fn db_handler(db: DiskDb, requestor: Receiver) { err => panic!("{:?}", err), } } + Request::UserByUsername(username) => { + let user = db.user_by_username(username); + match user { + Ok(user) => tx.send(DatabaseResponse::User(user)).await.unwrap(), + err => panic!("{:?}", err), + } + } Request::SaveUser(user_id, username, password, admin, enabled) => { let user_id = db.save_user(user_id, username.as_ref(), password.as_ref(), admin, enabled); match user_id { @@ -514,6 +549,26 @@ impl Database for DbConn { } } + async fn user_by_username(&self, username: String) -> Result, FatalError> { + let (tx, rx) = bounded::(1); + + let request = DatabaseRequest { + tx, + req: Request::UserByUsername(username), + }; + + match self.conn.send(request).await { + Ok(()) => (), + Err(_) => return Err(FatalError::DatabaseConnectionLost), + }; + + match rx.recv().await { + Ok(DatabaseResponse::User(user)) => Ok(user), + Ok(_) => Err(FatalError::MessageMismatch), + Err(_) => Err(FatalError::DatabaseConnectionLost), + } + } + async fn save_user( &mut self, user_id: Option, diff --git a/visions/server/src/handlers.rs b/visions/server/src/handlers.rs index 4392f3a..a3a3754 100644 --- a/visions/server/src/handlers.rs +++ b/visions/server/src/handlers.rs @@ -3,6 +3,7 @@ use std::future::Future; use futures::{SinkExt, StreamExt}; use result_extended::{error, ok, return_error, ResultExt}; use serde::{Deserialize, Serialize}; +use typeshare::typeshare; use warp::{http::Response, http::StatusCode, reply::Reply, ws::Message}; use crate::{ @@ -255,3 +256,24 @@ pub async fn handle_set_admin_password(core: Core, password: String) -> impl Rep }) .await } + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[typeshare] +pub struct AuthRequest { + username: String, + password: String, +} + +pub async fn handle_auth(core: Core, auth_request: AuthRequest) -> impl Reply { + handler(async move { + let userid = return_error!(core.auth(auth_request.username, auth_request.password).await); + + ok(Response::builder() + .header("Access-Control-Allow-Origin", "*") + .header("Access-Control-Allow-Methods", "*") + .header("Access-Control-Allow-Headers", "content-type") + .header("Content-Type", "application/json") + .body(serde_json::to_vec(&userid).unwrap()) + .unwrap()) + }).await +} diff --git a/visions/server/src/main.rs b/visions/server/src/main.rs index 894e2cc..f4e4fa4 100644 --- a/visions/server/src/main.rs +++ b/visions/server/src/main.rs @@ -8,10 +8,14 @@ use asset_db::{AssetId, FsAssets}; use authdb::AuthError; use database::DbConn; use handlers::{ - handle_available_images, handle_connect_websocket, handle_file, handle_get_charsheet, handle_get_users, handle_register_client, handle_server_status, handle_set_admin_password, handle_set_background_image, handle_unregister_client, RegisterRequest + handle_auth, handle_available_images, handle_connect_websocket, handle_file, + handle_get_charsheet, handle_get_users, handle_register_client, handle_server_status, + handle_set_admin_password, handle_set_background_image, handle_unregister_client, + RegisterRequest, }; use warp::{ // header, + filters::{method, path}, http::{Response, StatusCode}, reply::Reply, Filter, @@ -99,17 +103,16 @@ async fn handle_rejection(err: warp::Rejection) -> Result) => return ( { manager.setAdminPassword(password); - }} onAuth={(username, password) => console.log(username, password)}> + }} onAuth={(username, password) => manager.auth(username, password)}> {children} ); diff --git a/visions/ui/src/client.ts b/visions/ui/src/client.ts index 274ec77..8fa92f9 100644 --- a/visions/ui/src/client.ts +++ b/visions/ui/src/client.ts @@ -64,6 +64,12 @@ export class Client { return fetch(url, { method: 'PUT', headers: [['Content-Type', 'application/json']], body: JSON.stringify(password) }); } + async auth(username: string, password: string) { + const url = new URL(this.base); + url.pathname = `api/v1/auth` + return fetch(url, { method: 'PUT', headers: [['Content-Type', 'application/json']], body: JSON.stringify({ 'username': username, 'password': password }) }); + } + async status() { const url = new URL(this.base); url.pathname = `/api/v1/status`; diff --git a/visions/ui/src/providers/StateProvider/StateProvider.tsx b/visions/ui/src/providers/StateProvider/StateProvider.tsx index 8806702..e22ebe3 100644 --- a/visions/ui/src/providers/StateProvider/StateProvider.tsx +++ b/visions/ui/src/providers/StateProvider/StateProvider.tsx @@ -49,6 +49,15 @@ class StateManager { await this.client.setAdminPassword(password); await this.status(); } + + async auth(username: string, password: string) { + if (!this.client || !this.dispatch) return; + + let resp = await this.client.auth(username, password); + let userid = await resp.json(); + console.log("userid retrieved", userid); + this.dispatch({ type: "SetAuthState", content: { type: "Authed", userid } }); + } } export const StateContext = createContext<[AppState, StateManager]>([initialState(), new StateManager(undefined, undefined)]); -- 2.47.1 From 1d400ce38bfa07abd4f6f212da98fdddb1e6cca3 Mon Sep 17 00:00:00 2001 From: Savanni D'Gerinel Date: Tue, 24 Dec 2024 14:37:37 -0500 Subject: [PATCH 02/23] Start wrapping routes into standalone functions --- visions/server/src/database.rs | 3 +- visions/server/src/filters/mod.rs | 125 ++++++++++++++++ visions/server/src/filters/user_management.rs | 39 +++++ visions/server/src/handlers.rs | 17 +++ visions/server/src/main.rs | 138 ++---------------- 5 files changed, 195 insertions(+), 127 deletions(-) create mode 100644 visions/server/src/filters/mod.rs create mode 100644 visions/server/src/filters/user_management.rs diff --git a/visions/server/src/database.rs b/visions/server/src/database.rs index 9e3502e..b85df30 100644 --- a/visions/server/src/database.rs +++ b/visions/server/src/database.rs @@ -668,6 +668,7 @@ mod test { use std::path::PathBuf; use cool_asserts::assert_matches; + use result_extended::ResultExt; use super::*; @@ -700,7 +701,7 @@ mod test { assert_matches!( conn.character(CharacterId::from("1")).await, - ResultExt::Ok(None) + Ok(None) ); } } diff --git a/visions/server/src/filters/mod.rs b/visions/server/src/filters/mod.rs new file mode 100644 index 0000000..adf876d --- /dev/null +++ b/visions/server/src/filters/mod.rs @@ -0,0 +1,125 @@ +mod user_management; +pub use user_management::routes_user_management; + +use warp::{ + filters::cors::Builder, + http::{header::CONTENT_TYPE, HeaderName, Method, Response}, + reply::*, + Filter, +}; + +use crate::{ + asset_db::AssetId, core::Core, handlers::{handle_auth, handle_available_images, handle_connect_websocket, handle_file, handle_get_charsheet, handle_get_users, handle_register_client, handle_server_status, handle_set_admin_password, handle_set_background_image, handle_unregister_client, RegisterRequest} +}; + +fn cors(methods: Vec, headers: Vec) -> Builder +where + M: Into, + H: Into, +{ + warp::cors() + .allow_credentials(true) + .allow_methods(methods) + .allow_headers(headers) +} + +pub fn route_server_status( + core: Core, +) -> impl Filter + Clone { + warp::path!("api" / "v1" / "status") + .and(warp::get()) + .then(move || handle_server_status(core.clone())) +} + +pub fn route_set_bg_image( + core: Core, +) -> impl Filter + Clone { + warp::path!("api" / "v1" / "tabletop" / "bg_image") + .and(warp::put()) + .and(warp::body::json()) + .then({ + let core = core.clone(); + move |body| handle_set_background_image(core.clone(), body) + }) + .with(cors::(vec![Method::PUT], vec![])) +} + + +pub fn route_image( + core: Core, +) -> impl Filter + Clone { + warp::path!("api" / "v1" / "image" / String) + .and(warp::get()) + .then({ + let core = core.clone(); + move |file_name| handle_file(core.clone(), AssetId::from(file_name)) + }) +} + +pub fn route_available_images( + core: Core, +) -> impl Filter + Clone { + warp::path!("api" / "v1" / "image").and(warp::get()).then({ + let core = core.clone(); + move || handle_available_images(core.clone()) + }) +} + +pub fn route_register_client( + core: Core, +) -> impl Filter + Clone { + warp::path!("api" / "v1" / "client") + .and(warp::post()) + .then({ + let core = core.clone(); + move || handle_register_client(core.clone(), RegisterRequest {}) + }) +} + + +pub fn route_unregister_client( + core: Core, +) -> impl Filter + Clone { + warp::path!("api" / "v1" / "client" / String) + .and(warp::delete()) + .then({ + let core = core.clone(); + move |client_id| handle_unregister_client(core.clone(), client_id) + }) +} + +pub fn route_websocket( + core: Core, +) -> impl Filter + Clone { + warp::path("ws") + .and(warp::ws()) + .and(warp::path::param()) + .then({ + let core = core.clone(); + move |ws, client_id| handle_connect_websocket(core.clone(), ws, client_id) + }) +} + +pub fn route_get_charsheet( + core: Core, +) -> impl Filter + Clone { + warp::path!("api" / "v1" / "charsheet" / String) + .and(warp::get()) + .then({ + let core = core.clone(); + move |charid| handle_get_charsheet(core.clone(), charid) + }) +} + +pub fn route_check_password( + core: Core, +) -> impl Filter + Clone { + warp::path!("api" / "v1" / "auth") + .and(warp::put()) + .and(warp::body::json()) + .then({ + let core = core.clone(); + move |body| handle_auth(core.clone(), body) + }) +} + diff --git a/visions/server/src/filters/user_management.rs b/visions/server/src/filters/user_management.rs new file mode 100644 index 0000000..7353c21 --- /dev/null +++ b/visions/server/src/filters/user_management.rs @@ -0,0 +1,39 @@ +use warp::{ + http::{header::CONTENT_TYPE, Method}, + reply::Reply, + Filter, +}; + +use crate::{ + core::Core, + handlers::{handle_get_users, handle_set_admin_password}, +}; + +use super::cors; + +fn route_get_users( + core: Core, +) -> impl Filter + Clone { + warp::path!("api" / "v1" / "users") + .and(warp::get()) + .then(move || handle_get_users(core.clone())) +} + +fn route_set_admin_password( + core: Core, +) -> impl Filter + Clone { + warp::path!("api" / "v1" / "admin_password") + .and(warp::put()) + .and(warp::body::json()) + .then({ + let core = core.clone(); + move |body| handle_set_admin_password(core.clone(), body) + }) + .with(cors(vec![Method::PUT], vec![CONTENT_TYPE])) +} + +pub fn routes_user_management( + core: Core, +) -> impl Filter + Clone { + route_get_users(core.clone()).or(route_set_admin_password(core.clone())) +} diff --git a/visions/server/src/handlers.rs b/visions/server/src/handlers.rs index a3a3754..34145e4 100644 --- a/visions/server/src/handlers.rs +++ b/visions/server/src/handlers.rs @@ -262,6 +262,7 @@ pub async fn handle_set_admin_password(core: Core, password: String) -> impl Rep pub struct AuthRequest { username: String, password: String, + } pub async fn handle_auth(core: Core, auth_request: AuthRequest) -> impl Reply { @@ -277,3 +278,19 @@ pub async fn handle_auth(core: Core, auth_request: AuthRequest) -> impl Reply { .unwrap()) }).await } + +#[cfg(test)] +mod test { + use std::path::PathBuf; + + use crate::{asset_db::mocks::MemoryAssets, database::DbConn}; + + use super::*; + + fn setup() -> Core { + let asset_store = MemoryAssets::new(vec![]); + let memory_file: Option = None; + let db = DbConn::new(memory_file); + Core::new(asset_store, db) + } +} diff --git a/visions/server/src/main.rs b/visions/server/src/main.rs index f4e4fa4..9219215 100644 --- a/visions/server/src/main.rs +++ b/visions/server/src/main.rs @@ -7,6 +7,7 @@ use std::{ use asset_db::{AssetId, FsAssets}; use authdb::AuthError; use database::DbConn; +use filters::{route_available_images, route_check_password, route_get_charsheet, route_image, route_register_client, route_server_status, route_set_bg_image, route_unregister_client, route_websocket, routes_user_management}; use handlers::{ handle_auth, handle_available_images, handle_connect_websocket, handle_file, handle_get_charsheet, handle_get_users, handle_register_client, handle_server_status, @@ -26,6 +27,7 @@ mod core; mod database; mod handlers; mod types; +mod filters; #[derive(Debug)] struct Unauthorized; @@ -109,133 +111,17 @@ pub async fn main() { let core = core::Core::new(FsAssets::new(PathBuf::from("/home/savanni/Pictures")), conn); - let route_server_status = warp::path!("api" / "v1" / "status").and(warp::get()).then({ - let core = core.clone(); - move || handle_server_status(core.clone()) - }); - let route_image = warp::path!("api" / "v1" / "image" / String) - .and(warp::get()) - .then({ - let core = core.clone(); - move |file_name| handle_file(core.clone(), AssetId::from(file_name)) - }); - - let route_available_images = warp::path!("api" / "v1" / "image").and(warp::get()).then({ - let core = core.clone(); - move || handle_available_images(core.clone()) - }); - - let route_register_client = warp::path!("api" / "v1" / "client") - .and(warp::post()) - .then({ - let core = core.clone(); - move || handle_register_client(core.clone(), RegisterRequest {}) - }); - - let route_unregister_client = warp::path!("api" / "v1" / "client" / String) - .and(warp::delete()) - .then({ - let core = core.clone(); - move |client_id| handle_unregister_client(core.clone(), client_id) - }); - - let route_websocket = warp::path("ws") - .and(warp::ws()) - .and(warp::path::param()) - .then({ - let core = core.clone(); - move |ws, client_id| handle_connect_websocket(core.clone(), ws, client_id) - }); - - let route_set_bg_image_options = warp::path!("api" / "v1" / "tabletop" / "bg_image") - .and(warp::options()) - .map({ - move || { - Response::builder() - .header("Access-Control-Allow-Origin", "*") - .header("Access-Control-Allow-Methods", "PUT") - .header("Access-Control-Allow-Headers", "content-type") - .header("Content-Type", "application/json") - .body("") - .unwrap() - } - }); - let route_set_bg_image = warp::path!("api" / "v1" / "tabletop" / "bg_image") - .and(warp::put()) - .and(warp::body::json()) - .then({ - let core = core.clone(); - move |body| handle_set_background_image(core.clone(), body) - }); - - let route_get_users = warp::path!("api" / "v1" / "users").and(warp::get()).then({ - let core = core.clone(); - move || handle_get_users(core.clone()) - }); - - let route_get_charsheet = warp::path!("api" / "v1" / "charsheet" / String) - .and(warp::get()) - .then({ - let core = core.clone(); - move |charid| handle_get_charsheet(core.clone(), charid) - }); - - let route_set_admin_password_options = warp::path!("api" / "v1" / "admin_password") - .and(warp::options()) - .map({ - move || { - Response::builder() - .header("Access-Control-Allow-Origin", "*") - .header("Access-Control-Allow-Methods", "PUT") - .header("Access-Control-Allow-Headers", "content-type") - .header("Content-Type", "application/json") - .body("") - .unwrap() - } - }); - let route_set_admin_password = warp::path!("api" / "v1" / "admin_password") - .and(warp::put()) - .and(warp::body::json()) - .then({ - let core = core.clone(); - move |body| handle_set_admin_password(core.clone(), body) - }); - - let route_check_password_options = warp::path!("api" / "v1" / "auth") - .and(warp::options()) - .map({ - move || { - Response::builder() - .header("Access-Control-Allow-Origin", "*") - .header("Access-Control-Allow-Methods", "PUT") - .header("Access-Control-Allow-Headers", "content-type") - .body("") - .unwrap() - } - }); - let route_check_password = warp::path!("api" / "v1" / "auth") - .and(warp::put()) - .and(warp::body::json()) - .then({ - let core = core.clone(); - move |body| handle_auth(core.clone(), body) - }); - - let filter = route_server_status - .or(route_register_client) - .or(route_unregister_client) - .or(route_websocket) - .or(route_image) - .or(route_available_images) - .or(route_set_bg_image_options) - .or(route_set_bg_image) - .or(route_get_users) - .or(route_get_charsheet) - .or(route_set_admin_password_options) - .or(route_set_admin_password) - .or(route_check_password_options) - .or(route_check_password) + let filter = route_server_status(core.clone()) + .or(route_register_client(core.clone())) + .or(route_unregister_client(core.clone())) + .or(route_websocket(core.clone())) + .or(route_image(core.clone())) + .or(route_available_images(core.clone())) + .or(route_set_bg_image(core.clone())) + .or(routes_user_management(core.clone())) + .or(route_get_charsheet(core.clone())) + .or(route_check_password(core.clone())) .with(warp::log("visions")) .recover(handle_rejection); -- 2.47.1 From d5f4b7cfa55bf07eeafc540148bc2a743f1d085d Mon Sep 17 00:00:00 2001 From: Savanni D'Gerinel Date: Fri, 27 Dec 2024 14:02:43 -0500 Subject: [PATCH 03/23] Add session creation and lookup --- .../server/migrations/01-initial-db/up.sql | 11 +- visions/server/src/core.rs | 55 +++-- visions/server/src/database.rs | 197 +++++++++++++++--- visions/server/src/filters/mod.rs | 21 +- visions/server/src/filters/user_management.rs | 72 ++++++- visions/server/src/handlers.rs | 44 ++-- visions/server/src/main.rs | 28 +-- visions/server/src/types.rs | 15 +- 8 files changed, 339 insertions(+), 104 deletions(-) diff --git a/visions/server/migrations/01-initial-db/up.sql b/visions/server/migrations/01-initial-db/up.sql index 3e822af..29463cd 100644 --- a/visions/server/migrations/01-initial-db/up.sql +++ b/visions/server/migrations/01-initial-db/up.sql @@ -1,11 +1,18 @@ CREATE TABLE users( uuid TEXT PRIMARY KEY, - name TEXT, + name TEXT UNIQUE, password TEXT, admin BOOLEAN, enabled BOOLEAN ); +CREATE TABLE sessions( + id TEXT PRIMARY KEY, + user_id TEXT, + + FOREIGN KEY(user_id) REFERENCES users(uuid) +); + CREATE TABLE games( uuid TEXT PRIMARY KEY, name TEXT @@ -28,5 +35,3 @@ CREATE TABLE roles( FOREIGN KEY(game_id) REFERENCES games(uuid) ); -INSERT INTO users VALUES ("admin", "admin", "", true, true); - diff --git a/visions/server/src/core.rs b/visions/server/src/core.rs index 2593128..4eaeac0 100644 --- a/visions/server/src/core.rs +++ b/visions/server/src/core.rs @@ -10,7 +10,7 @@ use uuid::Uuid; use crate::{ asset_db::{self, AssetId, Assets}, - database::{CharacterId, Database, UserId}, + database::{CharacterId, Database, SessionId, UserId}, types::{AppError, FatalError, Game, Message, Tabletop, User, RGB}, }; @@ -61,7 +61,7 @@ impl Core { pub async fn status(&self) -> ResultExt { let mut state = self.0.write().await; - let admin_user = return_error!(match state.db.user(UserId::from("admin")).await { + let admin_user = return_error!(match state.db.user(&UserId::from("admin")).await { Ok(Some(admin_user)) => ok(admin_user), Ok(None) => { return ok(Status { @@ -106,6 +106,15 @@ impl Core { } } + pub async fn user_by_username(&self, username: &str) -> ResultExt, AppError, FatalError> { + let state = self.0.read().await; + match state.db.user_by_username(username).await { + Ok(Some(user_row)) => ok(Some(User::from(user_row))), + Ok(None) => ok(None), + Err(err) => fatal(err), + } + } + pub async fn list_users(&self) -> ResultExt, AppError, FatalError> { let users = self.0.write().await.db.users().await; match users { @@ -206,7 +215,7 @@ impl Core { password: String, ) -> ResultExt<(), AppError, FatalError> { let mut state = self.0.write().await; - let user = match state.db.user(uuid.clone()).await { + let user = match state.db.user(&uuid).await { Ok(Some(row)) => row, Ok(None) => return error(AppError::NotFound(uuid.as_str().to_owned())), Err(err) => return fatal(err), @@ -221,10 +230,13 @@ impl Core { } } - pub async fn auth(&self, username: String, password: String) -> ResultExt { + pub async fn auth(&self, username: &str, password: &str) -> ResultExt { let state = self.0.write().await; - match state.db.user_by_username(username).await { - Ok(Some(row)) if (row.password == password) => ok(row.id), + match state.db.user_by_username(&username).await { + Ok(Some(row)) if (row.password == password) => { + let session_id = state.db.create_session(row.id).await.unwrap(); + ok(session_id) + } Ok(_) => error(AppError::AuthFailed), Err(err) => fatal(err), } @@ -244,7 +256,7 @@ mod test { database::{DbConn, DiskDb}, }; - fn test_core() -> Core { + async fn test_core() -> Core { let assets = MemoryAssets::new(vec![ ( AssetId::from("asset_1"), @@ -274,19 +286,21 @@ mod test { ]); let memory_db: Option = None; let conn = DbConn::new(memory_db); + conn.save_user(None, "admin", "aoeu", true, true).await.unwrap(); + conn.save_user(None, "gm_1", "aoeu", false, true).await.unwrap(); Core::new(assets, conn) } #[tokio::test] async fn it_lists_available_images() { - let core = test_core(); + let core = test_core().await; let image_paths = core.available_images().await; assert_eq!(image_paths.len(), 2); } #[tokio::test] async fn it_retrieves_an_asset() { - let core = test_core(); + let core = test_core().await; assert_matches!(core.get_asset(AssetId::from("asset_1")).await, ResultExt::Ok((mime, data)) => { assert_eq!(mime.type_(), mime::IMAGE); assert_eq!(data, "abcdefg".as_bytes()); @@ -295,7 +309,7 @@ mod test { #[tokio::test] async fn it_can_retrieve_the_default_tabletop() { - let core = test_core(); + let core = test_core().await; assert_matches!(core.tabletop().await, Tabletop{ background_color, background_image } => { assert_eq!(background_color, DEFAULT_BACKGROUND_COLOR); assert_eq!(background_image, None); @@ -304,7 +318,7 @@ mod test { #[tokio::test] async fn it_can_change_the_tabletop_background() { - let core = test_core(); + let core = test_core().await; assert_matches!( core.set_background_image(AssetId::from("asset_1")).await, ResultExt::Ok(()) @@ -317,7 +331,7 @@ mod test { #[tokio::test] async fn it_sends_notices_to_clients_on_tabletop_change() { - let core = test_core(); + let core = test_core().await; let client_id = core.register_client().await; let mut receiver = core.connect_client(client_id).await; @@ -336,4 +350,21 @@ mod test { None => panic!("receiver did not get a message"), } } + + #[tokio::test] + async fn it_creates_a_sessionid_on_successful_auth() { + let core = test_core().await; + match core.auth("admin", "aoeu").await { + ResultExt::Ok(session_id) => { + let st = core.0.read().await; + match st.db.session(session_id).await { + Ok(Some(user_row)) => assert_eq!(user_row.name, "admin"), + Ok(None) => panic!("no matching user row for the session id"), + Err(err) => panic!("{}", err), + } + }, + ResultExt::Err(err) => panic!("{}", err), + ResultExt::Fatal(err) => panic!("{}", err), + } + } } diff --git a/visions/server/src/database.rs b/visions/server/src/database.rs index b85df30..a741594 100644 --- a/visions/server/src/database.rs +++ b/visions/server/src/database.rs @@ -24,11 +24,13 @@ lazy_static! { #[derive(Debug)] enum Request { Charsheet(CharacterId), + CreateSession(UserId), Games, + SaveUser(Option, String, String, bool, bool), + Session(SessionId), User(UserId), UserByUsername(String), Users, - SaveUser(Option, String, String, bool, bool), } #[derive(Debug)] @@ -40,10 +42,12 @@ struct DatabaseRequest { #[derive(Debug)] enum DatabaseResponse { Charsheet(Option), + CreateSession(SessionId), Games(Vec), + SaveUser(UserId), + Session(Option), User(Option), Users(Vec), - SaveUser(UserId), } #[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] @@ -80,6 +84,40 @@ impl FromSql for UserId { } } +#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +pub struct SessionId(String); + +impl SessionId { + pub fn new() -> Self { + Self(format!("{}", Uuid::new_v4().hyphenated())) + } + + pub fn as_str<'a>(&'a self) -> &'a str { + &self.0 + } +} + +impl From<&str> for SessionId { + fn from(s: &str) -> Self { + Self(s.to_owned()) + } +} + +impl From for SessionId { + fn from(s: String) -> Self { + Self(s) + } +} + +impl FromSql for SessionId { + fn column_result(value: ValueRef<'_>) -> FromSqlResult { + match value { + ValueRef::Text(text) => Ok(Self::from(String::from_utf8(text.to_vec()).unwrap())), + _ => unimplemented!(), + } + } +} + #[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] pub struct GameId(String); @@ -177,14 +215,20 @@ pub struct CharsheetRow { pub data: serde_json::Value, } +#[derive(Clone, Debug)] +pub struct SessionRow { + id: SessionId, + user_id: SessionId, +} + #[async_trait] pub trait Database: Send + Sync { - async fn user(&mut self, _: UserId) -> Result, FatalError>; + async fn user(&mut self, _: &UserId) -> Result, FatalError>; - async fn user_by_username(&self, _: String) -> Result, FatalError>; + async fn user_by_username(&self, _: &str) -> Result, FatalError>; async fn save_user( - &mut self, + &self, user_id: Option, name: &str, password: &str, @@ -197,6 +241,10 @@ pub trait Database: Send + Sync { async fn games(&mut self) -> Result, FatalError>; async fn character(&mut self, id: CharacterId) -> Result, FatalError>; + + async fn session(&self, id: SessionId) -> Result, FatalError>; + + async fn create_session(&self, id: UserId) -> Result; } pub struct DiskDb { @@ -268,7 +316,7 @@ impl DiskDb { Ok(DiskDb { conn }) } - fn user(&self, id: UserId) -> Result, FatalError> { + fn user(&self, id: &UserId) -> Result, FatalError> { let mut stmt = self .conn .prepare("SELECT uuid, name, password, admin, enabled FROM users WHERE uuid=?") @@ -293,13 +341,13 @@ impl DiskDb { } } - fn user_by_username(&self, username: String) -> Result, FatalError> { + fn user_by_username(&self, username: &str) -> Result, FatalError> { let mut stmt = self .conn .prepare("SELECT uuid, name, password, admin, enabled FROM users WHERE name=?") .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; let items: Vec = stmt - .query_map([username.as_str()], |row| { + .query_map([username], |row| { Ok(UserRow { id: row.get(0).unwrap(), name: row.get(1).unwrap(), @@ -361,9 +409,7 @@ impl DiskDb { Some(user_id) => { let mut stmt = self .conn - .prepare( - "UPDATE users SET name=?, password=?, admin=?, enabled=? WHERE uuid=?", - ) + .prepare("UPDATE users SET name=?, password=?, admin=?, enabled=? WHERE uuid=?") .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; stmt.execute((name, password, admin, enabled, user_id.as_str())) .unwrap(); @@ -394,6 +440,51 @@ impl DiskDb { } } + fn session(&self, session_id: &SessionId) -> Result, FatalError> { + let mut stmt = self.conn + .prepare("SELECT u.uuid, u.name, u.password, u.admin, u.enabled FROM sessions s INNER JOIN users u ON u.uuid = s.user_id WHERE s.id = ?") + .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; + + let items: Vec = stmt + .query_map([session_id.as_str()], |row| { + Ok(UserRow { + id: row.get(0).unwrap(), + name: row.get(1).unwrap(), + password: row.get(2).unwrap(), + admin: row.get(3).unwrap(), + enabled: row.get(4).unwrap(), + }) + }) + .unwrap() + .collect::, rusqlite::Error>>() + .unwrap(); + match &items[..] { + [] => Ok(None), + [item] => Ok(Some(item.clone())), + _ => Err(FatalError::NonUniqueDatabaseKey( + session_id.as_str().to_owned(), + )), + } + } + + fn create_session(&self, user_id: &UserId) -> Result { + match self.user(user_id) { + Ok(Some(_)) => { + let mut stmt = self + .conn + .prepare("INSERT INTO sessions VALUES (?, ?)") + .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; + + let session_id = SessionId::new(); + stmt.execute((session_id.as_str(), user_id.as_str())) + .unwrap(); + Ok(session_id) + } + Ok(None) => Err(FatalError::DatabaseKeyMissing), + Err(err) => Err(err), + } + } + fn character(&self, id: CharacterId) -> Result, FatalError> { let mut stmt = self .conn @@ -456,7 +547,6 @@ async fn db_handler(db: DiskDb, requestor: Receiver) { match req { Request::Charsheet(id) => { let sheet = db.character(id); - println!("sheet retrieved: {:?}", sheet); match sheet { Ok(sheet) => { tx.send(DatabaseResponse::Charsheet(sheet)).await.unwrap(); @@ -464,11 +554,17 @@ async fn db_handler(db: DiskDb, requestor: Receiver) { _ => unimplemented!(), } } + Request::CreateSession(id) => { + let session_id = db.create_session(&id).unwrap(); + tx.send(DatabaseResponse::CreateSession(session_id)) + .await + .unwrap(); + } Request::Games => { unimplemented!(); } Request::User(uid) => { - let user = db.user(uid); + let user = db.user(&uid); match user { Ok(user) => { tx.send(DatabaseResponse::User(user)).await.unwrap(); @@ -477,14 +573,20 @@ async fn db_handler(db: DiskDb, requestor: Receiver) { } } Request::UserByUsername(username) => { - let user = db.user_by_username(username); + let user = db.user_by_username(&username); match user { Ok(user) => tx.send(DatabaseResponse::User(user)).await.unwrap(), err => panic!("{:?}", err), } } Request::SaveUser(user_id, username, password, admin, enabled) => { - let user_id = db.save_user(user_id, username.as_ref(), password.as_ref(), admin, enabled); + let user_id = db.save_user( + user_id, + username.as_ref(), + password.as_ref(), + admin, + enabled, + ); match user_id { Ok(user_id) => { tx.send(DatabaseResponse::SaveUser(user_id)).await.unwrap(); @@ -492,6 +594,13 @@ async fn db_handler(db: DiskDb, requestor: Receiver) { err => panic!("{:?}", err), } } + Request::Session(session_id) => { + let user = db.session(&session_id); + match user { + Ok(user) => tx.send(DatabaseResponse::Session(user)).await.unwrap(), + err => panic!("{:?}", err), + } + } Request::Users => { let users = db.users(); match users { @@ -529,12 +638,12 @@ impl DbConn { #[async_trait] impl Database for DbConn { - async fn user(&mut self, uid: UserId) -> Result, FatalError> { + async fn user(&mut self, uid: &UserId) -> Result, FatalError> { let (tx, rx) = bounded::(1); let request = DatabaseRequest { tx, - req: Request::User(uid), + req: Request::User(uid.clone()), }; match self.conn.send(request).await { @@ -549,12 +658,12 @@ impl Database for DbConn { } } - async fn user_by_username(&self, username: String) -> Result, FatalError> { + async fn user_by_username(&self, username: &str) -> Result, FatalError> { let (tx, rx) = bounded::(1); let request = DatabaseRequest { tx, - req: Request::UserByUsername(username), + req: Request::UserByUsername(username.to_owned()), }; match self.conn.send(request).await { @@ -570,7 +679,7 @@ impl Database for DbConn { } async fn save_user( - &mut self, + &self, user_id: Option, name: &str, password: &str, @@ -661,6 +770,46 @@ impl Database for DbConn { Err(_) => Err(FatalError::DatabaseConnectionLost), } } + + async fn session(&self, id: SessionId) -> Result, FatalError> { + let (tx, rx) = bounded::(1); + + let request = DatabaseRequest { + tx, + req: Request::Session(id), + }; + + match self.conn.send(request).await { + Ok(()) => (), + Err(_) => return Err(FatalError::DatabaseConnectionLost), + }; + + match rx.recv().await { + Ok(DatabaseResponse::Session(row)) => Ok(row), + Ok(_) => Err(FatalError::MessageMismatch), + Err(_) => Err(FatalError::DatabaseConnectionLost), + } + } + + async fn create_session(&self, id: UserId) -> Result { + let (tx, rx) = bounded::(1); + + let request = DatabaseRequest { + tx, + req: Request::CreateSession(id), + }; + + match self.conn.send(request).await { + Ok(()) => (), + Err(_) => return Err(FatalError::DatabaseConnectionLost), + }; + + match rx.recv().await { + Ok(DatabaseResponse::CreateSession(session_id)) => Ok(session_id), + Ok(_) => Err(FatalError::MessageMismatch), + Err(_) => Err(FatalError::DatabaseConnectionLost), + } + } } #[cfg(test)] @@ -668,7 +817,6 @@ mod test { use std::path::PathBuf; use cool_asserts::assert_matches; - use result_extended::ResultExt; use super::*; @@ -678,7 +826,7 @@ mod test { let no_path: Option = None; let db = DiskDb::new(no_path).unwrap(); - db.save_user(None, "admin", "abcdefg", true, true); + db.save_user(None, "admin", "abcdefg", true, true).unwrap(); let game_id = db.save_game(None, "Candela").unwrap(); (db, game_id) } @@ -699,9 +847,6 @@ mod test { let memory_db: Option = None; let mut conn = DbConn::new(memory_db); - assert_matches!( - conn.character(CharacterId::from("1")).await, - Ok(None) - ); + assert_matches!(conn.character(CharacterId::from("1")).await, Ok(None)); } } diff --git a/visions/server/src/filters/mod.rs b/visions/server/src/filters/mod.rs index adf876d..06b5635 100644 --- a/visions/server/src/filters/mod.rs +++ b/visions/server/src/filters/mod.rs @@ -9,7 +9,11 @@ use warp::{ }; use crate::{ - asset_db::AssetId, core::Core, handlers::{handle_auth, handle_available_images, handle_connect_websocket, handle_file, handle_get_charsheet, handle_get_users, handle_register_client, handle_server_status, handle_set_admin_password, handle_set_background_image, handle_unregister_client, RegisterRequest} + asset_db::AssetId, + core::Core, + handlers::{ + handle_available_images, handle_check_password, handle_connect_websocket, handle_file, handle_get_charsheet, handle_register_client, handle_server_status, handle_set_background_image, handle_unregister_client, RegisterRequest + }, }; fn cors(methods: Vec, headers: Vec) -> Builder @@ -23,6 +27,13 @@ where .allow_headers(headers) } +pub fn route_healthcheck() -> impl Filter + Clone +{ + warp::path!("api" / "v1" / "healthcheck") + .and(warp::get()) + .map(|| warp::reply::reply()) +} + pub fn route_server_status( core: Core, ) -> impl Filter + Clone { @@ -44,7 +55,6 @@ pub fn route_set_bg_image( .with(cors::(vec![Method::PUT], vec![])) } - pub fn route_image( core: Core, ) -> impl Filter + Clone { @@ -76,7 +86,6 @@ pub fn route_register_client( }) } - pub fn route_unregister_client( core: Core, ) -> impl Filter + Clone { @@ -111,7 +120,7 @@ pub fn route_get_charsheet( }) } -pub fn route_check_password( +pub fn route_authenticate( core: Core, ) -> impl Filter + Clone { warp::path!("api" / "v1" / "auth") @@ -119,7 +128,7 @@ pub fn route_check_password( .and(warp::body::json()) .then({ let core = core.clone(); - move |body| handle_auth(core.clone(), body) + move |body| handle_check_password(core.clone(), body) }) + .with(cors::(vec![Method::PUT], vec![])) } - diff --git a/visions/server/src/filters/user_management.rs b/visions/server/src/filters/user_management.rs index 7353c21..d63b809 100644 --- a/visions/server/src/filters/user_management.rs +++ b/visions/server/src/filters/user_management.rs @@ -1,12 +1,12 @@ use warp::{ - http::{header::CONTENT_TYPE, Method}, + http::{header::CONTENT_TYPE, HeaderName, Method}, reply::Reply, Filter, }; use crate::{ core::Core, - handlers::{handle_get_users, handle_set_admin_password}, + handlers::{handle_check_password, handle_get_users, handle_set_admin_password}, }; use super::cors; @@ -37,3 +37,71 @@ pub fn routes_user_management( ) -> impl Filter + Clone { route_get_users(core.clone()).or(route_set_admin_password(core.clone())) } + +pub fn route_check_password( + core: Core, +) -> impl Filter + Clone { + warp::path!("api" / "v1" / "auth") + .and(warp::put()) + .and(warp::body::json()) + .then({ + let core = core.clone(); + move |body| handle_check_password(core.clone(), body) + }) + .with(cors::(vec![Method::PUT], vec![])) +} + +#[cfg(test)] +mod test { + use std::{collections::HashMap, path::PathBuf}; + + use result_extended::ResultExt; + + use crate::{asset_db::mocks::MemoryAssets, database::{Database, DbConn, UserId}}; + + use super::*; + + async fn setup() -> Core { + let asset_store = MemoryAssets::new(vec![]); + let memory_file: Option = None; + let db = DbConn::new(memory_file); + db.save_user(None, "admin", "", true, true).await.unwrap(); + Core::new(asset_store, db) + } + + #[tokio::test] + async fn handle_check_password_should_return_a_valid_token() { + let core = setup().await; + match core.list_users().await { + ResultExt::Ok(users) => println!("{:?}", users), + ResultExt::Err(err) => panic!("{}", err), + ResultExt::Fatal(err) => panic!("{}", err), + } + match core.user_by_username("admin").await { + ResultExt::Ok(Some(user)) => { + let _ = core.set_password(UserId::from(user.id), "aoeu".to_owned()).await; + }, + ResultExt::Ok(None) => panic!("expected user wasn't found"), + ResultExt::Err(err) => panic!("{}", err), + ResultExt::Fatal(err) => panic!("{}", err), + } + let filter = route_check_password(core); + let params: HashMap = vec![ + ("username".to_owned(), "admin".to_owned()), + ("password".to_owned(), "aoeu".to_owned()), + ] + .into_iter() + .collect(); + let resp = warp::test::request() + .method("PUT") + .path("/api/v1/auth") + .json(¶ms) + .reply(&filter) + .await; + + println!("response: {}", resp.status()); + assert!(resp.status().is_success()); + println!("resp.body(): {}", String::from_utf8(resp.body().to_vec()).unwrap()); + serde_json::from_slice::(resp.body()).unwrap(); + } +} diff --git a/visions/server/src/handlers.rs b/visions/server/src/handlers.rs index 34145e4..2865ff8 100644 --- a/visions/server/src/handlers.rs +++ b/visions/server/src/handlers.rs @@ -47,10 +47,13 @@ where .status(StatusCode::NOT_FOUND) .body(vec![]) .unwrap(), - ResultExt::Err(_) => Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .body(vec![]) - .unwrap(), + ResultExt::Err(err) => { + println!("request error: {:?}", err); + Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(vec![]) + .unwrap() + } ResultExt::Fatal(err) => { panic!("Shutting down with fatal error: {:?}", err); } @@ -262,35 +265,20 @@ pub async fn handle_set_admin_password(core: Core, password: String) -> impl Rep pub struct AuthRequest { username: String, password: String, - } -pub async fn handle_auth(core: Core, auth_request: AuthRequest) -> impl Reply { +pub async fn handle_check_password(core: Core, auth_request: AuthRequest) -> impl Reply { handler(async move { - let userid = return_error!(core.auth(auth_request.username, auth_request.password).await); + let session_id = return_error!( + core.auth(&auth_request.username, &auth_request.password) + .await + ); + println!("handle_check_password: {:?}", session_id); ok(Response::builder() - .header("Access-Control-Allow-Origin", "*") - .header("Access-Control-Allow-Methods", "*") - .header("Access-Control-Allow-Headers", "content-type") .header("Content-Type", "application/json") - .body(serde_json::to_vec(&userid).unwrap()) + .body(serde_json::to_vec(&session_id).unwrap()) .unwrap()) - }).await -} - -#[cfg(test)] -mod test { - use std::path::PathBuf; - - use crate::{asset_db::mocks::MemoryAssets, database::DbConn}; - - use super::*; - - fn setup() -> Core { - let asset_store = MemoryAssets::new(vec![]); - let memory_file: Option = None; - let db = DbConn::new(memory_file); - Core::new(asset_store, db) - } + }) + .await } diff --git a/visions/server/src/main.rs b/visions/server/src/main.rs index 9219215..a6692ec 100644 --- a/visions/server/src/main.rs +++ b/visions/server/src/main.rs @@ -7,13 +7,7 @@ use std::{ use asset_db::{AssetId, FsAssets}; use authdb::AuthError; use database::DbConn; -use filters::{route_available_images, route_check_password, route_get_charsheet, route_image, route_register_client, route_server_status, route_set_bg_image, route_unregister_client, route_websocket, routes_user_management}; -use handlers::{ - handle_auth, handle_available_images, handle_connect_websocket, handle_file, - handle_get_charsheet, handle_get_users, handle_register_client, handle_server_status, - handle_set_admin_password, handle_set_background_image, handle_unregister_client, - RegisterRequest, -}; +use filters::{route_authenticate, route_available_images, route_get_charsheet, route_healthcheck, route_image, route_register_client, route_server_status, route_set_bg_image, route_unregister_client, route_websocket, routes_user_management}; use warp::{ // header, filters::{method, path}, @@ -111,21 +105,13 @@ pub async fn main() { let core = core::Core::new(FsAssets::new(PathBuf::from("/home/savanni/Pictures")), conn); + let unauthenticated_endpoints = route_healthcheck().or(route_authenticate(core.clone())); + let authenticated_endpoints = route_image(core.clone()); - let filter = route_server_status(core.clone()) - .or(route_register_client(core.clone())) - .or(route_unregister_client(core.clone())) - .or(route_websocket(core.clone())) - .or(route_image(core.clone())) - .or(route_available_images(core.clone())) - .or(route_set_bg_image(core.clone())) - .or(routes_user_management(core.clone())) - .or(route_get_charsheet(core.clone())) - .or(route_check_password(core.clone())) - .with(warp::log("visions")) - .recover(handle_rejection); - - let server = warp::serve(filter); + let server = warp::serve(unauthenticated_endpoints + .or(authenticated_endpoints) + .with(warp::log("visions")) + .recover(handle_rejection)); server .run(SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 8001)) .await; diff --git a/visions/server/src/types.rs b/visions/server/src/types.rs index 43cdcf7..15fedf4 100644 --- a/visions/server/src/types.rs +++ b/visions/server/src/types.rs @@ -7,20 +7,23 @@ use crate::{asset_db::AssetId, database::UserRow}; #[derive(Debug, Error)] pub enum FatalError { - #[error("Non-unique database key {0}")] - NonUniqueDatabaseKey(String), - - #[error("Database migrations failed {0}")] - DatabaseMigrationFailure(String), - #[error("Failed to construct a query")] ConstructQueryFailure(String), #[error("Database connection lost")] DatabaseConnectionLost, + #[error("Expected database key is missing")] + DatabaseKeyMissing, + + #[error("Database migrations failed {0}")] + DatabaseMigrationFailure(String), + #[error("Unexpected response for message")] MessageMismatch, + + #[error("Non-unique database key {0}")] + NonUniqueDatabaseKey(String), } impl result_extended::FatalError for FatalError {} -- 2.47.1 From fb34d0d9651fc28788a1024f18ab3b51ef9359b6 Mon Sep 17 00:00:00 2001 From: Savanni D'Gerinel Date: Fri, 27 Dec 2024 14:05:41 -0500 Subject: [PATCH 04/23] Move the database into a more complex sub-module --- .../src/{database.rs => database/mod.rs} | 46 ------------------- 1 file changed, 46 deletions(-) rename visions/server/src/{database.rs => database/mod.rs} (88%) diff --git a/visions/server/src/database.rs b/visions/server/src/database/mod.rs similarity index 88% rename from visions/server/src/database.rs rename to visions/server/src/database/mod.rs index a741594..8e27f4b 100644 --- a/visions/server/src/database.rs +++ b/visions/server/src/database/mod.rs @@ -251,52 +251,6 @@ pub struct DiskDb { conn: Connection, } -/* -fn setup_test_database(conn: &Connection) -> Result<(), FatalError> { - let mut gamecount_stmt = conn.prepare("SELECT count(*) FROM games").unwrap(); - let mut count = gamecount_stmt.query([]).unwrap(); - if count.next().unwrap().unwrap().get::(0) == Ok(0) { - let admin_id = format!("{}", Uuid::new_v4()); - let user_id = format!("{}", Uuid::new_v4()); - let game_id = format!("{}", Uuid::new_v4()); - let char_id = CharacterId::new(); - - let mut user_stmt = conn - .prepare("INSERT INTO users VALUES (?, ?, ?, ?, ?)") - .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; - user_stmt - .execute((admin_id.clone(), "admin", "abcdefg", true, true)) - .unwrap(); - user_stmt - .execute((user_id.clone(), "savanni", "abcdefg", false, true)) - .unwrap(); - - let mut game_stmt = conn - .prepare("INSERT INTO games VALUES (?, ?)") - .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; - game_stmt - .execute((game_id.clone(), "Circle of Bluest Sky")) - .unwrap(); - - let mut role_stmt = conn - .prepare("INSERT INTO roles VALUES (?, ?, ?)") - .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; - role_stmt - .execute((user_id.clone(), game_id.clone(), "gm")) - .unwrap(); - - let mut sheet_stmt = conn - .prepare("INSERT INTO characters VALUES (?, ?, ?)") - .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; - - sheet_stmt.execute((char_id.as_str(), game_id, r#"{ "type_": "Candela", "name": "Soren Jensen", "pronouns": "he/him", "circle": "Circle of the Bluest Sky", "style": "dapper gentleman", "catalyst": "a cursed book", "question": "What were the contents of that book?", "nerve": { "type_": "nerve", "drives": { "current": 1, "max": 2 }, "resistances": { "current": 0, "max": 3 }, "move": { "gilded": false, "score": 2 }, "strike": { "gilded": false, "score": 1 }, "control": { "gilded": true, "score": 0 } }, "cunning": { "type_": "cunning", "drives": { "current": 1, "max": 1 }, "resistances": { "current": 0, "max": 3 }, "sway": { "gilded": false, "score": 0 }, "read": { "gilded": false, "score": 0 }, "hide": { "gilded": false, "score": 0 } }, "intuition": { "type_": "intuition", "drives": { "current": 0, "max": 0 }, "resistances": { "current": 0, "max": 3 }, "survey": { "gilded": false, "score": 0 }, "focus": { "gilded": false, "score": 0 }, "sense": { "gilded": false, "score": 0 } }, "role": "Slink", "role_abilities": [ "Scout: If you have time to observe a location, you can spend 1 Intuition to ask a question: What do I notice here that others do not see? What in this place might be of use to us? What path should we follow?" ], "specialty": "Detective", "specialty_abilities": [ "Mind Palace: When you want to figure out how two clues might relate or what path they should point you towards, burn 1 Intution resistance. The GM will give you the information you have deduced." ] }"#)) - .unwrap(); - } - - Ok(()) -} -*/ - impl DiskDb { pub fn new

(path: Option

) -> Result where -- 2.47.1 From 2b1a0b99f80d62b716c7c3dcff1af4d4f9c0cb24 Mon Sep 17 00:00:00 2001 From: Savanni D'Gerinel Date: Fri, 27 Dec 2024 14:29:07 -0500 Subject: [PATCH 05/23] Lots of linting and refactoring --- visions/server/src/core.rs | 26 +- visions/server/src/database/disk_db.rs | 338 +++++++++++++++ visions/server/src/database/mod.rs | 550 ++----------------------- visions/server/src/database/types.rs | 175 ++++++++ visions/server/src/handlers.rs | 2 +- visions/server/src/main.rs | 24 +- 6 files changed, 567 insertions(+), 548 deletions(-) create mode 100644 visions/server/src/database/disk_db.rs create mode 100644 visions/server/src/database/types.rs diff --git a/visions/server/src/core.rs b/visions/server/src/core.rs index 4eaeac0..99eea4a 100644 --- a/visions/server/src/core.rs +++ b/visions/server/src/core.rs @@ -106,7 +106,10 @@ impl Core { } } - pub async fn user_by_username(&self, username: &str) -> ResultExt, AppError, FatalError> { + pub async fn user_by_username( + &self, + username: &str, + ) -> ResultExt, AppError, FatalError> { let state = self.0.read().await; match state.db.user_by_username(username).await { Ok(Some(user_row)) => ok(Some(User::from(user_row))), @@ -230,7 +233,11 @@ impl Core { } } - pub async fn auth(&self, username: &str, password: &str) -> ResultExt { + pub async fn auth( + &self, + username: &str, + password: &str, + ) -> ResultExt { let state = self.0.write().await; match state.db.user_by_username(&username).await { Ok(Some(row)) if (row.password == password) => { @@ -251,10 +258,7 @@ mod test { use cool_asserts::assert_matches; - use crate::{ - asset_db::mocks::MemoryAssets, - database::{DbConn, DiskDb}, - }; + use crate::{asset_db::mocks::MemoryAssets, database::DbConn}; async fn test_core() -> Core { let assets = MemoryAssets::new(vec![ @@ -286,8 +290,12 @@ mod test { ]); let memory_db: Option = None; let conn = DbConn::new(memory_db); - conn.save_user(None, "admin", "aoeu", true, true).await.unwrap(); - conn.save_user(None, "gm_1", "aoeu", false, true).await.unwrap(); + conn.save_user(None, "admin", "aoeu", true, true) + .await + .unwrap(); + conn.save_user(None, "gm_1", "aoeu", false, true) + .await + .unwrap(); Core::new(assets, conn) } @@ -362,7 +370,7 @@ mod test { Ok(None) => panic!("no matching user row for the session id"), Err(err) => panic!("{}", err), } - }, + } ResultExt::Err(err) => panic!("{}", err), ResultExt::Fatal(err) => panic!("{}", err), } diff --git a/visions/server/src/database/disk_db.rs b/visions/server/src/database/disk_db.rs new file mode 100644 index 0000000..5b41a28 --- /dev/null +++ b/visions/server/src/database/disk_db.rs @@ -0,0 +1,338 @@ +use std::path::Path; + +use async_std::channel::{bounded, Receiver, Sender}; +use include_dir::{include_dir, Dir}; +use lazy_static::lazy_static; +use rusqlite::Connection; +use rusqlite_migration::Migrations; + +use crate::{database::{DatabaseResponse, Request}, types::FatalError}; + +use super::{types::GameId, CharacterId, CharsheetRow, DatabaseRequest, SessionId, UserId, UserRow}; + +static MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/migrations"); + +lazy_static! { + static ref MIGRATIONS: Migrations<'static> = + Migrations::from_directory(&MIGRATIONS_DIR).unwrap(); +} + +pub struct DiskDb { + conn: Connection, +} + +impl DiskDb { + pub fn new

(path: Option

) -> Result + where + P: AsRef, + { + let mut conn = match path { + None => Connection::open(":memory:").expect("to create a memory connection"), + Some(path) => Connection::open(path).expect("to create connection"), + }; + + MIGRATIONS + .to_latest(&mut conn) + .map_err(|err| FatalError::DatabaseMigrationFailure(format!("{}", err)))?; + + // setup_test_database(&conn)?; + + Ok(DiskDb { conn }) + } + + pub fn user(&self, id: &UserId) -> Result, FatalError> { + let mut stmt = self + .conn + .prepare("SELECT uuid, name, password, admin, enabled FROM users WHERE uuid=?") + .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; + let items: Vec = stmt + .query_map([id.as_str()], |row| { + Ok(UserRow { + id: row.get(0).unwrap(), + name: row.get(1).unwrap(), + password: row.get(2).unwrap(), + admin: row.get(3).unwrap(), + enabled: row.get(4).unwrap(), + }) + }) + .unwrap() + .collect::, rusqlite::Error>>() + .unwrap(); + match &items[..] { + [] => Ok(None), + [item] => Ok(Some(item.clone())), + _ => Err(FatalError::NonUniqueDatabaseKey(id.as_str().to_owned())), + } + } + + pub fn user_by_username(&self, username: &str) -> Result, FatalError> { + let mut stmt = self + .conn + .prepare("SELECT uuid, name, password, admin, enabled FROM users WHERE name=?") + .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; + let items: Vec = stmt + .query_map([username], |row| { + Ok(UserRow { + id: row.get(0).unwrap(), + name: row.get(1).unwrap(), + password: row.get(2).unwrap(), + admin: row.get(3).unwrap(), + enabled: row.get(4).unwrap(), + }) + }) + .unwrap() + .collect::, rusqlite::Error>>() + .unwrap(); + match &items[..] { + [] => Ok(None), + [item] => Ok(Some(item.clone())), + _ => Err(FatalError::NonUniqueDatabaseKey(username.to_owned())), + } + } + + pub fn save_user( + &self, + user_id: Option, + name: &str, + password: &str, + admin: bool, + enabled: bool, + ) -> Result { + match user_id { + None => { + let user_id = UserId::new(); + let mut stmt = self + .conn + .prepare("INSERT INTO users VALUES (?, ?, ?, ?, ?)") + .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; + stmt.execute((user_id.as_str(), name, password, admin, enabled)) + .unwrap(); + Ok(user_id) + } + Some(user_id) => { + let mut stmt = self + .conn + .prepare("UPDATE users SET name=?, password=?, admin=?, enabled=? WHERE uuid=?") + .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; + stmt.execute((name, password, admin, enabled, user_id.as_str())) + .unwrap(); + Ok(user_id) + } + } + } + + pub fn users(&self) -> Result, FatalError> { + let mut stmt = self + .conn + .prepare("SELECT * FROM users") + .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; + let items = stmt + .query_map([], |row| { + Ok(UserRow { + id: row.get(0).unwrap(), + name: row.get(1).unwrap(), + password: row.get(2).unwrap(), + admin: row.get(3).unwrap(), + enabled: row.get(4).unwrap(), + }) + }) + .unwrap() + .collect::, rusqlite::Error>>() + .unwrap(); + Ok(items) + } + + pub fn save_game(&self, game_id: Option, name: &str) -> Result { + match game_id { + None => { + let game_id = GameId::new(); + let mut stmt = self + .conn + .prepare("INSERT INTO games VALUES (?, ?)") + .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; + stmt.execute((game_id.as_str(), name)).unwrap(); + Ok(game_id) + } + Some(game_id) => { + let mut stmt = self + .conn + .prepare("UPDATE games SET name=? WHERE uuid=?") + .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; + stmt.execute((name, game_id.as_str())).unwrap(); + Ok(game_id) + } + } + } + + pub fn session(&self, session_id: &SessionId) -> Result, FatalError> { + let mut stmt = self.conn + .prepare("SELECT u.uuid, u.name, u.password, u.admin, u.enabled FROM sessions s INNER JOIN users u ON u.uuid = s.user_id WHERE s.id = ?") + .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; + + let items: Vec = stmt + .query_map([session_id.as_str()], |row| { + Ok(UserRow { + id: row.get(0).unwrap(), + name: row.get(1).unwrap(), + password: row.get(2).unwrap(), + admin: row.get(3).unwrap(), + enabled: row.get(4).unwrap(), + }) + }) + .unwrap() + .collect::, rusqlite::Error>>() + .unwrap(); + match &items[..] { + [] => Ok(None), + [item] => Ok(Some(item.clone())), + _ => Err(FatalError::NonUniqueDatabaseKey( + session_id.as_str().to_owned(), + )), + } + } + + fn create_session(&self, user_id: &UserId) -> Result { + match self.user(user_id) { + Ok(Some(_)) => { + let mut stmt = self + .conn + .prepare("INSERT INTO sessions VALUES (?, ?)") + .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; + + let session_id = SessionId::new(); + stmt.execute((session_id.as_str(), user_id.as_str())) + .unwrap(); + Ok(session_id) + } + Ok(None) => Err(FatalError::DatabaseKeyMissing), + Err(err) => Err(err), + } + } + + pub fn character(&self, id: CharacterId) -> Result, FatalError> { + let mut stmt = self + .conn + .prepare("SELECT uuid, game, data FROM characters WHERE uuid=?") + .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; + let items: Vec = stmt + .query_map([id.as_str()], |row| { + let data: String = row.get(2).unwrap(); + Ok(CharsheetRow { + id: row.get(0).unwrap(), + game: row.get(1).unwrap(), + data: serde_json::from_str(&data).unwrap(), + }) + }) + .unwrap() + .collect::, rusqlite::Error>>() + .unwrap(); + match &items[..] { + [] => Ok(None), + [item] => Ok(Some(item.clone())), + _ => Err(FatalError::NonUniqueDatabaseKey(id.as_str().to_owned())), + } + } + + pub fn save_character( + &self, + char_id: Option, + game: GameId, + character: serde_json::Value, + ) -> std::result::Result { + match char_id { + None => { + let char_id = CharacterId::new(); + let mut stmt = self + .conn + .prepare("INSERT INTO characters VALUES (?, ?, ?)") + .unwrap(); + stmt.execute((char_id.as_str(), game.as_str(), character.to_string())) + .unwrap(); + + Ok(char_id) + } + Some(char_id) => { + let mut stmt = self + .conn + .prepare("UPDATE characters SET data=? WHERE uuid=?") + .unwrap(); + stmt.execute((character.to_string(), char_id.as_str())) + .unwrap(); + + Ok(char_id) + } + } + } +} + +pub async fn db_handler(db: DiskDb, requestor: Receiver) { + while let Ok(DatabaseRequest { tx, req }) = requestor.recv().await { + match req { + Request::Charsheet(id) => { + let sheet = db.character(id); + match sheet { + Ok(sheet) => { + tx.send(DatabaseResponse::Charsheet(sheet)).await.unwrap(); + } + _ => unimplemented!(), + } + } + Request::CreateSession(id) => { + let session_id = db.create_session(&id).unwrap(); + tx.send(DatabaseResponse::CreateSession(session_id)) + .await + .unwrap(); + } + Request::Games => { + unimplemented!(); + } + Request::User(uid) => { + let user = db.user(&uid); + match user { + Ok(user) => { + tx.send(DatabaseResponse::User(user)).await.unwrap(); + } + err => panic!("{:?}", err), + } + } + Request::UserByUsername(username) => { + let user = db.user_by_username(&username); + match user { + Ok(user) => tx.send(DatabaseResponse::User(user)).await.unwrap(), + err => panic!("{:?}", err), + } + } + Request::SaveUser(user_id, username, password, admin, enabled) => { + let user_id = db.save_user( + user_id, + username.as_ref(), + password.as_ref(), + admin, + enabled, + ); + match user_id { + Ok(user_id) => { + tx.send(DatabaseResponse::SaveUser(user_id)).await.unwrap(); + } + err => panic!("{:?}", err), + } + } + Request::Session(session_id) => { + let user = db.session(&session_id); + match user { + Ok(user) => tx.send(DatabaseResponse::Session(user)).await.unwrap(), + err => panic!("{:?}", err), + } + } + Request::Users => { + let users = db.users(); + match users { + Ok(users) => { + tx.send(DatabaseResponse::Users(users)).await.unwrap(); + } + _ => unimplemented!(), + } + } + } + } +} diff --git a/visions/server/src/database/mod.rs b/visions/server/src/database/mod.rs index 8e27f4b..f195caa 100644 --- a/visions/server/src/database/mod.rs +++ b/visions/server/src/database/mod.rs @@ -1,26 +1,15 @@ +mod disk_db; +mod types; + use std::path::Path; -use async_std::channel::{bounded, Receiver, Sender}; +use async_std::channel::{bounded, Sender}; use async_trait::async_trait; -use include_dir::{include_dir, Dir}; -use lazy_static::lazy_static; -use rusqlite::{ - types::{FromSql, FromSqlResult, ValueRef}, - Connection, -}; -use rusqlite_migration::Migrations; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; +use disk_db::{db_handler, DiskDb}; +pub use types::{CharacterId, CharsheetRow, GameRow, SessionId, UserId, UserRow}; use crate::types::FatalError; -static MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/migrations"); - -lazy_static! { - static ref MIGRATIONS: Migrations<'static> = - Migrations::from_directory(&MIGRATIONS_DIR).unwrap(); -} - #[derive(Debug)] enum Request { Charsheet(CharacterId), @@ -50,180 +39,9 @@ enum DatabaseResponse { Users(Vec), } -#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] -pub struct UserId(String); - -impl UserId { - pub fn new() -> Self { - Self(format!("{}", Uuid::new_v4().hyphenated())) - } - - pub fn as_str<'a>(&'a self) -> &'a str { - &self.0 - } -} - -impl From<&str> for UserId { - fn from(s: &str) -> Self { - Self(s.to_owned()) - } -} - -impl From for UserId { - fn from(s: String) -> Self { - Self(s) - } -} - -impl FromSql for UserId { - fn column_result(value: ValueRef<'_>) -> FromSqlResult { - match value { - ValueRef::Text(text) => Ok(Self::from(String::from_utf8(text.to_vec()).unwrap())), - _ => unimplemented!(), - } - } -} - -#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] -pub struct SessionId(String); - -impl SessionId { - pub fn new() -> Self { - Self(format!("{}", Uuid::new_v4().hyphenated())) - } - - pub fn as_str<'a>(&'a self) -> &'a str { - &self.0 - } -} - -impl From<&str> for SessionId { - fn from(s: &str) -> Self { - Self(s.to_owned()) - } -} - -impl From for SessionId { - fn from(s: String) -> Self { - Self(s) - } -} - -impl FromSql for SessionId { - fn column_result(value: ValueRef<'_>) -> FromSqlResult { - match value { - ValueRef::Text(text) => Ok(Self::from(String::from_utf8(text.to_vec()).unwrap())), - _ => unimplemented!(), - } - } -} - -#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] -pub struct GameId(String); - -impl GameId { - pub fn new() -> Self { - Self(format!("{}", Uuid::new_v4().hyphenated())) - } - - pub fn as_str<'a>(&'a self) -> &'a str { - &self.0 - } -} - -impl From<&str> for GameId { - fn from(s: &str) -> Self { - Self(s.to_owned()) - } -} - -impl From for GameId { - fn from(s: String) -> Self { - Self(s) - } -} - -impl FromSql for GameId { - fn column_result(value: ValueRef<'_>) -> FromSqlResult { - match value { - ValueRef::Text(text) => Ok(Self::from(String::from_utf8(text.to_vec()).unwrap())), - _ => unimplemented!(), - } - } -} - -#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] -pub struct CharacterId(String); - -impl CharacterId { - pub fn new() -> Self { - Self(format!("{}", Uuid::new_v4().hyphenated())) - } - - pub fn as_str<'a>(&'a self) -> &'a str { - &self.0 - } -} - -impl From<&str> for CharacterId { - fn from(s: &str) -> Self { - Self(s.to_owned()) - } -} - -impl From for CharacterId { - fn from(s: String) -> Self { - Self(s) - } -} - -impl FromSql for CharacterId { - fn column_result(value: ValueRef<'_>) -> FromSqlResult { - match value { - ValueRef::Text(text) => Ok(Self::from(String::from_utf8(text.to_vec()).unwrap())), - _ => unimplemented!(), - } - } -} - -#[derive(Clone, Debug)] -pub struct UserRow { - pub id: UserId, - pub name: String, - pub password: String, - pub admin: bool, - pub enabled: bool, -} - -#[derive(Clone, Debug)] -pub struct Role { - userid: UserId, - gameid: GameId, - role: String, -} - -#[derive(Clone, Debug)] -pub struct GameRow { - pub id: UserId, - pub name: String, -} - -#[derive(Clone, Debug)] -pub struct CharsheetRow { - id: String, - game: GameId, - pub data: serde_json::Value, -} - -#[derive(Clone, Debug)] -pub struct SessionRow { - id: SessionId, - user_id: SessionId, -} - #[async_trait] pub trait Database: Send + Sync { - async fn user(&mut self, _: &UserId) -> Result, FatalError>; + async fn user(&self, _: &UserId) -> Result, FatalError>; async fn user_by_username(&self, _: &str) -> Result, FatalError>; @@ -247,328 +65,6 @@ pub trait Database: Send + Sync { async fn create_session(&self, id: UserId) -> Result; } -pub struct DiskDb { - conn: Connection, -} - -impl DiskDb { - pub fn new

(path: Option

) -> Result - where - P: AsRef, - { - let mut conn = match path { - None => Connection::open(":memory:").expect("to create a memory connection"), - Some(path) => Connection::open(path).expect("to create connection"), - }; - - MIGRATIONS - .to_latest(&mut conn) - .map_err(|err| FatalError::DatabaseMigrationFailure(format!("{}", err)))?; - - // setup_test_database(&conn)?; - - Ok(DiskDb { conn }) - } - - fn user(&self, id: &UserId) -> Result, FatalError> { - let mut stmt = self - .conn - .prepare("SELECT uuid, name, password, admin, enabled FROM users WHERE uuid=?") - .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; - let items: Vec = stmt - .query_map([id.as_str()], |row| { - Ok(UserRow { - id: row.get(0).unwrap(), - name: row.get(1).unwrap(), - password: row.get(2).unwrap(), - admin: row.get(3).unwrap(), - enabled: row.get(4).unwrap(), - }) - }) - .unwrap() - .collect::, rusqlite::Error>>() - .unwrap(); - match &items[..] { - [] => Ok(None), - [item] => Ok(Some(item.clone())), - _ => Err(FatalError::NonUniqueDatabaseKey(id.as_str().to_owned())), - } - } - - fn user_by_username(&self, username: &str) -> Result, FatalError> { - let mut stmt = self - .conn - .prepare("SELECT uuid, name, password, admin, enabled FROM users WHERE name=?") - .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; - let items: Vec = stmt - .query_map([username], |row| { - Ok(UserRow { - id: row.get(0).unwrap(), - name: row.get(1).unwrap(), - password: row.get(2).unwrap(), - admin: row.get(3).unwrap(), - enabled: row.get(4).unwrap(), - }) - }) - .unwrap() - .collect::, rusqlite::Error>>() - .unwrap(); - match &items[..] { - [] => Ok(None), - [item] => Ok(Some(item.clone())), - _ => Err(FatalError::NonUniqueDatabaseKey(username.to_owned())), - } - } - - fn users(&self) -> Result, FatalError> { - let mut stmt = self - .conn - .prepare("SELECT * FROM users") - .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; - let items = stmt - .query_map([], |row| { - Ok(UserRow { - id: row.get(0).unwrap(), - name: row.get(1).unwrap(), - password: row.get(2).unwrap(), - admin: row.get(3).unwrap(), - enabled: row.get(4).unwrap(), - }) - }) - .unwrap() - .collect::, rusqlite::Error>>() - .unwrap(); - Ok(items) - } - - fn save_user( - &self, - user_id: Option, - name: &str, - password: &str, - admin: bool, - enabled: bool, - ) -> Result { - match user_id { - None => { - let user_id = UserId::new(); - let mut stmt = self - .conn - .prepare("INSERT INTO users VALUES (?, ?, ?, ?, ?)") - .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; - stmt.execute((user_id.as_str(), name, password, admin, enabled)) - .unwrap(); - Ok(user_id) - } - Some(user_id) => { - let mut stmt = self - .conn - .prepare("UPDATE users SET name=?, password=?, admin=?, enabled=? WHERE uuid=?") - .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; - stmt.execute((name, password, admin, enabled, user_id.as_str())) - .unwrap(); - Ok(user_id) - } - } - } - - fn save_game(&self, game_id: Option, name: &str) -> Result { - match game_id { - None => { - let game_id = GameId::new(); - let mut stmt = self - .conn - .prepare("INSERT INTO games VALUES (?, ?)") - .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; - stmt.execute((game_id.as_str(), name)).unwrap(); - Ok(game_id) - } - Some(game_id) => { - let mut stmt = self - .conn - .prepare("UPDATE games SET name=? WHERE uuid=?") - .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; - stmt.execute((name, game_id.as_str())).unwrap(); - Ok(game_id) - } - } - } - - fn session(&self, session_id: &SessionId) -> Result, FatalError> { - let mut stmt = self.conn - .prepare("SELECT u.uuid, u.name, u.password, u.admin, u.enabled FROM sessions s INNER JOIN users u ON u.uuid = s.user_id WHERE s.id = ?") - .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; - - let items: Vec = stmt - .query_map([session_id.as_str()], |row| { - Ok(UserRow { - id: row.get(0).unwrap(), - name: row.get(1).unwrap(), - password: row.get(2).unwrap(), - admin: row.get(3).unwrap(), - enabled: row.get(4).unwrap(), - }) - }) - .unwrap() - .collect::, rusqlite::Error>>() - .unwrap(); - match &items[..] { - [] => Ok(None), - [item] => Ok(Some(item.clone())), - _ => Err(FatalError::NonUniqueDatabaseKey( - session_id.as_str().to_owned(), - )), - } - } - - fn create_session(&self, user_id: &UserId) -> Result { - match self.user(user_id) { - Ok(Some(_)) => { - let mut stmt = self - .conn - .prepare("INSERT INTO sessions VALUES (?, ?)") - .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; - - let session_id = SessionId::new(); - stmt.execute((session_id.as_str(), user_id.as_str())) - .unwrap(); - Ok(session_id) - } - Ok(None) => Err(FatalError::DatabaseKeyMissing), - Err(err) => Err(err), - } - } - - fn character(&self, id: CharacterId) -> Result, FatalError> { - let mut stmt = self - .conn - .prepare("SELECT uuid, game, data FROM characters WHERE uuid=?") - .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; - let items: Vec = stmt - .query_map([id.as_str()], |row| { - let data: String = row.get(2).unwrap(); - Ok(CharsheetRow { - id: row.get(0).unwrap(), - game: row.get(1).unwrap(), - data: serde_json::from_str(&data).unwrap(), - }) - }) - .unwrap() - .collect::, rusqlite::Error>>() - .unwrap(); - match &items[..] { - [] => Ok(None), - [item] => Ok(Some(item.clone())), - _ => Err(FatalError::NonUniqueDatabaseKey(id.as_str().to_owned())), - } - } - - fn save_character( - &self, - char_id: Option, - game: GameId, - character: serde_json::Value, - ) -> std::result::Result { - match char_id { - None => { - let char_id = CharacterId::new(); - let mut stmt = self - .conn - .prepare("INSERT INTO characters VALUES (?, ?, ?)") - .unwrap(); - stmt.execute((char_id.as_str(), game.as_str(), character.to_string())) - .unwrap(); - - Ok(char_id) - } - Some(char_id) => { - let mut stmt = self - .conn - .prepare("UPDATE characters SET data=? WHERE uuid=?") - .unwrap(); - stmt.execute((character.to_string(), char_id.as_str())) - .unwrap(); - - Ok(char_id) - } - } - } -} - -async fn db_handler(db: DiskDb, requestor: Receiver) { - while let Ok(DatabaseRequest { tx, req }) = requestor.recv().await { - println!("Request received: {:?}", req); - match req { - Request::Charsheet(id) => { - let sheet = db.character(id); - match sheet { - Ok(sheet) => { - tx.send(DatabaseResponse::Charsheet(sheet)).await.unwrap(); - } - _ => unimplemented!(), - } - } - Request::CreateSession(id) => { - let session_id = db.create_session(&id).unwrap(); - tx.send(DatabaseResponse::CreateSession(session_id)) - .await - .unwrap(); - } - Request::Games => { - unimplemented!(); - } - Request::User(uid) => { - let user = db.user(&uid); - match user { - Ok(user) => { - tx.send(DatabaseResponse::User(user)).await.unwrap(); - } - err => panic!("{:?}", err), - } - } - Request::UserByUsername(username) => { - let user = db.user_by_username(&username); - match user { - Ok(user) => tx.send(DatabaseResponse::User(user)).await.unwrap(), - err => panic!("{:?}", err), - } - } - Request::SaveUser(user_id, username, password, admin, enabled) => { - let user_id = db.save_user( - user_id, - username.as_ref(), - password.as_ref(), - admin, - enabled, - ); - match user_id { - Ok(user_id) => { - tx.send(DatabaseResponse::SaveUser(user_id)).await.unwrap(); - } - err => panic!("{:?}", err), - } - } - Request::Session(session_id) => { - let user = db.session(&session_id); - match user { - Ok(user) => tx.send(DatabaseResponse::Session(user)).await.unwrap(), - err => panic!("{:?}", err), - } - } - Request::Users => { - let users = db.users(); - match users { - Ok(users) => { - tx.send(DatabaseResponse::Users(users)).await.unwrap(); - } - _ => unimplemented!(), - } - } - } - } - println!("ending db_handler"); -} - pub struct DbConn { conn: Sender, handle: tokio::task::JoinHandle<()>, @@ -588,24 +84,28 @@ impl DbConn { Self { conn: tx, handle } } -} -#[async_trait] -impl Database for DbConn { - async fn user(&mut self, uid: &UserId) -> Result, FatalError> { + async fn send(&self, req: Request) -> Result { let (tx, rx) = bounded::(1); - - let request = DatabaseRequest { - tx, - req: Request::User(uid.clone()), - }; - + let request = DatabaseRequest { tx, req }; match self.conn.send(request).await { Ok(()) => (), Err(_) => return Err(FatalError::DatabaseConnectionLost), }; - match rx.recv().await { + rx.recv() + .await + .map_err(|_| FatalError::DatabaseConnectionLost) + } +} + +#[async_trait] +impl Database for DbConn { + async fn user(&self, uid: &UserId) -> Result, FatalError> { + match self + .send(Request::User(uid.clone())) + .await + { Ok(DatabaseResponse::User(user)) => Ok(user), Ok(_) => Err(FatalError::MessageMismatch), Err(_) => Err(FatalError::DatabaseConnectionLost), @@ -771,10 +271,12 @@ mod test { use std::path::PathBuf; use cool_asserts::assert_matches; + use disk_db::DiskDb; + use types::GameId; use super::*; - const soren: &'static str = r#"{ "type_": "Candela", "name": "Soren Jensen", "pronouns": "he/him", "circle": "Circle of the Bluest Sky", "style": "dapper gentleman", "catalyst": "a cursed book", "question": "What were the contents of that book?", "nerve": { "type_": "nerve", "drives": { "current": 1, "max": 2 }, "resistances": { "current": 0, "max": 3 }, "move": { "gilded": false, "score": 2 }, "strike": { "gilded": false, "score": 1 }, "control": { "gilded": true, "score": 0 } }, "cunning": { "type_": "cunning", "drives": { "current": 1, "max": 1 }, "resistances": { "current": 0, "max": 3 }, "sway": { "gilded": false, "score": 0 }, "read": { "gilded": false, "score": 0 }, "hide": { "gilded": false, "score": 0 } }, "intuition": { "type_": "intuition", "drives": { "current": 0, "max": 0 }, "resistances": { "current": 0, "max": 3 }, "survey": { "gilded": false, "score": 0 }, "focus": { "gilded": false, "score": 0 }, "sense": { "gilded": false, "score": 0 } }, "role": "Slink", "role_abilities": [ "Scout: If you have time to observe a location, you can spend 1 Intuition to ask a question: What do I notice here that others do not see? What in this place might be of use to us? What path should we follow?" ], "specialty": "Detective", "specialty_abilities": [ "Mind Palace: When you want to figure out how two clues might relate or what path they should point you towards, burn 1 Intution resistance. The GM will give you the information you have deduced." ] }"#; + const SOREN: &'static str = r#"{ "type_": "Candela", "name": "Soren Jensen", "pronouns": "he/him", "circle": "Circle of the Bluest Sky", "style": "dapper gentleman", "catalyst": "a cursed book", "question": "What were the contents of that book?", "nerve": { "type_": "nerve", "drives": { "current": 1, "max": 2 }, "resistances": { "current": 0, "max": 3 }, "move": { "gilded": false, "score": 2 }, "strike": { "gilded": false, "score": 1 }, "control": { "gilded": true, "score": 0 } }, "cunning": { "type_": "cunning", "drives": { "current": 1, "max": 1 }, "resistances": { "current": 0, "max": 3 }, "sway": { "gilded": false, "score": 0 }, "read": { "gilded": false, "score": 0 }, "hide": { "gilded": false, "score": 0 } }, "intuition": { "type_": "intuition", "drives": { "current": 0, "max": 0 }, "resistances": { "current": 0, "max": 3 }, "survey": { "gilded": false, "score": 0 }, "focus": { "gilded": false, "score": 0 }, "sense": { "gilded": false, "score": 0 } }, "role": "Slink", "role_abilities": [ "Scout: If you have time to observe a location, you can spend 1 Intuition to ask a question: What do I notice here that others do not see? What in this place might be of use to us? What path should we follow?" ], "specialty": "Detective", "specialty_abilities": [ "Mind Palace: When you want to figure out how two clues might relate or what path they should point you towards, burn 1 Intution resistance. The GM will give you the information you have deduced." ] }"#; fn setup_db() -> (DiskDb, GameId) { let no_path: Option = None; @@ -791,7 +293,7 @@ mod test { assert_matches!(db.character(CharacterId::from("1")), Ok(None)); - let js: serde_json::Value = serde_json::from_str(soren).unwrap(); + let js: serde_json::Value = serde_json::from_str(SOREN).unwrap(); let soren_id = db.save_character(None, game_id, js.clone()).unwrap(); assert_matches!(db.character(soren_id).unwrap(), Some(CharsheetRow{ data, .. }) => assert_eq!(js, data)); } diff --git a/visions/server/src/database/types.rs b/visions/server/src/database/types.rs new file mode 100644 index 0000000..9909365 --- /dev/null +++ b/visions/server/src/database/types.rs @@ -0,0 +1,175 @@ +use rusqlite::types::{FromSql, FromSqlResult, ValueRef}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +pub struct UserId(String); + +impl UserId { + pub fn new() -> Self { + Self(format!("{}", Uuid::new_v4().hyphenated())) + } + + pub fn as_str<'a>(&'a self) -> &'a str { + &self.0 + } +} + +impl From<&str> for UserId { + fn from(s: &str) -> Self { + Self(s.to_owned()) + } +} + +impl From for UserId { + fn from(s: String) -> Self { + Self(s) + } +} + +impl FromSql for UserId { + fn column_result(value: ValueRef<'_>) -> FromSqlResult { + match value { + ValueRef::Text(text) => Ok(Self::from(String::from_utf8(text.to_vec()).unwrap())), + _ => unimplemented!(), + } + } +} + +#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +pub struct SessionId(String); + +impl SessionId { + pub fn new() -> Self { + Self(format!("{}", Uuid::new_v4().hyphenated())) + } + + pub fn as_str<'a>(&'a self) -> &'a str { + &self.0 + } +} + +impl From<&str> for SessionId { + fn from(s: &str) -> Self { + Self(s.to_owned()) + } +} + +impl From for SessionId { + fn from(s: String) -> Self { + Self(s) + } +} + +impl FromSql for SessionId { + fn column_result(value: ValueRef<'_>) -> FromSqlResult { + match value { + ValueRef::Text(text) => Ok(Self::from(String::from_utf8(text.to_vec()).unwrap())), + _ => unimplemented!(), + } + } +} + +#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +pub struct GameId(String); + +impl GameId { + pub fn new() -> Self { + Self(format!("{}", Uuid::new_v4().hyphenated())) + } + + pub fn as_str<'a>(&'a self) -> &'a str { + &self.0 + } +} + +impl From<&str> for GameId { + fn from(s: &str) -> Self { + Self(s.to_owned()) + } +} + +impl From for GameId { + fn from(s: String) -> Self { + Self(s) + } +} + +impl FromSql for GameId { + fn column_result(value: ValueRef<'_>) -> FromSqlResult { + match value { + ValueRef::Text(text) => Ok(Self::from(String::from_utf8(text.to_vec()).unwrap())), + _ => unimplemented!(), + } + } +} + +#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +pub struct CharacterId(String); + +impl CharacterId { + pub fn new() -> Self { + Self(format!("{}", Uuid::new_v4().hyphenated())) + } + + pub fn as_str<'a>(&'a self) -> &'a str { + &self.0 + } +} + +impl From<&str> for CharacterId { + fn from(s: &str) -> Self { + Self(s.to_owned()) + } +} + +impl From for CharacterId { + fn from(s: String) -> Self { + Self(s) + } +} + +impl FromSql for CharacterId { + fn column_result(value: ValueRef<'_>) -> FromSqlResult { + match value { + ValueRef::Text(text) => Ok(Self::from(String::from_utf8(text.to_vec()).unwrap())), + _ => unimplemented!(), + } + } +} + +#[derive(Clone, Debug)] +pub struct UserRow { + pub id: UserId, + pub name: String, + pub password: String, + pub admin: bool, + pub enabled: bool, +} + +#[derive(Clone, Debug)] +pub struct Role { + userid: UserId, + gameid: GameId, + role: String, +} + +#[derive(Clone, Debug)] +pub struct GameRow { + pub id: UserId, + pub name: String, +} + +#[derive(Clone, Debug)] +pub struct CharsheetRow { + pub id: String, + pub game: GameId, + pub data: serde_json::Value, +} + +#[derive(Clone, Debug)] +pub struct SessionRow { + id: SessionId, + user_id: SessionId, +} + diff --git a/visions/server/src/handlers.rs b/visions/server/src/handlers.rs index 2865ff8..6752894 100644 --- a/visions/server/src/handlers.rs +++ b/visions/server/src/handlers.rs @@ -129,7 +129,7 @@ pub async fn handle_register_client(core: Core, _request: RegisterRequest) -> im pub async fn handle_unregister_client(core: Core, client_id: String) -> impl Reply { handler(async move { - core.unregister_client(client_id); + core.unregister_client(client_id).await; ok(Response::builder() .status(StatusCode::NO_CONTENT) diff --git a/visions/server/src/main.rs b/visions/server/src/main.rs index a6692ec..2ccde04 100644 --- a/visions/server/src/main.rs +++ b/visions/server/src/main.rs @@ -4,24 +4,18 @@ use std::{ path::PathBuf, }; -use asset_db::{AssetId, FsAssets}; +use asset_db::FsAssets; use authdb::AuthError; use database::DbConn; -use filters::{route_authenticate, route_available_images, route_get_charsheet, route_healthcheck, route_image, route_register_client, route_server_status, route_set_bg_image, route_unregister_client, route_websocket, routes_user_management}; -use warp::{ - // header, - filters::{method, path}, - http::{Response, StatusCode}, - reply::Reply, - Filter, -}; +use filters::{route_authenticate, route_healthcheck, route_image}; +use warp::{http::StatusCode, reply::Reply, Filter}; mod asset_db; mod core; mod database; +mod filters; mod handlers; mod types; -mod filters; #[derive(Debug)] struct Unauthorized; @@ -108,10 +102,12 @@ pub async fn main() { let unauthenticated_endpoints = route_healthcheck().or(route_authenticate(core.clone())); let authenticated_endpoints = route_image(core.clone()); - let server = warp::serve(unauthenticated_endpoints - .or(authenticated_endpoints) - .with(warp::log("visions")) - .recover(handle_rejection)); + let server = warp::serve( + unauthenticated_endpoints + .or(authenticated_endpoints) + .with(warp::log("visions")) + .recover(handle_rejection), + ); server .run(SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 8001)) .await; -- 2.47.1 From 085a82776e26a09bb54788c3acf96cf41e3c181c Mon Sep 17 00:00:00 2001 From: Savanni D'Gerinel Date: Fri, 27 Dec 2024 14:59:24 -0500 Subject: [PATCH 06/23] Write a macro that eases communication between DbConn and DiskDb --- visions/server/src/database/mod.rs | 155 +++++------------------------ 1 file changed, 23 insertions(+), 132 deletions(-) diff --git a/visions/server/src/database/mod.rs b/visions/server/src/database/mod.rs index f195caa..3743d54 100644 --- a/visions/server/src/database/mod.rs +++ b/visions/server/src/database/mod.rs @@ -84,52 +84,37 @@ impl DbConn { Self { conn: tx, handle } } +} - async fn send(&self, req: Request) -> Result { +macro_rules! send_request { + ($s:expr, $req:expr, $resp_h:pat => $block:expr) => {{ let (tx, rx) = bounded::(1); - let request = DatabaseRequest { tx, req }; - match self.conn.send(request).await { + let request = DatabaseRequest { tx, req: $req }; + match $s.conn.send(request).await { Ok(()) => (), Err(_) => return Err(FatalError::DatabaseConnectionLost), }; - rx.recv() + match rx + .recv() .await .map_err(|_| FatalError::DatabaseConnectionLost) - } + { + Ok($resp_h) => $block, + Ok(_) => Err(FatalError::MessageMismatch), + Err(_) => Err(FatalError::DatabaseConnectionLost), + } + }}; } #[async_trait] impl Database for DbConn { async fn user(&self, uid: &UserId) -> Result, FatalError> { - match self - .send(Request::User(uid.clone())) - .await - { - Ok(DatabaseResponse::User(user)) => Ok(user), - Ok(_) => Err(FatalError::MessageMismatch), - Err(_) => Err(FatalError::DatabaseConnectionLost), - } + send_request!(self, Request::User(uid.clone()), DatabaseResponse::User(user) => Ok(user)) } async fn user_by_username(&self, username: &str) -> Result, FatalError> { - let (tx, rx) = bounded::(1); - - let request = DatabaseRequest { - tx, - req: Request::UserByUsername(username.to_owned()), - }; - - match self.conn.send(request).await { - Ok(()) => (), - Err(_) => return Err(FatalError::DatabaseConnectionLost), - }; - - match rx.recv().await { - Ok(DatabaseResponse::User(user)) => Ok(user), - Ok(_) => Err(FatalError::MessageMismatch), - Err(_) => Err(FatalError::DatabaseConnectionLost), - } + send_request!(self, Request::UserByUsername(username.to_owned()), DatabaseResponse::User(user) => Ok(user)) } async fn save_user( @@ -140,129 +125,35 @@ impl Database for DbConn { admin: bool, enabled: bool, ) -> Result { - let (tx, rx) = bounded::(1); - - let request = DatabaseRequest { - tx, - req: Request::SaveUser( + send_request!(self, + Request::SaveUser( user_id, name.to_owned(), password.to_owned(), admin, enabled, ), - }; - - match self.conn.send(request).await { - Ok(()) => (), - Err(_) => return Err(FatalError::DatabaseConnectionLost), - }; - - match rx.recv().await { - Ok(DatabaseResponse::SaveUser(user_id)) => Ok(user_id), - Ok(_) => Err(FatalError::MessageMismatch), - Err(_) => Err(FatalError::DatabaseConnectionLost), - } + DatabaseResponse::SaveUser(user_id) => Ok(user_id)) } async fn users(&mut self) -> Result, FatalError> { - let (tx, rx) = bounded::(1); - - let request = DatabaseRequest { - tx, - req: Request::Users, - }; - - match self.conn.send(request).await { - Ok(()) => (), - Err(_) => return Err(FatalError::DatabaseConnectionLost), - }; - - match rx.recv().await { - Ok(DatabaseResponse::Users(lst)) => Ok(lst), - Ok(_) => Err(FatalError::MessageMismatch), - Err(_) => Err(FatalError::DatabaseConnectionLost), - } + send_request!(self, Request::Users, DatabaseResponse::Users(lst) => Ok(lst)) } async fn games(&mut self) -> Result, FatalError> { - let (tx, rx) = bounded::(1); - - let request = DatabaseRequest { - tx, - req: Request::Games, - }; - - match self.conn.send(request).await { - Ok(()) => (), - Err(_) => return Err(FatalError::DatabaseConnectionLost), - }; - - match rx.recv().await { - Ok(DatabaseResponse::Games(lst)) => Ok(lst), - Ok(_) => Err(FatalError::MessageMismatch), - Err(_) => Err(FatalError::DatabaseConnectionLost), - } + send_request!(self, Request::Games, DatabaseResponse::Games(lst) => Ok(lst)) } async fn character(&mut self, id: CharacterId) -> Result, FatalError> { - let (tx, rx) = bounded::(1); - - let request = DatabaseRequest { - tx, - req: Request::Charsheet(id), - }; - - match self.conn.send(request).await { - Ok(()) => (), - Err(_) => return Err(FatalError::DatabaseConnectionLost), - }; - - match rx.recv().await { - Ok(DatabaseResponse::Charsheet(row)) => Ok(row), - Ok(_) => Err(FatalError::MessageMismatch), - Err(_) => Err(FatalError::DatabaseConnectionLost), - } + send_request!(self, Request::Charsheet(id), DatabaseResponse::Charsheet(row) => Ok(row)) } async fn session(&self, id: SessionId) -> Result, FatalError> { - let (tx, rx) = bounded::(1); - - let request = DatabaseRequest { - tx, - req: Request::Session(id), - }; - - match self.conn.send(request).await { - Ok(()) => (), - Err(_) => return Err(FatalError::DatabaseConnectionLost), - }; - - match rx.recv().await { - Ok(DatabaseResponse::Session(row)) => Ok(row), - Ok(_) => Err(FatalError::MessageMismatch), - Err(_) => Err(FatalError::DatabaseConnectionLost), - } + send_request!(self, Request::Session(id), DatabaseResponse::Session(row) => Ok(row)) } async fn create_session(&self, id: UserId) -> Result { - let (tx, rx) = bounded::(1); - - let request = DatabaseRequest { - tx, - req: Request::CreateSession(id), - }; - - match self.conn.send(request).await { - Ok(()) => (), - Err(_) => return Err(FatalError::DatabaseConnectionLost), - }; - - match rx.recv().await { - Ok(DatabaseResponse::CreateSession(session_id)) => Ok(session_id), - Ok(_) => Err(FatalError::MessageMismatch), - Err(_) => Err(FatalError::DatabaseConnectionLost), - } + send_request!(self, Request::CreateSession(id), DatabaseResponse::CreateSession(session_id) => Ok(session_id)) } } -- 2.47.1 From e4c5ce0236a3198c8b475a7c9ca0d3df3efbfac6 Mon Sep 17 00:00:00 2001 From: Savanni D'Gerinel Date: Sun, 29 Dec 2024 23:39:43 -0500 Subject: [PATCH 07/23] Set up a bit of code that rejects requests that have no authorization header --- flake.nix | 1 + rust-toolchain | 1 + visions/server/src/filters/mod.rs | 10 ++ visions/server/src/filters/user_management.rs | 105 +++++++++++++++--- visions/server/src/handlers.rs | 5 +- visions/server/src/main.rs | 24 ---- 6 files changed, 106 insertions(+), 40 deletions(-) diff --git a/flake.nix b/flake.nix index 74e8908..3d8c97e 100644 --- a/flake.nix +++ b/flake.nix @@ -22,6 +22,7 @@ name = "ld-tools-devshell"; buildInputs = [ pkgs.cargo-nextest + pkgs.cargo-watch pkgs.clang pkgs.crate2nix pkgs.glib diff --git a/rust-toolchain b/rust-toolchain index 635cbe4..b96bf13 100644 --- a/rust-toolchain +++ b/rust-toolchain @@ -1,3 +1,4 @@ [toolchain] channel = "1.81.0" targets = [ "wasm32-unknown-unknown", "thumbv6m-none-eabi" ] +components = [ "rustfmt", "rust-analyzer", "clippy" ] diff --git a/visions/server/src/filters/mod.rs b/visions/server/src/filters/mod.rs index 06b5635..8964fb0 100644 --- a/visions/server/src/filters/mod.rs +++ b/visions/server/src/filters/mod.rs @@ -16,6 +16,16 @@ use crate::{ }, }; + +// Per-endpoint Authentication: +// +// If an endpoint requires authentication: +// - check the Authorization header for a token +// - if the token is absent or unknown, return a 403 +// - if the admin user is absent, return a 403, with a body that indicates the admin user is absent +// +// The login function does not require authentication, but it should return a session ID + fn cors(methods: Vec, headers: Vec) -> Builder where M: Into, diff --git a/visions/server/src/filters/user_management.rs b/visions/server/src/filters/user_management.rs index d63b809..6daca25 100644 --- a/visions/server/src/filters/user_management.rs +++ b/visions/server/src/filters/user_management.rs @@ -1,5 +1,8 @@ +use std::{convert::Infallible, future::Future}; + use warp::{ - http::{header::CONTENT_TYPE, HeaderName, Method}, + http::{header::CONTENT_TYPE, HeaderName, Method, Response, StatusCode}, + reject, reply::Reply, Filter, }; @@ -9,14 +12,41 @@ use crate::{ handlers::{handle_check_password, handle_get_users, handle_set_admin_password}, }; +async fn handle_rejection(err: warp::Rejection) -> Result { + println!("handle_rejection: {:?}", err); + if let Some(Unauthorized) = err.find() { + Ok(warp::reply::with_status( + "".to_owned(), + StatusCode::UNAUTHORIZED, + )) + } else { + Ok(warp::reply::with_status( + "".to_owned(), + StatusCode::INTERNAL_SERVER_ERROR, + )) + } +} + +#[derive(Debug)] +struct Unauthorized; + +impl reject::Reject for Unauthorized {} + use super::cors; fn route_get_users( core: Core, -) -> impl Filter + Clone { +) -> impl Filter>,), Error = warp::Rejection> + Clone { warp::path!("api" / "v1" / "users") .and(warp::get()) - .then(move || handle_get_users(core.clone())) + .and(warp::header::optional::("authorization")) + .and(warp::any().map(move || core.clone())) + .and_then(|auth_token, core: Core| async move { + match auth_token { + Some(token) => Ok(handle_get_users(core.clone()).await), + None => Err(warp::reject::custom(Unauthorized)), + } + }) } fn route_set_admin_password( @@ -25,10 +55,7 @@ fn route_set_admin_password( warp::path!("api" / "v1" / "admin_password") .and(warp::put()) .and(warp::body::json()) - .then({ - let core = core.clone(); - move |body| handle_set_admin_password(core.clone(), body) - }) + .then({ move |body| handle_set_admin_password(core.clone(), body) }) .with(cors(vec![Method::PUT], vec![CONTENT_TYPE])) } @@ -44,10 +71,7 @@ pub fn route_check_password( warp::path!("api" / "v1" / "auth") .and(warp::put()) .and(warp::body::json()) - .then({ - let core = core.clone(); - move |body| handle_check_password(core.clone(), body) - }) + .then({ move |body| handle_check_password(core.clone(), body) }) .with(cors::(vec![Method::PUT], vec![])) } @@ -56,8 +80,12 @@ mod test { use std::{collections::HashMap, path::PathBuf}; use result_extended::ResultExt; + use warp::http::StatusCode; - use crate::{asset_db::mocks::MemoryAssets, database::{Database, DbConn, UserId}}; + use crate::{ + asset_db::mocks::MemoryAssets, + database::{Database, DbConn, UserId}, + }; use super::*; @@ -79,8 +107,10 @@ mod test { } match core.user_by_username("admin").await { ResultExt::Ok(Some(user)) => { - let _ = core.set_password(UserId::from(user.id), "aoeu".to_owned()).await; - }, + let _ = core + .set_password(UserId::from(user.id), "aoeu".to_owned()) + .await; + } ResultExt::Ok(None) => panic!("expected user wasn't found"), ResultExt::Err(err) => panic!("{}", err), ResultExt::Fatal(err) => panic!("{}", err), @@ -101,7 +131,52 @@ mod test { println!("response: {}", resp.status()); assert!(resp.status().is_success()); - println!("resp.body(): {}", String::from_utf8(resp.body().to_vec()).unwrap()); + println!( + "resp.body(): {}", + String::from_utf8(resp.body().to_vec()).unwrap() + ); serde_json::from_slice::(resp.body()).unwrap(); } + + /* + #[tokio::test] + async fn handle_check_auth_token() { + let core = setup().await; + let filter = route_get_users(core); + let response = warp::test::request() + .method("GET") + .path("/api/v1/users") + .header("Authorization", "abcdefg") + .reply(&filter) + .await; + + println!("response: {}", response.status()); + assert!(false); + } + */ + + #[tokio::test] + async fn it_rejects_unauthorized_requests() { + let core = setup().await; + let filter = route_get_users(core) + .recover(handle_rejection); + let response = warp::test::request() + .method("GET") + .path("/api/v1/users") + .reply(&filter) + .await; + + println!("response: {:?}", response); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn it_accepts_authorized_requests() { + unimplemented!(); + } + + #[tokio::test] + async fn it_returns_special_response_for_no_admin() { + unimplemented!(); + } } diff --git a/visions/server/src/handlers.rs b/visions/server/src/handlers.rs index 6752894..672ce13 100644 --- a/visions/server/src/handlers.rs +++ b/visions/server/src/handlers.rs @@ -185,7 +185,9 @@ pub async fn handle_set_background_image(core: Core, image_name: String) -> impl .await } -pub async fn handle_get_users(core: Core) -> impl Reply { +pub async fn handle_get_users(core: Core) -> Response> { + unimplemented!() + /* handler(async move { let users = match core.list_users().await { ResultExt::Ok(users) => users, @@ -200,6 +202,7 @@ pub async fn handle_get_users(core: Core) -> impl Reply { .unwrap()) }) .await + */ } pub async fn handle_get_games(core: Core) -> impl Reply { diff --git a/visions/server/src/main.rs b/visions/server/src/main.rs index 2ccde04..6f2d518 100644 --- a/visions/server/src/main.rs +++ b/visions/server/src/main.rs @@ -17,14 +17,6 @@ mod filters; mod handlers; mod types; -#[derive(Debug)] -struct Unauthorized; -impl warp::reject::Reject for Unauthorized {} - -#[derive(Debug)] -struct AuthDBError(AuthError); -impl warp::reject::Reject for AuthDBError {} - /* fn with_session( auth_ctx: Arc, @@ -76,21 +68,6 @@ fn route_echo_authenticated( } */ -async fn handle_rejection(err: warp::Rejection) -> Result { - println!("handle_rejection: {:?}", err); - if let Some(Unauthorized) = err.find() { - Ok(warp::reply::with_status( - "".to_owned(), - StatusCode::UNAUTHORIZED, - )) - } else { - Ok(warp::reply::with_status( - "".to_owned(), - StatusCode::INTERNAL_SERVER_ERROR, - )) - } -} - #[tokio::main] pub async fn main() { pretty_env_logger::init(); @@ -106,7 +83,6 @@ pub async fn main() { unauthenticated_endpoints .or(authenticated_endpoints) .with(warp::log("visions")) - .recover(handle_rejection), ); server .run(SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 8001)) -- 2.47.1 From c31870367fd15fccdb1524e375c2337e966715bd Mon Sep 17 00:00:00 2001 From: Savanni D'Gerinel Date: Mon, 30 Dec 2024 21:05:31 -0500 Subject: [PATCH 08/23] Switch to Axum and implement the password check --- Cargo.lock | 178 +++++++++++++++++- visions/server/Cargo.toml | 5 +- visions/server/src/filters/mod.rs | 16 +- visions/server/src/filters/user_management.rs | 16 +- .../src/{handlers.rs => handlers/mod.rs} | 38 +++- visions/server/src/main.rs | 97 +++------- 6 files changed, 248 insertions(+), 102 deletions(-) rename visions/server/src/{handlers.rs => handlers/mod.rs} (89%) diff --git a/Cargo.lock b/Cargo.lock index 075a08b..1b248de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -290,6 +290,61 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http 1.1.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.5.2", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde 1.0.210", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper 1.0.2", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 1.1.0", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper 1.0.2", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "az" version = "1.2.1" @@ -1883,6 +1938,29 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.1.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" +dependencies = [ + "bytes", + "futures-util", + "http 1.1.0", + "http-body 1.0.1", + "pin-project-lite", +] + [[package]] name = "httparse" version = "1.9.5" @@ -1913,7 +1991,7 @@ dependencies = [ "futures-util", "h2", "http 0.2.12", - "http-body", + "http-body 0.4.6", "httparse", "httpdate", "itoa", @@ -1925,6 +2003,25 @@ dependencies = [ "want", ] +[[package]] +name = "hyper" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "256fb8d4bd6413123cc9d91832d78325c48ff41677595be797d90f42969beae0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http 1.1.0", + "http-body 1.0.1", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + [[package]] name = "hyper-tls" version = "0.5.0" @@ -1932,12 +2029,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" dependencies = [ "bytes", - "hyper", + "hyper 0.14.30", "native-tls", "tokio", "tokio-native-tls", ] +[[package]] +name = "hyper-util" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4" +dependencies = [ + "bytes", + "futures-util", + "http 1.1.0", + "http-body 1.0.1", + "hyper 1.5.2", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "iana-time-zone" version = "0.1.61" @@ -2284,6 +2397,12 @@ dependencies = [ "value-bag", ] +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "md-5" version = "0.10.6" @@ -3118,8 +3237,8 @@ dependencies = [ "futures-util", "h2", "http 0.2.12", - "http-body", - "hyper", + "http-body 0.4.6", + "hyper 0.14.30", "hyper-tls", "ipnet", "js-sys", @@ -3133,7 +3252,7 @@ dependencies = [ "serde 1.0.210", "serde_json", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 0.1.2", "system-configuration", "tokio", "tokio-native-tls", @@ -3240,6 +3359,12 @@ dependencies = [ "base64 0.21.7", ] +[[package]] +name = "rustversion" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4" + [[package]] name = "rusty-fork" version = "0.3.0" @@ -3369,6 +3494,16 @@ dependencies = [ "serde 1.0.210", ] +[[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 1.0.210", +] + [[package]] name = "serde_spanned" version = "0.6.8" @@ -3800,6 +3935,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + [[package]] name = "system-configuration" version = "0.5.1" @@ -4065,6 +4206,28 @@ dependencies = [ "winnow", ] +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper 1.0.2", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + [[package]] name = "tower-service" version = "0.3.3" @@ -4336,9 +4499,9 @@ dependencies = [ "async-std", "async-trait", "authdb", + "axum", "cool_asserts", "futures", - "http 1.1.0", "include_dir", "lazy_static", "mime", @@ -4355,7 +4518,6 @@ dependencies = [ "typeshare", "urlencoding", "uuid 1.11.0", - "warp", ] [[package]] @@ -4387,7 +4549,7 @@ dependencies = [ "futures-util", "headers", "http 0.2.12", - "hyper", + "hyper 0.14.30", "log", "mime", "mime_guess", diff --git a/visions/server/Cargo.toml b/visions/server/Cargo.toml index c9339a8..fd52875 100644 --- a/visions/server/Cargo.toml +++ b/visions/server/Cargo.toml @@ -9,13 +9,13 @@ edition = "2021" async-std = { version = "1.13.0" } async-trait = { version = "0.1.83" } authdb = { path = "../../authdb/" } +axum = { version = "0.7.9" } futures = { version = "0.3.31" } -http = { version = "1" } include_dir = { version = "0.7.4" } lazy_static = { version = "1.5.0" } mime = { version = "0.3.17" } mime_guess = { version = "2.0.5" } -pretty_env_logger = "0.5.0" +pretty_env_logger = { version = "0.5.0" } result-extended = { path = "../../result-extended" } rusqlite = { version = "0.32.1" } rusqlite_migration = { version = "1.3.1", features = ["from-directory"] } @@ -27,7 +27,6 @@ tokio-stream = { version = "0.1.16" } typeshare = { version = "1.0.4" } urlencoding = { version = "2.1.3" } uuid = { version = "1.11.0", features = ["v4"] } -warp = { version = "0.3" } [dev-dependencies] cool_asserts = "2.0.3" diff --git a/visions/server/src/filters/mod.rs b/visions/server/src/filters/mod.rs index 8964fb0..a433057 100644 --- a/visions/server/src/filters/mod.rs +++ b/visions/server/src/filters/mod.rs @@ -1,19 +1,9 @@ mod user_management; -pub use user_management::routes_user_management; - -use warp::{ - filters::cors::Builder, - http::{header::CONTENT_TYPE, HeaderName, Method, Response}, - reply::*, - Filter, -}; +// pub use user_management::routes_user_management; use crate::{ asset_db::AssetId, core::Core, - handlers::{ - handle_available_images, handle_check_password, handle_connect_websocket, handle_file, handle_get_charsheet, handle_register_client, handle_server_status, handle_set_background_image, handle_unregister_client, RegisterRequest - }, }; @@ -26,6 +16,7 @@ use crate::{ // // The login function does not require authentication, but it should return a session ID +/* fn cors(methods: Vec, headers: Vec) -> Builder where M: Into, @@ -36,7 +27,9 @@ where .allow_methods(methods) .allow_headers(headers) } +*/ +/* pub fn route_healthcheck() -> impl Filter + Clone { warp::path!("api" / "v1" / "healthcheck") @@ -142,3 +135,4 @@ pub fn route_authenticate( }) .with(cors::(vec![Method::PUT], vec![])) } +*/ diff --git a/visions/server/src/filters/user_management.rs b/visions/server/src/filters/user_management.rs index 6daca25..1a80bb9 100644 --- a/visions/server/src/filters/user_management.rs +++ b/visions/server/src/filters/user_management.rs @@ -1,17 +1,10 @@ use std::{convert::Infallible, future::Future}; -use warp::{ - http::{header::CONTENT_TYPE, HeaderName, Method, Response, StatusCode}, - reject, - reply::Reply, - Filter, -}; - use crate::{ core::Core, - handlers::{handle_check_password, handle_get_users, handle_set_admin_password}, }; +/* async fn handle_rejection(err: warp::Rejection) -> Result { println!("handle_rejection: {:?}", err); if let Some(Unauthorized) = err.find() { @@ -26,7 +19,9 @@ async fn handle_rejection(err: warp::Rejection) -> Result impl Filter + Clone { route_get_users(core.clone()).or(route_set_admin_password(core.clone())) } +*/ +/* pub fn route_check_password( core: Core, ) -> impl Filter + Clone { @@ -74,7 +71,9 @@ pub fn route_check_password( .then({ move |body| handle_check_password(core.clone(), body) }) .with(cors::(vec![Method::PUT], vec![])) } +*/ +/* #[cfg(test)] mod test { use std::{collections::HashMap, path::PathBuf}; @@ -180,3 +179,4 @@ mod test { unimplemented!(); } } +*/ diff --git a/visions/server/src/handlers.rs b/visions/server/src/handlers/mod.rs similarity index 89% rename from visions/server/src/handlers.rs rename to visions/server/src/handlers/mod.rs index 672ce13..b7ad690 100644 --- a/visions/server/src/handlers.rs +++ b/visions/server/src/handlers/mod.rs @@ -4,15 +4,47 @@ use futures::{SinkExt, StreamExt}; use result_extended::{error, ok, return_error, ResultExt}; use serde::{Deserialize, Serialize}; use typeshare::typeshare; -use warp::{http::Response, http::StatusCode, reply::Reply, ws::Message}; +use axum::{http::StatusCode, Json}; use crate::{ asset_db::AssetId, core::Core, - database::{CharacterId, UserId}, + database::{CharacterId, UserId, SessionId}, types::{AppError, FatalError}, }; +#[derive(Serialize)] +struct HealthCheck { + ok: bool, +} + +pub async fn healthcheck(core: Core) -> Vec { + match core.status().await { + ResultExt::Ok(s) => serde_json::to_vec(&HealthCheck { + ok: s.admin_enabled, + }) + .unwrap(), + ResultExt::Err(_) => serde_json::to_vec(&HealthCheck { ok: false }).unwrap(), + ResultExt::Fatal(err) => panic!("{}", err), + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[typeshare] +pub struct AuthRequest { + username: String, + password: String, +} + +pub async fn check_password(core: Core, req: Json) -> (StatusCode, Json>) { + let Json(AuthRequest { username, password }) = req; + match core.auth(&username, &password).await { + ResultExt::Ok(session_id) => (StatusCode::OK, Json(Some(session_id))), + ResultExt::Err(err) => (StatusCode::UNAUTHORIZED, Json(None)), + ResultExt::Fatal(err) => panic!("Fatal: {}", err), + } +} + /* pub async fn handle_auth( auth_ctx: &AuthDB, @@ -37,6 +69,7 @@ pub async fn handle_auth( } */ +/* pub async fn handler(f: F) -> impl Reply where F: Future>, AppError, FatalError>>, @@ -285,3 +318,4 @@ pub async fn handle_check_password(core: Core, auth_request: AuthRequest) -> imp }) .await } +*/ diff --git a/visions/server/src/main.rs b/visions/server/src/main.rs index 6f2d518..2ed2b87 100644 --- a/visions/server/src/main.rs +++ b/visions/server/src/main.rs @@ -1,90 +1,47 @@ -use std::{ - convert::Infallible, - net::{IpAddr, Ipv4Addr, SocketAddr}, - path::PathBuf, -}; +use core::Core; +use std::path::PathBuf; use asset_db::FsAssets; -use authdb::AuthError; +use axum::{routing::{get, post}, Router, Json}; use database::DbConn; -use filters::{route_authenticate, route_healthcheck, route_image}; -use warp::{http::StatusCode, reply::Reply, Filter}; mod asset_db; mod core; mod database; mod filters; mod handlers; +use handlers::{ healthcheck, check_password, AuthRequest}; mod types; -/* -fn with_session( - auth_ctx: Arc, -) -> impl Filter + Clone { - header("authentication").and_then({ - move |value: String| { - let auth_ctx = auth_ctx.clone(); - async move { - match auth_ctx.validate_session(SessionToken::from(value)).await { - Ok(Some(username)) => Ok(username), - Ok(None) => Err(warp::reject::custom(Unauthorized)), - Err(err) => Err(warp::reject::custom(AuthDBError(err))), - } - } - } - }) -} - -fn route_echo_unauthenticated() -> impl Filter + Clone { - warp::path!("api" / "v1" / "echo" / String).map(|param: String| { - println!("param: {}", param); - warp::reply::json(&vec!["unauthenticated", param.as_str()]) - }) -} - -fn route_authenticate( - auth_ctx: Arc, -) -> impl Filter + Clone { - let auth_ctx = auth_ctx.clone(); - warp::path!("api" / "v1" / "auth") - .and(warp::post()) - .and(warp::body::json()) - .map(move |param: AuthToken| { - let res = handle_auth(&auth_ctx, param.clone()); - warp::reply::json(¶m) - }) -} - -fn route_echo_authenticated( - auth_ctx: Arc, -) -> impl Filter + Clone { - warp::path!("api" / "v1" / "echo" / String) - .and(with_session(auth_ctx.clone())) - .map(move |param: String, username: Username| { - println!("param: {:?}", username); - println!("param: {}", param); - warp::reply::json(&vec!["authenticated", username.as_str(), param.as_str()]) - }) -} -*/ - #[tokio::main] pub async fn main() { + /* pretty_env_logger::init(); - let conn = DbConn::new(Some("/home/savanni/game.db")); - - let core = core::Core::new(FsAssets::new(PathBuf::from("/home/savanni/Pictures")), conn); - let unauthenticated_endpoints = route_healthcheck().or(route_authenticate(core.clone())); let authenticated_endpoints = route_image(core.clone()); + */ - let server = warp::serve( - unauthenticated_endpoints - .or(authenticated_endpoints) - .with(warp::log("visions")) + let conn = DbConn::new(Some("/home/savanni/game.db")); + let core = Core::new(FsAssets::new(PathBuf::from("/home/savanni/Pictures")), conn); + + let app = Router::new().route( + "/api/v1/health", + get({ + let core = core.clone(); + move || healthcheck(core) + }), + ).route( + "/api/v1/auth", + post({ + let core = core.clone(); + move |req: Json| handlers::check_password(core, req) + }), ); - server - .run(SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 8001)) - .await; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:8001") + .await + .unwrap(); + + axum::serve(listener, app).await.unwrap(); } -- 2.47.1 From a18cdb071034ff0ad70c9224aa10d8e3d1e3f483 Mon Sep 17 00:00:00 2001 From: Savanni D'Gerinel Date: Tue, 31 Dec 2024 01:12:26 -0500 Subject: [PATCH 09/23] Create a test for the healthcheck endpoint --- Cargo.lock | 193 +++++++++++++++++++++++++++-- visions/server/Cargo.toml | 1 + visions/server/src/handlers/mod.rs | 8 +- visions/server/src/main.rs | 19 +-- visions/server/src/routes.rs | 54 ++++++++ 5 files changed, 242 insertions(+), 33 deletions(-) create mode 100644 visions/server/src/routes.rs diff --git a/Cargo.lock b/Cargo.lock index 1b248de..532f500 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -130,6 +130,16 @@ version = "1.0.89" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86fdf8605db99b54d3cd748a44c6d04df638eb5dafb219b135d0149bd0db01f6" +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde 1.0.210", + "serde_json", +] + [[package]] name = "async-channel" version = "1.9.0" @@ -284,6 +294,12 @@ dependencies = [ "uuid 0.4.0", ] +[[package]] +name = "auto-future" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c1e7e457ea78e524f48639f551fd79703ac3f2237f5ecccdf4708f8a75ad373" + [[package]] name = "autocfg" version = "1.4.0" @@ -300,7 +316,7 @@ dependencies = [ "axum-core", "bytes", "futures-util", - "http 1.1.0", + "http 1.2.0", "http-body 1.0.1", "http-body-util", "hyper 1.5.2", @@ -333,7 +349,7 @@ dependencies = [ "async-trait", "bytes", "futures-util", - "http 1.1.0", + "http 1.2.0", "http-body 1.0.1", "http-body-util", "mime", @@ -345,6 +361,36 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-test" +version = "16.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e3a443d2608936a02a222da7b746eb412fede7225b3030b64fe9be99eab8dc" +dependencies = [ + "anyhow", + "assert-json-diff", + "auto-future", + "axum", + "bytes", + "bytesize", + "cookie", + "http 1.2.0", + "http-body-util", + "hyper 1.5.2", + "hyper-util", + "mime", + "pretty_assertions", + "reserve-port", + "rust-multipart-rfc7578_2", + "serde 1.0.210", + "serde_json", + "serde_urlencoded", + "smallvec", + "tokio", + "tower", + "url", +] + [[package]] name = "az" version = "1.2.1" @@ -483,9 +529,15 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.7.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "428d9aa8fbc0670b7b8d6030a7fadd0f86151cae55e4dbbece15f3780a3dfaf3" +checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" + +[[package]] +name = "bytesize" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e368af43e418a04d52505cf3dbc23dda4e3407ae2fa99fd0e4f308ce546acc" [[package]] name = "cairo-rs" @@ -697,6 +749,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + [[package]] name = "cookie-factory" version = "0.3.3" @@ -901,6 +963,21 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.10.7" @@ -1918,9 +1995,9 @@ dependencies = [ [[package]] name = "http" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" +checksum = "f16ca2af56261c99fba8bac40a10251ce8188205a4c448fbb745a2e4daa76fea" dependencies = [ "bytes", "fnv", @@ -1945,7 +2022,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.1.0", + "http 1.2.0", ] [[package]] @@ -1956,7 +2033,7 @@ checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" dependencies = [ "bytes", "futures-util", - "http 1.1.0", + "http 1.2.0", "http-body 1.0.1", "pin-project-lite", ] @@ -2012,7 +2089,7 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http 1.1.0", + "http 1.2.0", "http-body 1.0.1", "httparse", "httpdate", @@ -2020,6 +2097,7 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", + "want", ] [[package]] @@ -2042,13 +2120,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4" dependencies = [ "bytes", + "futures-channel", "futures-util", - "http 1.1.0", + "http 1.2.0", "http-body 1.0.1", "hyper 1.5.2", "pin-project-lite", + "socket2", "tokio", "tower-service", + "tracing", ] [[package]] @@ -2589,6 +2670,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num-integer" version = "0.1.46" @@ -2964,6 +3051,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.20" @@ -2973,6 +3066,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "pretty_env_logger" version = "0.5.0" @@ -3264,6 +3367,16 @@ dependencies = [ "winreg", ] +[[package]] +name = "reserve-port" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9838134a2bfaa8e1f40738fcc972ac799de6e0e06b5157acb95fc2b05a0ea283" +dependencies = [ + "lazy_static", + "thiserror 1.0.64", +] + [[package]] name = "result-extended" version = "0.1.0" @@ -3316,6 +3429,22 @@ dependencies = [ "rusqlite", ] +[[package]] +name = "rust-multipart-rfc7578_2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03b748410c0afdef2ebbe3685a6a862e2ee937127cdaae623336a459451c8d57" +dependencies = [ + "bytes", + "futures-core", + "futures-util", + "http 0.2.12", + "mime", + "mime_guess", + "rand 0.8.5", + "thiserror 1.0.64", +] + [[package]] name = "rustc-demangle" version = "0.1.24" @@ -4054,6 +4183,37 @@ dependencies = [ "weezl", ] +[[package]] +name = "time" +version = "0.3.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde 1.0.210", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2834e6017e3e5e4b9834939793b282bc03b37a3336245fa820e35e233e2a85de" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "timezone-testing" version = "0.1.0" @@ -4088,9 +4248,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.40.0" +version = "1.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2b070231665d27ad9ec9b8df639893f46727666c6767db40317fbe920a5d998" +checksum = "5cec9b21b0450273377fc97bd4c33a8acffc8c996c987a7c5b319a0083707551" dependencies = [ "backtrace", "bytes", @@ -4285,7 +4445,7 @@ dependencies = [ "byteorder", "bytes", "data-encoding", - "http 1.1.0", + "http 1.2.0", "httparse", "log", "rand 0.8.5", @@ -4500,6 +4660,7 @@ dependencies = [ "async-trait", "authdb", "axum", + "axum-test", "cool_asserts", "futures", "include_dir", @@ -4879,6 +5040,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yansi-term" version = "0.1.2" diff --git a/visions/server/Cargo.toml b/visions/server/Cargo.toml index fd52875..8d1d27e 100644 --- a/visions/server/Cargo.toml +++ b/visions/server/Cargo.toml @@ -10,6 +10,7 @@ async-std = { version = "1.13.0" } async-trait = { version = "0.1.83" } authdb = { path = "../../authdb/" } axum = { version = "0.7.9" } +axum-test = "16.4.1" futures = { version = "0.3.31" } include_dir = { version = "0.7.4" } lazy_static = { version = "1.5.0" } diff --git a/visions/server/src/handlers/mod.rs b/visions/server/src/handlers/mod.rs index b7ad690..6d33a58 100644 --- a/visions/server/src/handlers/mod.rs +++ b/visions/server/src/handlers/mod.rs @@ -13,9 +13,9 @@ use crate::{ types::{AppError, FatalError}, }; -#[derive(Serialize)] -struct HealthCheck { - ok: bool, +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +pub struct HealthCheck { + pub ok: bool, } pub async fn healthcheck(core: Core) -> Vec { @@ -40,7 +40,7 @@ pub async fn check_password(core: Core, req: Json) -> (StatusCode, let Json(AuthRequest { username, password }) = req; match core.auth(&username, &password).await { ResultExt::Ok(session_id) => (StatusCode::OK, Json(Some(session_id))), - ResultExt::Err(err) => (StatusCode::UNAUTHORIZED, Json(None)), + ResultExt::Err(_err) => (StatusCode::UNAUTHORIZED, Json(None)), ResultExt::Fatal(err) => panic!("Fatal: {}", err), } } diff --git a/visions/server/src/main.rs b/visions/server/src/main.rs index 2ed2b87..84524e6 100644 --- a/visions/server/src/main.rs +++ b/visions/server/src/main.rs @@ -2,15 +2,14 @@ use core::Core; use std::path::PathBuf; use asset_db::FsAssets; -use axum::{routing::{get, post}, Router, Json}; use database::DbConn; mod asset_db; mod core; mod database; mod filters; -mod handlers; -use handlers::{ healthcheck, check_password, AuthRequest}; +pub mod handlers; +pub mod routes; mod types; #[tokio::main] @@ -25,19 +24,7 @@ pub async fn main() { let conn = DbConn::new(Some("/home/savanni/game.db")); let core = Core::new(FsAssets::new(PathBuf::from("/home/savanni/Pictures")), conn); - let app = Router::new().route( - "/api/v1/health", - get({ - let core = core.clone(); - move || healthcheck(core) - }), - ).route( - "/api/v1/auth", - post({ - let core = core.clone(); - move |req: Json| handlers::check_password(core, req) - }), - ); + let app = routes::routes(core); let listener = tokio::net::TcpListener::bind("127.0.0.1:8001") .await diff --git a/visions/server/src/routes.rs b/visions/server/src/routes.rs new file mode 100644 index 0000000..c644c58 --- /dev/null +++ b/visions/server/src/routes.rs @@ -0,0 +1,54 @@ +use axum::{routing::{get, post}, Json, Router}; + +use crate::{ + core::Core, + handlers::{check_password, healthcheck, AuthRequest}, +}; + +pub fn routes(core: Core) -> Router { + Router::new() + .route( + "/api/v1/health", + get({ + let core = core.clone(); + move || healthcheck(core) + }), + ) + .route( + "/api/v1/auth", + post({ + let core = core.clone(); + move |req: Json| check_password(core, req) + }), + ) +} + +#[cfg(test)] +mod test { + use std::path::PathBuf; + + use axum_test::TestServer; + + use crate::{asset_db::FsAssets, core::Core, database::DbConn}; + use super::*; + + fn setup() -> (Core, TestServer) { + let memory_db: Option = None; + let conn = DbConn::new(memory_db); + let core = Core::new(FsAssets::new(PathBuf::from("/home/savanni/Pictures")), conn); + let app = routes(core.clone()); + let server = TestServer::new(app).unwrap(); + (core, server) + } + + #[tokio::test] + async fn it_returns_a_healthcheck() { + let (_core, server) = setup(); + let response = server.get("/api/v1/health").await; + response.assert_status_ok(); + + let b: crate::handlers::HealthCheck = response.json(); + + assert_eq!(b, crate::handlers::HealthCheck { ok: false }); + } +} -- 2.47.1 From a33b94e5b310212edd3cd925fe951e8dac6b8da8 Mon Sep 17 00:00:00 2001 From: Savanni D'Gerinel Date: Tue, 31 Dec 2024 11:47:24 -0500 Subject: [PATCH 10/23] Resolve many warnings --- visions/server/Taskfile.yml | 10 +++++++++- visions/server/src/asset_db.rs | 14 +++++++------- visions/server/src/core.rs | 18 +++++++++--------- visions/server/src/database/types.rs | 8 ++++---- visions/server/src/types.rs | 4 ++-- 5 files changed, 31 insertions(+), 23 deletions(-) diff --git a/visions/server/Taskfile.yml b/visions/server/Taskfile.yml index d54c919..df7c99e 100644 --- a/visions/server/Taskfile.yml +++ b/visions/server/Taskfile.yml @@ -7,9 +7,17 @@ tasks: test: cmds: - # - cargo watch -x 'test -- --nocapture' - cargo watch -x 'nextest run' dev: cmds: - cargo watch -x run + + lint: + cmds: + - cargo watch -x clippy + + release: + cmds: + - task lint + - cargo build --release diff --git a/visions/server/src/asset_db.rs b/visions/server/src/asset_db.rs index 7b7a786..c84e5c9 100644 --- a/visions/server/src/asset_db.rs +++ b/visions/server/src/asset_db.rs @@ -15,7 +15,7 @@ pub enum Error { Inaccessible, #[error("An unexpected IO error occured when retrieving an asset {0}")] - UnexpectedError(std::io::Error), + Unexpected(std::io::Error), } impl From for Error { @@ -25,7 +25,7 @@ impl From for Error { match err.kind() { NotFound => Error::NotFound, PermissionDenied | UnexpectedEof => Error::Inaccessible, - _ => Error::UnexpectedError(err), + _ => Error::Unexpected(err), } } } @@ -35,7 +35,7 @@ impl From for Error { pub struct AssetId(String); impl AssetId { - pub fn as_str<'a>(&'a self) -> &'a str { + pub fn as_str(&self) -> &str { &self.0 } } @@ -69,7 +69,7 @@ impl<'a> Iterator for AssetIter<'a> { } pub trait Assets { - fn assets<'a>(&'a self) -> AssetIter<'a>; + fn assets(&self) -> AssetIter; fn get(&self, asset_id: AssetId) -> Result<(Mime, Vec), Error>; } @@ -95,7 +95,7 @@ impl FsAssets { } impl Assets for FsAssets { - fn assets<'a>(&'a self) -> AssetIter<'a> { + fn assets(&self) -> AssetIter { AssetIter(self.assets.iter()) } @@ -104,9 +104,9 @@ impl Assets for FsAssets { Some(asset) => Ok(asset), None => Err(Error::NotFound), }?; - let mime = mime_guess::from_path(&path).first().unwrap(); + let mime = mime_guess::from_path(path).first().unwrap(); let mut content: Vec = Vec::new(); - let mut file = std::fs::File::open(&path)?; + let mut file = std::fs::File::open(path)?; file.read_to_end(&mut content)?; Ok((mime, content)) } diff --git a/visions/server/src/core.rs b/visions/server/src/core.rs index 99eea4a..03937de 100644 --- a/visions/server/src/core.rs +++ b/visions/server/src/core.rs @@ -11,10 +11,10 @@ use uuid::Uuid; use crate::{ asset_db::{self, AssetId, Assets}, database::{CharacterId, Database, SessionId, UserId}, - types::{AppError, FatalError, Game, Message, Tabletop, User, RGB}, + types::{AppError, FatalError, Game, Message, Tabletop, User, Rgb}, }; -const DEFAULT_BACKGROUND_COLOR: RGB = RGB { +const DEFAULT_BACKGROUND_COLOR: Rgb = Rgb { red: 0xca, green: 0xb9, blue: 0xbb, @@ -60,7 +60,7 @@ impl Core { } pub async fn status(&self) -> ResultExt { - let mut state = self.0.write().await; + let state = self.0.write().await; let admin_user = return_error!(match state.db.user(&UserId::from("admin")).await { Ok(Some(admin_user)) => ok(admin_user), Ok(None) => { @@ -121,7 +121,7 @@ impl Core { pub async fn list_users(&self) -> ResultExt, AppError, FatalError> { let users = self.0.write().await.db.users().await; match users { - Ok(users) => ok(users.into_iter().map(|u| User::from(u)).collect()), + Ok(users) => ok(users.into_iter().map(User::from).collect()), Err(err) => fatal(err), } } @@ -130,7 +130,7 @@ impl Core { let games = self.0.write().await.db.games().await; match games { // Ok(games) => ok(games.into_iter().map(|g| Game::from(g)).collect()), - Ok(games) => unimplemented!(), + Ok(_games) => unimplemented!(), Err(err) => fatal(err), } } @@ -154,7 +154,7 @@ impl Core { asset_db::Error::Inaccessible => { AppError::Inaccessible(format!("{}", asset_id)) } - asset_db::Error::UnexpectedError(err) => { + asset_db::Error::Unexpected(err) => { AppError::Inaccessible(format!("{}", err)) } }), @@ -168,7 +168,7 @@ impl Core { .asset_store .assets() .filter_map( - |(asset_id, value)| match mime_guess::from_path(&value).first() { + |(asset_id, value)| match mime_guess::from_path(value).first() { Some(mime) if mime.type_() == mime::IMAGE => Some(asset_id.clone()), _ => None, }, @@ -217,7 +217,7 @@ impl Core { uuid: UserId, password: String, ) -> ResultExt<(), AppError, FatalError> { - let mut state = self.0.write().await; + let state = self.0.write().await; let user = match state.db.user(&uuid).await { Ok(Some(row)) => row, Ok(None) => return error(AppError::NotFound(uuid.as_str().to_owned())), @@ -239,7 +239,7 @@ impl Core { password: &str, ) -> ResultExt { let state = self.0.write().await; - match state.db.user_by_username(&username).await { + match state.db.user_by_username(username).await { Ok(Some(row)) if (row.password == password) => { let session_id = state.db.create_session(row.id).await.unwrap(); ok(session_id) diff --git a/visions/server/src/database/types.rs b/visions/server/src/database/types.rs index 9909365..55b3490 100644 --- a/visions/server/src/database/types.rs +++ b/visions/server/src/database/types.rs @@ -10,7 +10,7 @@ impl UserId { Self(format!("{}", Uuid::new_v4().hyphenated())) } - pub fn as_str<'a>(&'a self) -> &'a str { + pub fn as_str(&self) -> &str { &self.0 } } @@ -44,7 +44,7 @@ impl SessionId { Self(format!("{}", Uuid::new_v4().hyphenated())) } - pub fn as_str<'a>(&'a self) -> &'a str { + pub fn as_str(&self) -> &str { &self.0 } } @@ -78,7 +78,7 @@ impl GameId { Self(format!("{}", Uuid::new_v4().hyphenated())) } - pub fn as_str<'a>(&'a self) -> &'a str { + pub fn as_str(&self) -> &str { &self.0 } } @@ -112,7 +112,7 @@ impl CharacterId { Self(format!("{}", Uuid::new_v4().hyphenated())) } - pub fn as_str<'a>(&'a self) -> &'a str { + pub fn as_str(&self) -> &str { &self.0 } } diff --git a/visions/server/src/types.rs b/visions/server/src/types.rs index 15fedf4..7ea62b4 100644 --- a/visions/server/src/types.rs +++ b/visions/server/src/types.rs @@ -52,7 +52,7 @@ pub enum AppError { #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] #[typeshare] -pub struct RGB { +pub struct Rgb { pub red: u32, pub green: u32, pub blue: u32, @@ -109,7 +109,7 @@ pub struct Game { #[serde(rename_all = "camelCase")] #[typeshare] pub struct Tabletop { - pub background_color: RGB, + pub background_color: Rgb, pub background_image: Option, } -- 2.47.1 From a0f1a0b81c9859ebec6e4c14951675fd108bddaa Mon Sep 17 00:00:00 2001 From: Savanni D'Gerinel Date: Tue, 31 Dec 2024 12:56:05 -0500 Subject: [PATCH 11/23] Test user authentication --- .../server/migrations/01-initial-db/up.sql | 1 + visions/server/src/core.rs | 27 ++++-- visions/server/src/database/disk_db.rs | 2 +- visions/server/src/database/mod.rs | 2 +- visions/server/src/database/types.rs | 10 ++- visions/server/src/handlers/mod.rs | 4 +- visions/server/src/routes.rs | 83 +++++++++++++++++-- 7 files changed, 110 insertions(+), 19 deletions(-) diff --git a/visions/server/migrations/01-initial-db/up.sql b/visions/server/migrations/01-initial-db/up.sql index 29463cd..8f2ad6b 100644 --- a/visions/server/migrations/01-initial-db/up.sql +++ b/visions/server/migrations/01-initial-db/up.sql @@ -35,3 +35,4 @@ CREATE TABLE roles( FOREIGN KEY(game_id) REFERENCES games(uuid) ); +INSERT INTO users VALUES ('admin', 'admin', '', true, true); diff --git a/visions/server/src/core.rs b/visions/server/src/core.rs index 03937de..2d72ac1 100644 --- a/visions/server/src/core.rs +++ b/visions/server/src/core.rs @@ -11,7 +11,7 @@ use uuid::Uuid; use crate::{ asset_db::{self, AssetId, Assets}, database::{CharacterId, Database, SessionId, UserId}, - types::{AppError, FatalError, Game, Message, Tabletop, User, Rgb}, + types::{AppError, FatalError, Game, Message, Rgb, Tabletop, User}, }; const DEFAULT_BACKGROUND_COLOR: Rgb = Rgb { @@ -154,9 +154,7 @@ impl Core { asset_db::Error::Inaccessible => { AppError::Inaccessible(format!("{}", asset_id)) } - asset_db::Error::Unexpected(err) => { - AppError::Inaccessible(format!("{}", err)) - } + asset_db::Error::Unexpected(err) => AppError::Inaccessible(format!("{}", err)), }), ) } @@ -212,6 +210,25 @@ impl Core { }); } + pub async fn save_user( + &self, + uuid: Option, + username: &str, + password: &str, + admin: bool, + enabled: bool, + ) -> ResultExt { + let state = self.0.read().await; + match state + .db + .save_user(uuid, username, password, admin, enabled) + .await + { + Ok(uuid) => ok(uuid), + Err(err) => fatal(err), + } + } + pub async fn set_password( &self, uuid: UserId, @@ -290,7 +307,7 @@ mod test { ]); let memory_db: Option = None; let conn = DbConn::new(memory_db); - conn.save_user(None, "admin", "aoeu", true, true) + conn.save_user(Some(UserId::from("admin")), "admin", "aoeu", true, true) .await .unwrap(); conn.save_user(None, "gm_1", "aoeu", false, true) diff --git a/visions/server/src/database/disk_db.rs b/visions/server/src/database/disk_db.rs index 5b41a28..bc8dd6c 100644 --- a/visions/server/src/database/disk_db.rs +++ b/visions/server/src/database/disk_db.rs @@ -100,7 +100,7 @@ impl DiskDb { ) -> Result { match user_id { None => { - let user_id = UserId::new(); + let user_id = UserId::default(); let mut stmt = self .conn .prepare("INSERT INTO users VALUES (?, ?, ?, ?, ?)") diff --git a/visions/server/src/database/mod.rs b/visions/server/src/database/mod.rs index 3743d54..8352e88 100644 --- a/visions/server/src/database/mod.rs +++ b/visions/server/src/database/mod.rs @@ -173,7 +173,7 @@ mod test { let no_path: Option = None; let db = DiskDb::new(no_path).unwrap(); - db.save_user(None, "admin", "abcdefg", true, true).unwrap(); + db.save_user(Some(UserId::from("admin")), "admin", "abcdefg", true, true).unwrap(); let game_id = db.save_game(None, "Candela").unwrap(); (db, game_id) } diff --git a/visions/server/src/database/types.rs b/visions/server/src/database/types.rs index 55b3490..65d2e35 100644 --- a/visions/server/src/database/types.rs +++ b/visions/server/src/database/types.rs @@ -6,15 +6,17 @@ use uuid::Uuid; pub struct UserId(String); impl UserId { - pub fn new() -> Self { - Self(format!("{}", Uuid::new_v4().hyphenated())) - } - pub fn as_str(&self) -> &str { &self.0 } } +impl Default for UserId { + fn default() -> Self { + Self(format!("{}", Uuid::new_v4().hyphenated())) + } +} + impl From<&str> for UserId { fn from(s: &str) -> Self { Self(s.to_owned()) diff --git a/visions/server/src/handlers/mod.rs b/visions/server/src/handlers/mod.rs index 6d33a58..b6d05e5 100644 --- a/visions/server/src/handlers/mod.rs +++ b/visions/server/src/handlers/mod.rs @@ -32,8 +32,8 @@ pub async fn healthcheck(core: Core) -> Vec { #[derive(Clone, Debug, Deserialize, Serialize)] #[typeshare] pub struct AuthRequest { - username: String, - password: String, + pub username: String, + pub password: String, } pub async fn check_password(core: Core, req: Json) -> (StatusCode, Json>) { diff --git a/visions/server/src/routes.rs b/visions/server/src/routes.rs index c644c58..d99e229 100644 --- a/visions/server/src/routes.rs +++ b/visions/server/src/routes.rs @@ -1,4 +1,7 @@ -use axum::{routing::{get, post}, Json, Router}; +use axum::{ + routing::{get, post}, + Json, Router, +}; use crate::{ core::Core, @@ -27,12 +30,19 @@ pub fn routes(core: Core) -> Router { mod test { use std::path::PathBuf; + use axum::http::StatusCode; use axum_test::TestServer; + use cool_asserts::assert_matches; + use result_extended::ResultExt; - use crate::{asset_db::FsAssets, core::Core, database::DbConn}; use super::*; + use crate::{ + asset_db::FsAssets, + core::Core, + database::{Database, DbConn, SessionId, UserId}, + }; - fn setup() -> (Core, TestServer) { + fn setup_without_admin() -> (Core, TestServer) { let memory_db: Option = None; let conn = DbConn::new(memory_db); let core = Core::new(FsAssets::new(PathBuf::from("/home/savanni/Pictures")), conn); @@ -41,14 +51,75 @@ mod test { (core, server) } + async fn setup_admin_enabled() -> (Core, TestServer) { + let memory_db: Option = None; + let conn = DbConn::new(memory_db); + conn.save_user(Some(UserId::from("admin")), "admin", "aoeu", true, true) + .await + .unwrap(); + let core = Core::new(FsAssets::new(PathBuf::from("/home/savanni/Pictures")), conn); + let app = routes(core.clone()); + let server = TestServer::new(app).unwrap(); + (core, server) + } + #[tokio::test] async fn it_returns_a_healthcheck() { - let (_core, server) = setup(); + let (core, server) = setup_without_admin(); + let response = server.get("/api/v1/health").await; response.assert_status_ok(); - let b: crate::handlers::HealthCheck = response.json(); - assert_eq!(b, crate::handlers::HealthCheck { ok: false }); + + assert_matches!( + core.save_user(Some(UserId::from("admin")), "admin", "aoeu", true, true) + .await, + ResultExt::Ok(_) + ); + + let response = server.get("/api/v1/health").await; + response.assert_status_ok(); + let b: crate::handlers::HealthCheck = response.json(); + assert_eq!(b, crate::handlers::HealthCheck { ok: true }); + } + + #[tokio::test] + async fn it_authenticates_a_user() { + let (_core, server) = setup_admin_enabled().await; + + let response = server + .post("/api/v1/auth") + .json(&AuthRequest { + username: "admin".to_owned(), + password: "wrong".to_owned(), + }) + .await; + response.assert_status(StatusCode::UNAUTHORIZED); + + let response = server + .post("/api/v1/auth") + .json(&AuthRequest { + username: "unknown".to_owned(), + password: "wrong".to_owned(), + }) + .await; + response.assert_status(StatusCode::UNAUTHORIZED); + + let response = server + .post("/api/v1/auth") + .json(&AuthRequest { + username: "admin".to_owned(), + password: "aoeu".to_owned(), + }) + .await; + response.assert_status_ok(); + let session_id: Option = response.json(); + assert!(session_id.is_some()); + } + + #[tokio::test] + async fn it_returns_user_profile() { + unimplemented!(); } } -- 2.47.1 From 822dfe2a138966b41b884ab31d460e2d461a046a Mon Sep 17 00:00:00 2001 From: Savanni D'Gerinel Date: Tue, 31 Dec 2024 14:09:21 -0500 Subject: [PATCH 12/23] authenticate an endpoint for getting the user profile --- visions/server/Cargo.toml | 2 +- visions/server/src/core.rs | 15 ++++++-- visions/server/src/database/mod.rs | 20 +++++----- visions/server/src/database/types.rs | 8 ++++ visions/server/src/handlers/mod.rs | 55 ++++++++++++++++++++++++++-- visions/server/src/routes.rs | 43 +++++++++++++++++++++- visions/server/src/types.rs | 9 +++-- 7 files changed, 130 insertions(+), 22 deletions(-) diff --git a/visions/server/Cargo.toml b/visions/server/Cargo.toml index 8d1d27e..1e9f78d 100644 --- a/visions/server/Cargo.toml +++ b/visions/server/Cargo.toml @@ -10,7 +10,6 @@ async-std = { version = "1.13.0" } async-trait = { version = "0.1.83" } authdb = { path = "../../authdb/" } axum = { version = "0.7.9" } -axum-test = "16.4.1" futures = { version = "0.3.31" } include_dir = { version = "0.7.4" } lazy_static = { version = "1.5.0" } @@ -31,3 +30,4 @@ uuid = { version = "1.11.0", features = ["v4"] } [dev-dependencies] cool_asserts = "2.0.3" +axum-test = "16.4.1" diff --git a/visions/server/src/core.rs b/visions/server/src/core.rs index 2d72ac1..8cecb45 100644 --- a/visions/server/src/core.rs +++ b/visions/server/src/core.rs @@ -192,7 +192,7 @@ impl Core { id: CharacterId, ) -> ResultExt, AppError, FatalError> { let mut state = self.0.write().await; - let cr = state.db.character(id).await; + let cr = state.db.character(&id).await; match cr { Ok(Some(row)) => ok(Some(row.data)), Ok(None) => ok(None), @@ -258,13 +258,22 @@ impl Core { let state = self.0.write().await; match state.db.user_by_username(username).await { Ok(Some(row)) if (row.password == password) => { - let session_id = state.db.create_session(row.id).await.unwrap(); + let session_id = state.db.create_session(&row.id).await.unwrap(); ok(session_id) } Ok(_) => error(AppError::AuthFailed), Err(err) => fatal(err), } } + + pub async fn session(&self, session_id: &SessionId) -> ResultExt, AppError, FatalError> { + let state = self.0.read().await; + match state.db.session(session_id).await { + Ok(Some(user_row)) => ok(Some(User::from(user_row))), + Ok(None) => ok(None), + Err(fatal_error) => fatal(fatal_error), + } + } } #[cfg(test)] @@ -382,7 +391,7 @@ mod test { match core.auth("admin", "aoeu").await { ResultExt::Ok(session_id) => { let st = core.0.read().await; - match st.db.session(session_id).await { + match st.db.session(&session_id).await { Ok(Some(user_row)) => assert_eq!(user_row.name, "admin"), Ok(None) => panic!("no matching user row for the session id"), Err(err) => panic!("{}", err), diff --git a/visions/server/src/database/mod.rs b/visions/server/src/database/mod.rs index 8352e88..39211ce 100644 --- a/visions/server/src/database/mod.rs +++ b/visions/server/src/database/mod.rs @@ -58,11 +58,11 @@ pub trait Database: Send + Sync { async fn games(&mut self) -> Result, FatalError>; - async fn character(&mut self, id: CharacterId) -> Result, FatalError>; + async fn character(&mut self, id: &CharacterId) -> Result, FatalError>; - async fn session(&self, id: SessionId) -> Result, FatalError>; + async fn session(&self, id: &SessionId) -> Result, FatalError>; - async fn create_session(&self, id: UserId) -> Result; + async fn create_session(&self, id: &UserId) -> Result; } pub struct DbConn { @@ -144,16 +144,16 @@ impl Database for DbConn { send_request!(self, Request::Games, DatabaseResponse::Games(lst) => Ok(lst)) } - async fn character(&mut self, id: CharacterId) -> Result, FatalError> { - send_request!(self, Request::Charsheet(id), DatabaseResponse::Charsheet(row) => Ok(row)) + async fn character(&mut self, id: &CharacterId) -> Result, FatalError> { + send_request!(self, Request::Charsheet(id.to_owned()), DatabaseResponse::Charsheet(row) => Ok(row)) } - async fn session(&self, id: SessionId) -> Result, FatalError> { - send_request!(self, Request::Session(id), DatabaseResponse::Session(row) => Ok(row)) + async fn session(&self, id: &SessionId) -> Result, FatalError> { + send_request!(self, Request::Session(id.to_owned()), DatabaseResponse::Session(row) => Ok(row)) } - async fn create_session(&self, id: UserId) -> Result { - send_request!(self, Request::CreateSession(id), DatabaseResponse::CreateSession(session_id) => Ok(session_id)) + async fn create_session(&self, id: &UserId) -> Result { + send_request!(self, Request::CreateSession(id.to_owned()), DatabaseResponse::CreateSession(session_id) => Ok(session_id)) } } @@ -194,6 +194,6 @@ mod test { let memory_db: Option = None; let mut conn = DbConn::new(memory_db); - assert_matches!(conn.character(CharacterId::from("1")).await, Ok(None)); + assert_matches!(conn.character(&CharacterId::from("1")).await, Ok(None)); } } diff --git a/visions/server/src/database/types.rs b/visions/server/src/database/types.rs index 65d2e35..9bb9283 100644 --- a/visions/server/src/database/types.rs +++ b/visions/server/src/database/types.rs @@ -1,3 +1,5 @@ +use std::fmt; + use rusqlite::types::{FromSql, FromSqlResult, ValueRef}; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -72,6 +74,12 @@ impl FromSql for SessionId { } } +impl fmt::Display for SessionId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { + f.write_str(&self.0) + } +} + #[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] pub struct GameId(String); diff --git a/visions/server/src/handlers/mod.rs b/visions/server/src/handlers/mod.rs index b6d05e5..c742b9d 100644 --- a/visions/server/src/handlers/mod.rs +++ b/visions/server/src/handlers/mod.rs @@ -1,18 +1,44 @@ use std::future::Future; +use axum::{ + http::{HeaderMap, StatusCode}, + Json, +}; use futures::{SinkExt, StreamExt}; use result_extended::{error, ok, return_error, ResultExt}; use serde::{Deserialize, Serialize}; use typeshare::typeshare; -use axum::{http::StatusCode, Json}; use crate::{ asset_db::AssetId, core::Core, - database::{CharacterId, UserId, SessionId}, - types::{AppError, FatalError}, + database::{CharacterId, SessionId, UserId}, + types::{AppError, FatalError, User}, }; +async fn check_session( + core: &Core, + headers: HeaderMap, +) -> ResultExt, AppError, FatalError> { + println!("headers: {:?}", headers); + println!("auth_header: {:?}", headers.get("Authorization")); + match headers.get("Authorization") { + Some(token) => { + match token + .to_str() + .unwrap() + .split(" ") + .collect::>() + .as_slice() + { + [_schema, token] => core.session(&SessionId::from(token.to_owned())).await, + _ => error(AppError::BadRequest), + } + } + None => ok(None), + } +} + #[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] pub struct HealthCheck { pub ok: bool, @@ -36,7 +62,10 @@ pub struct AuthRequest { pub password: String, } -pub async fn check_password(core: Core, req: Json) -> (StatusCode, Json>) { +pub async fn check_password( + core: Core, + req: Json, +) -> (StatusCode, Json>) { let Json(AuthRequest { username, password }) = req; match core.auth(&username, &password).await { ResultExt::Ok(session_id) => (StatusCode::OK, Json(Some(session_id))), @@ -45,6 +74,24 @@ pub async fn check_password(core: Core, req: Json) -> (StatusCode, } } +#[derive(Deserialize, Serialize)] +#[typeshare] +pub struct UserProfile { + pub userid: UserId, + pub username: String, +} + +pub async fn get_user(core: Core, headers: HeaderMap) -> (StatusCode, Json>) { + match check_session(&core, headers).await { + ResultExt::Ok(Some(user)) => { + (StatusCode::OK, Json(Some(UserProfile{userid: UserId::from(user.id), username: user.name} ))) + } + ResultExt::Ok(None) => (StatusCode::UNAUTHORIZED, Json(None)), + ResultExt::Err(_err) => (StatusCode::BAD_REQUEST, Json(None)), + ResultExt::Fatal(err) => panic!("{}", err), + } +} + /* pub async fn handle_auth( auth_ctx: &AuthDB, diff --git a/visions/server/src/routes.rs b/visions/server/src/routes.rs index d99e229..2337e19 100644 --- a/visions/server/src/routes.rs +++ b/visions/server/src/routes.rs @@ -1,11 +1,14 @@ +use std::fmt; + use axum::{ + http::{HeaderMap, StatusCode}, routing::{get, post}, Json, Router, }; use crate::{ core::Core, - handlers::{check_password, healthcheck, AuthRequest}, + handlers::{check_password, get_user, healthcheck, AuthRequest}, }; pub fn routes(core: Core) -> Router { @@ -24,6 +27,14 @@ pub fn routes(core: Core) -> Router { move |req: Json| check_password(core, req) }), ) + .route( + // By default, just get the self user. + "/api/v1/user", + get({ + let core = core.clone(); + move |headers: HeaderMap| get_user(core, headers) + }) + ) } #[cfg(test)] @@ -40,6 +51,7 @@ mod test { asset_db::FsAssets, core::Core, database::{Database, DbConn, SessionId, UserId}, + handlers::UserProfile, }; fn setup_without_admin() -> (Core, TestServer) { @@ -120,6 +132,35 @@ mod test { #[tokio::test] async fn it_returns_user_profile() { + let (_core, server) = setup_admin_enabled().await; + + let response = server.get("/api/v1/user").await; + response.assert_status(StatusCode::UNAUTHORIZED); + + let response = server + .post("/api/v1/auth") + .json(&AuthRequest { + username: "admin".to_owned(), + password: "aoeu".to_owned(), + }) + .await; + response.assert_status_ok(); + let session_id: Option = response.json(); + let session_id = session_id.unwrap(); + + let response = server + .get("/api/v1/user") + .add_header("Authorization", format!("Bearer {}", session_id)) + .await; + response.assert_status_ok(); + let profile: Option = response.json(); + let profile = profile.unwrap(); + assert_eq!(profile.userid, UserId::from("admin")); + assert_eq!(profile.username, "admin"); + } + + #[tokio::test] + async fn an_admin_can_create_a_user() { unimplemented!(); } } diff --git a/visions/server/src/types.rs b/visions/server/src/types.rs index 7ea62b4..6e8fc4c 100644 --- a/visions/server/src/types.rs +++ b/visions/server/src/types.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use typeshare::typeshare; -use crate::{asset_db::AssetId, database::UserRow}; +use crate::{asset_db::AssetId, database::{UserId, UserRow}}; #[derive(Debug, Error)] pub enum FatalError { @@ -30,6 +30,9 @@ impl result_extended::FatalError for FatalError {} #[derive(Debug, Error)] pub enum AppError { + #[error("invalid request")] + BadRequest, + #[error("something wasn't found {0}")] NotFound(String), @@ -62,7 +65,7 @@ pub struct Rgb { #[serde(rename_all = "camelCase")] #[typeshare] pub struct User { - pub id: String, + pub id: UserId, pub name: String, pub password: String, pub admin: bool, @@ -72,7 +75,7 @@ pub struct User { impl From for User { fn from(row: UserRow) -> Self { Self { - id: row.id.as_str().to_owned(), + id: row.id, name: row.name.to_owned(), password: row.password.to_owned(), admin: row.admin, -- 2.47.1 From 82e41d711bdd7b8d93a875a313e65aba4f7ec65b Mon Sep 17 00:00:00 2001 From: Savanni D'Gerinel Date: Tue, 31 Dec 2024 14:16:44 -0500 Subject: [PATCH 13/23] Extract authentication into a wrapper function --- visions/server/src/handlers/mod.rs | 34 +++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/visions/server/src/handlers/mod.rs b/visions/server/src/handlers/mod.rs index c742b9d..740f5ad 100644 --- a/visions/server/src/handlers/mod.rs +++ b/visions/server/src/handlers/mod.rs @@ -74,6 +74,22 @@ pub async fn check_password( } } +pub async fn authenticated( + core: Core, + headers: HeaderMap, + f: F, +) -> (StatusCode, Json>) +where + F: FnOnce(User) -> (StatusCode, Json>), +{ + match check_session(&core, headers).await { + ResultExt::Ok(Some(user)) => f(user), + ResultExt::Ok(None) => (StatusCode::UNAUTHORIZED, Json(None)), + ResultExt::Err(_err) => (StatusCode::BAD_REQUEST, Json(None)), + ResultExt::Fatal(err) => panic!("{}", err), + } +} + #[derive(Deserialize, Serialize)] #[typeshare] pub struct UserProfile { @@ -82,14 +98,16 @@ pub struct UserProfile { } pub async fn get_user(core: Core, headers: HeaderMap) -> (StatusCode, Json>) { - match check_session(&core, headers).await { - ResultExt::Ok(Some(user)) => { - (StatusCode::OK, Json(Some(UserProfile{userid: UserId::from(user.id), username: user.name} ))) - } - ResultExt::Ok(None) => (StatusCode::UNAUTHORIZED, Json(None)), - ResultExt::Err(_err) => (StatusCode::BAD_REQUEST, Json(None)), - ResultExt::Fatal(err) => panic!("{}", err), - } + authenticated(core.clone(), headers, |user| { + ( + StatusCode::OK, + Json(Some(UserProfile { + userid: UserId::from(user.id), + username: user.name, + })), + ) + }) + .await } /* -- 2.47.1 From b2a7577c9d0308688e04f9efe703e0c1b2fcc56b Mon Sep 17 00:00:00 2001 From: Savanni D'Gerinel Date: Tue, 31 Dec 2024 16:34:59 -0500 Subject: [PATCH 14/23] Make handlers asynchronous --- visions/server/src/core.rs | 8 +++++ visions/server/src/handlers/mod.rs | 48 +++++++++++++++++++++++++++--- visions/server/src/routes.rs | 23 +++++++++++++- visions/server/src/types.rs | 3 ++ 4 files changed, 77 insertions(+), 5 deletions(-) diff --git a/visions/server/src/core.rs b/visions/server/src/core.rs index 8cecb45..ad90579 100644 --- a/visions/server/src/core.rs +++ b/visions/server/src/core.rs @@ -126,6 +126,14 @@ impl Core { } } + pub async fn create_user(&self, username: &str) -> ResultExt<(), AppError, FatalError> { + let state = self.0.read().await; + match return_error!(self.user_by_username(username).await) { + Some(_) => error(AppError::UsernameUnavailable), + None => unimplemented!(), + } + } + pub async fn list_games(&self) -> ResultExt, AppError, FatalError> { let games = self.0.write().await.db.games().await; match games { diff --git a/visions/server/src/handlers/mod.rs b/visions/server/src/handlers/mod.rs index 740f5ad..37d35bf 100644 --- a/visions/server/src/handlers/mod.rs +++ b/visions/server/src/handlers/mod.rs @@ -74,16 +74,38 @@ pub async fn check_password( } } -pub async fn authenticated( +pub async fn auth_required( core: Core, headers: HeaderMap, f: F, ) -> (StatusCode, Json>) where - F: FnOnce(User) -> (StatusCode, Json>), + F: FnOnce(User) -> Fut, + Fut: Future>)>, { match check_session(&core, headers).await { - ResultExt::Ok(Some(user)) => f(user), + ResultExt::Ok(Some(user)) => f(user).await, + ResultExt::Ok(None) => (StatusCode::UNAUTHORIZED, Json(None)), + ResultExt::Err(_err) => (StatusCode::BAD_REQUEST, Json(None)), + ResultExt::Fatal(err) => panic!("{}", err), + } +} + +pub async fn admin_required( + core: Core, + headers: HeaderMap, + f: F, + ) -> (StatusCode, Json>) +where + F: FnOnce(User) -> Fut, + Fut: Future>)>, +{ + match check_session(&core, headers).await { + ResultExt::Ok(Some(user)) => if user.admin { + f(user).await + } else { + (StatusCode::FORBIDDEN, Json(None)) + }, ResultExt::Ok(None) => (StatusCode::UNAUTHORIZED, Json(None)), ResultExt::Err(_err) => (StatusCode::BAD_REQUEST, Json(None)), ResultExt::Fatal(err) => panic!("{}", err), @@ -95,21 +117,39 @@ where pub struct UserProfile { pub userid: UserId, pub username: String, + pub is_admin: bool, } pub async fn get_user(core: Core, headers: HeaderMap) -> (StatusCode, Json>) { - authenticated(core.clone(), headers, |user| { + auth_required(core.clone(), headers, |user| async move { ( StatusCode::OK, Json(Some(UserProfile { userid: UserId::from(user.id), username: user.name, + is_admin: user.admin, })), ) }) .await } +#[derive(Deserialize, Serialize)] +#[typeshare] +pub struct CreateUserRequest { + username: String, +} + +pub async fn create_user(core: Core, headers: HeaderMap, req: CreateUserRequest) -> (StatusCode, Json>) { + auth_required(core.clone(), headers, |_admin| async { + match core.create_user(&req.username).await { + ResultExt::Ok(_) => (StatusCode::OK, Json(None)), + ResultExt::Err(err) => (StatusCode::BAD_REQUEST, Json(None)), + ResultExt::Fatal(fatal) => panic!("{}", fatal), + } + }).await +} + /* pub async fn handle_auth( auth_ctx: &AuthDB, diff --git a/visions/server/src/routes.rs b/visions/server/src/routes.rs index 2337e19..3e71dda 100644 --- a/visions/server/src/routes.rs +++ b/visions/server/src/routes.rs @@ -8,7 +8,9 @@ use axum::{ use crate::{ core::Core, - handlers::{check_password, get_user, healthcheck, AuthRequest}, + handlers::{ + check_password, create_user, get_user, healthcheck, AuthRequest, CreateUserRequest, + }, }; pub fn routes(core: Core) -> Router { @@ -34,6 +36,13 @@ pub fn routes(core: Core) -> Router { let core = core.clone(); move |headers: HeaderMap| get_user(core, headers) }) + .put({ + let core = core.clone(); + move |headers: HeaderMap, req: Json| { + let Json(req) = req; + create_user(core, headers, req) + } + }), ) } @@ -163,4 +172,16 @@ mod test { async fn an_admin_can_create_a_user() { unimplemented!(); } + + #[ignore] + #[tokio::test] + async fn a_user_can_create_a_game() { + unimplemented!(); + } + + #[ignore] + #[tokio::test] + async fn gms_can_invite_others_into_a_game() { + unimplemented!(); + } } diff --git a/visions/server/src/types.rs b/visions/server/src/types.rs index 6e8fc4c..3fbc70e 100644 --- a/visions/server/src/types.rs +++ b/visions/server/src/types.rs @@ -50,6 +50,9 @@ pub enum AppError { #[error("wat {0}")] UnexpectedError(String), + + #[error("this username is not available")] + UsernameUnavailable, } #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] -- 2.47.1 From d9f1efb8d36b5ac016bbcb8945b1b5b2d1e354bd Mon Sep 17 00:00:00 2001 From: Savanni D'Gerinel Date: Tue, 31 Dec 2024 23:47:40 -0500 Subject: [PATCH 15/23] Add the ability to create users and to get profiles --- visions/server/src/core.rs | 10 +++- visions/server/src/database/types.rs | 6 ++ visions/server/src/handlers/mod.rs | 64 ++++++++++++++------ visions/server/src/routes.rs | 88 +++++++++++++++++++++++++++- 4 files changed, 147 insertions(+), 21 deletions(-) diff --git a/visions/server/src/core.rs b/visions/server/src/core.rs index ad90579..2535458 100644 --- a/visions/server/src/core.rs +++ b/visions/server/src/core.rs @@ -126,11 +126,19 @@ impl Core { } } + pub async fn user(&self, user_id: UserId) -> ResultExt, AppError, FatalError> { + let users = return_error!(self.list_users().await); + ok(users.into_iter().find(|user| user.id == user_id)) + } + pub async fn create_user(&self, username: &str) -> ResultExt<(), AppError, FatalError> { let state = self.0.read().await; match return_error!(self.user_by_username(username).await) { Some(_) => error(AppError::UsernameUnavailable), - None => unimplemented!(), + None => match state.db.save_user(None, username, "", false, true).await { + Ok(_) => ok(()), + Err(err) => fatal(err), + }, } } diff --git a/visions/server/src/database/types.rs b/visions/server/src/database/types.rs index 9bb9283..8e2daef 100644 --- a/visions/server/src/database/types.rs +++ b/visions/server/src/database/types.rs @@ -40,6 +40,12 @@ impl FromSql for UserId { } } +impl fmt::Display for UserId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { + f.write_str(&self.0) + } +} + #[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] pub struct SessionId(String); diff --git a/visions/server/src/handlers/mod.rs b/visions/server/src/handlers/mod.rs index 37d35bf..0900855 100644 --- a/visions/server/src/handlers/mod.rs +++ b/visions/server/src/handlers/mod.rs @@ -95,17 +95,19 @@ pub async fn admin_required( core: Core, headers: HeaderMap, f: F, - ) -> (StatusCode, Json>) +) -> (StatusCode, Json>) where F: FnOnce(User) -> Fut, Fut: Future>)>, { match check_session(&core, headers).await { - ResultExt::Ok(Some(user)) => if user.admin { - f(user).await - } else { - (StatusCode::FORBIDDEN, Json(None)) - }, + ResultExt::Ok(Some(user)) => { + if user.admin { + f(user).await + } else { + (StatusCode::FORBIDDEN, Json(None)) + } + } ResultExt::Ok(None) => (StatusCode::UNAUTHORIZED, Json(None)), ResultExt::Err(_err) => (StatusCode::BAD_REQUEST, Json(None)), ResultExt::Fatal(err) => panic!("{}", err), @@ -120,16 +122,35 @@ pub struct UserProfile { pub is_admin: bool, } -pub async fn get_user(core: Core, headers: HeaderMap) -> (StatusCode, Json>) { +pub async fn get_user( + core: Core, + headers: HeaderMap, + user_id: Option, +) -> (StatusCode, Json>) { auth_required(core.clone(), headers, |user| async move { - ( - StatusCode::OK, - Json(Some(UserProfile { - userid: UserId::from(user.id), - username: user.name, - is_admin: user.admin, - })), - ) + match user_id { + Some(user_id) => match core.user(user_id).await { + ResultExt::Ok(Some(user)) => ( + StatusCode::OK, + Json(Some(UserProfile { + userid: UserId::from(user.id), + username: user.name, + is_admin: user.admin, + })), + ), + ResultExt::Ok(None) => (StatusCode::NOT_FOUND, Json(None)), + ResultExt::Err(_err) => (StatusCode::BAD_REQUEST, Json(None)), + ResultExt::Fatal(err) => panic!("{}", err), + } + None => ( + StatusCode::OK, + Json(Some(UserProfile { + userid: UserId::from(user.id), + username: user.name, + is_admin: user.admin, + })), + ), + } }) .await } @@ -137,17 +158,22 @@ pub async fn get_user(core: Core, headers: HeaderMap) -> (StatusCode, Json (StatusCode, Json>) { - auth_required(core.clone(), headers, |_admin| async { +pub async fn create_user( + core: Core, + headers: HeaderMap, + req: CreateUserRequest, +) -> (StatusCode, Json>) { + admin_required(core.clone(), headers, |_admin| async { match core.create_user(&req.username).await { ResultExt::Ok(_) => (StatusCode::OK, Json(None)), ResultExt::Err(err) => (StatusCode::BAD_REQUEST, Json(None)), ResultExt::Fatal(fatal) => panic!("{}", fatal), } - }).await + }) + .await } /* diff --git a/visions/server/src/routes.rs b/visions/server/src/routes.rs index 3e71dda..86f6474 100644 --- a/visions/server/src/routes.rs +++ b/visions/server/src/routes.rs @@ -1,6 +1,7 @@ use std::fmt; use axum::{ + extract::Path, http::{HeaderMap, StatusCode}, routing::{get, post}, Json, Router, @@ -8,6 +9,7 @@ use axum::{ use crate::{ core::Core, + database::UserId, handlers::{ check_password, create_user, get_user, healthcheck, AuthRequest, CreateUserRequest, }, @@ -34,7 +36,7 @@ pub fn routes(core: Core) -> Router { "/api/v1/user", get({ let core = core.clone(); - move |headers: HeaderMap| get_user(core, headers) + move |headers: HeaderMap| get_user(core, headers, None) }) .put({ let core = core.clone(); @@ -44,6 +46,16 @@ pub fn routes(core: Core) -> Router { } }), ) + .route( + "/api/v1/user/:user_id", + get({ + let core = core.clone(); + move |user_id: Path, headers: HeaderMap| { + let Path(user_id) = user_id; + get_user(core, headers, Some(user_id)) + } + }), + ) } #[cfg(test)] @@ -84,6 +96,32 @@ mod test { (core, server) } + async fn setup_with_user() -> (Core, TestServer) { + let (core, server) = setup_admin_enabled().await; + let response = server + .post("/api/v1/auth") + .json(&AuthRequest { + username: "admin".to_owned(), + password: "aoeu".to_owned(), + }) + .await; + response.assert_status_ok(); + let session_id: Option = response.json(); + let session_id = session_id.unwrap(); + + let response = server + .put("/api/v1/user") + .add_header("Authorization", format!("Bearer {}", session_id)) + .json(&CreateUserRequest { + username: "savanni".to_owned(), + }) + .await; + + response.assert_status_ok(); + + (core, server) + } + #[tokio::test] async fn it_returns_a_healthcheck() { let (core, server) = setup_without_admin(); @@ -170,6 +208,54 @@ mod test { #[tokio::test] async fn an_admin_can_create_a_user() { + // All of the contents of this test are basically required for any test on individual + // users, so I moved it all into the setup code. + let (_core, _server) = setup_with_user().await; + } + + #[tokio::test] + async fn a_user_can_get_any_user_profile() { + let (core, server) = setup_with_user().await; + + let savanni = match core.user_by_username("savanni").await { + ResultExt::Ok(Some(savanni)) => savanni, + ResultExt::Ok(None) => panic!("user was not initialized"), + ResultExt::Err(err) => panic!("{:?}", err), + ResultExt::Fatal(err) => panic!("{:?}", err), + }; + + let response = server + .post("/api/v1/auth") + .json(&AuthRequest { + username: "savanni".to_owned(), + password: "".to_owned(), + }) + .await; + let session_id: Option = response.json(); + let session_id = session_id.unwrap(); + + let response = server + .get(&format!("/api/v1/user/{}", savanni.id)) + .add_header("Authorization", format!("Bearer {}", session_id)) + .await; + response.assert_status_ok(); + let profile: Option = response.json(); + let profile = profile.unwrap(); + assert_eq!(profile.username, "savanni"); + + let response = server + .get("/api/v1/user/admin") + .add_header("Authorization", format!("Bearer {}", session_id)) + .await; + response.assert_status_ok(); + let profile: Option = response.json(); + let profile = profile.unwrap(); + assert_eq!(profile.username, "admin"); + } + + #[ignore] + #[tokio::test] + async fn a_user_can_get_change_their_password() { unimplemented!(); } -- 2.47.1 From 792437af44299987dab6760df3d09ec3387c61af Mon Sep 17 00:00:00 2001 From: Savanni D'Gerinel Date: Wed, 1 Jan 2025 00:19:12 -0500 Subject: [PATCH 16/23] Add the ability for a user to set their password --- visions/server/src/handlers/mod.rs | 30 ++++++++++++- visions/server/src/routes.rs | 68 ++++++++++++++++++++++++++++-- 2 files changed, 93 insertions(+), 5 deletions(-) diff --git a/visions/server/src/handlers/mod.rs b/visions/server/src/handlers/mod.rs index 0900855..be48e68 100644 --- a/visions/server/src/handlers/mod.rs +++ b/visions/server/src/handlers/mod.rs @@ -141,7 +141,7 @@ pub async fn get_user( ResultExt::Ok(None) => (StatusCode::NOT_FOUND, Json(None)), ResultExt::Err(_err) => (StatusCode::BAD_REQUEST, Json(None)), ResultExt::Fatal(err) => panic!("{}", err), - } + }, None => ( StatusCode::OK, Json(Some(UserProfile { @@ -169,13 +169,39 @@ pub async fn create_user( admin_required(core.clone(), headers, |_admin| async { match core.create_user(&req.username).await { ResultExt::Ok(_) => (StatusCode::OK, Json(None)), - ResultExt::Err(err) => (StatusCode::BAD_REQUEST, Json(None)), + ResultExt::Err(_err) => (StatusCode::BAD_REQUEST, Json(None)), ResultExt::Fatal(fatal) => panic!("{}", fatal), } }) .await } +#[derive(Deserialize, Serialize)] +#[typeshare] +pub struct SetPasswordRequest { + pub password_1: String, + pub password_2: String, +} + +pub async fn set_password( + core: Core, + headers: HeaderMap, + req: SetPasswordRequest, +) -> (StatusCode, Json>) { + auth_required(core.clone(), headers, |user| async { + if req.password_1 == req.password_2 { + match core.set_password(user.id, req.password_1).await { + ResultExt::Ok(_) => (StatusCode::OK, Json(None)), + ResultExt::Err(_err) => (StatusCode::BAD_REQUEST, Json(None)), + ResultExt::Fatal(fatal) => panic!("{}", fatal), + } + } else { + (StatusCode::BAD_REQUEST, Json(None)) + } + }) + .await +} + /* pub async fn handle_auth( auth_ctx: &AuthDB, diff --git a/visions/server/src/routes.rs b/visions/server/src/routes.rs index 86f6474..81db116 100644 --- a/visions/server/src/routes.rs +++ b/visions/server/src/routes.rs @@ -3,7 +3,7 @@ use std::fmt; use axum::{ extract::Path, http::{HeaderMap, StatusCode}, - routing::{get, post}, + routing::{get, post, put}, Json, Router, }; @@ -11,7 +11,8 @@ use crate::{ core::Core, database::UserId, handlers::{ - check_password, create_user, get_user, healthcheck, AuthRequest, CreateUserRequest, + check_password, create_user, get_user, healthcheck, set_password, AuthRequest, + CreateUserRequest, SetPasswordRequest, }, }; @@ -46,6 +47,16 @@ pub fn routes(core: Core) -> Router { } }), ) + .route( + "/api/v1/user/password", + put({ + let core = core.clone(); + move |headers: HeaderMap, req: Json| { + let Json(req) = req; + set_password(core, headers, req) + } + }), + ) .route( "/api/v1/user/:user_id", get({ @@ -253,9 +264,60 @@ mod test { assert_eq!(profile.username, "admin"); } + #[tokio::test] + async fn a_user_can_change_their_password() { + let (_core, server) = setup_with_user().await; + + let response = server + .post("/api/v1/auth") + .json(&AuthRequest { + username: "savanni".to_owned(), + password: "".to_owned(), + }) + .await; + let session_id: Option = response.json(); + let session_id = session_id.unwrap(); + + let response = server + .get("/api/v1/user") + .add_header("Authorization", format!("Bearer {}", session_id)) + .await; + let profile = response.json::>().unwrap(); + assert_eq!(profile.username, "savanni"); + + let response = server + .put("/api/v1/user/password") + .json(&SetPasswordRequest { + password_1: "abcdefg".to_owned(), + password_2: "abcd".to_owned(), + }) + .await; + response.assert_status(StatusCode::UNAUTHORIZED); + + let response = server + .put("/api/v1/user/password") + .add_header("Authorization", format!("Bearer {}", session_id)) + .json(&SetPasswordRequest { + password_1: "abcdefg".to_owned(), + password_2: "abcd".to_owned(), + }) + .await; + response.assert_status(StatusCode::BAD_REQUEST); + + let response = server + .put(&format!("/api/v1/user/password")) + .add_header("Authorization", format!("Bearer {}", session_id)) + .json(&SetPasswordRequest { + password_1: "abcdefg".to_owned(), + password_2: "abcdefg".to_owned(), + }) + .await; + response.assert_status(StatusCode::OK); + } + #[ignore] #[tokio::test] - async fn a_user_can_get_change_their_password() { + async fn a_user_cannot_change_another_users_password() { unimplemented!(); } -- 2.47.1 From f6eb942371b8675eb8d7500228c133dea526b87a Mon Sep 17 00:00:00 2001 From: Savanni D'Gerinel Date: Thu, 2 Jan 2025 11:57:17 -0500 Subject: [PATCH 17/23] Add the ability to create a game --- .../server/migrations/01-initial-db/up.sql | 6 +- visions/server/src/core.rs | 14 ++++- visions/server/src/database/disk_db.rs | 20 +++++-- visions/server/src/database/mod.rs | 47 +++++++++++++--- visions/server/src/database/types.rs | 6 +- .../server/src/handlers/game_management.rs | 33 +++++++++++ visions/server/src/handlers/mod.rs | 5 +- visions/server/src/routes.rs | 55 ++++++++++++++----- visions/server/src/types.rs | 3 + 9 files changed, 155 insertions(+), 34 deletions(-) create mode 100644 visions/server/src/handlers/game_management.rs diff --git a/visions/server/migrations/01-initial-db/up.sql b/visions/server/migrations/01-initial-db/up.sql index 8f2ad6b..8d0a733 100644 --- a/visions/server/migrations/01-initial-db/up.sql +++ b/visions/server/migrations/01-initial-db/up.sql @@ -15,7 +15,11 @@ CREATE TABLE sessions( CREATE TABLE games( uuid TEXT PRIMARY KEY, - name TEXT + gm TEXT, + game_type TEXT, + name TEXT, + + FOREIGN KEY(gm) REFERENCES users(uuid) ); CREATE TABLE characters( diff --git a/visions/server/src/core.rs b/visions/server/src/core.rs index 2535458..079c6bb 100644 --- a/visions/server/src/core.rs +++ b/visions/server/src/core.rs @@ -10,7 +10,7 @@ use uuid::Uuid; use crate::{ asset_db::{self, AssetId, Assets}, - database::{CharacterId, Database, SessionId, UserId}, + database::{CharacterId, Database, GameId, SessionId, UserId}, types::{AppError, FatalError, Game, Message, Rgb, Tabletop, User}, }; @@ -131,12 +131,12 @@ impl Core { ok(users.into_iter().find(|user| user.id == user_id)) } - pub async fn create_user(&self, username: &str) -> ResultExt<(), AppError, FatalError> { + pub async fn create_user(&self, username: &str) -> ResultExt { let state = self.0.read().await; match return_error!(self.user_by_username(username).await) { Some(_) => error(AppError::UsernameUnavailable), None => match state.db.save_user(None, username, "", false, true).await { - Ok(_) => ok(()), + Ok(user_id) => ok(user_id), Err(err) => fatal(err), }, } @@ -151,6 +151,14 @@ impl Core { } } + pub async fn create_game(&self, gm: &UserId, game_type: &str, game_name: &str) -> ResultExt { + let state = self.0.read().await; + match state.db.save_game(None, gm, game_type, game_name).await { + Ok(game_id) => ok(game_id), + Err(err) => fatal(err), + } + } + pub async fn tabletop(&self) -> Tabletop { self.0.read().await.tabletop.clone() } diff --git a/visions/server/src/database/disk_db.rs b/visions/server/src/database/disk_db.rs index bc8dd6c..b14368e 100644 --- a/visions/server/src/database/disk_db.rs +++ b/visions/server/src/database/disk_db.rs @@ -142,23 +142,23 @@ impl DiskDb { Ok(items) } - pub fn save_game(&self, game_id: Option, name: &str) -> Result { + pub fn save_game(&self, game_id: Option, gm: &UserId, game_type: &str, name: &str) -> Result { match game_id { None => { let game_id = GameId::new(); let mut stmt = self .conn - .prepare("INSERT INTO games VALUES (?, ?)") + .prepare("INSERT INTO games VALUES (?, ?, ?, ?)") .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; - stmt.execute((game_id.as_str(), name)).unwrap(); + stmt.execute((game_id.as_str(), gm.as_str(), game_type, name)).unwrap(); Ok(game_id) } Some(game_id) => { let mut stmt = self .conn - .prepare("UPDATE games SET name=? WHERE uuid=?") + .prepare("UPDATE games SET gm=? game_type=? name=? WHERE uuid=?") .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; - stmt.execute((name, game_id.as_str())).unwrap(); + stmt.execute((gm.as_str(), game_type, name, game_id.as_str())).unwrap(); Ok(game_id) } } @@ -286,6 +286,16 @@ pub async fn db_handler(db: DiskDb, requestor: Receiver) { Request::Games => { unimplemented!(); } + Request::Game(_game_id) => { + unimplemented!(); + } + Request::SaveGame(game_id, user_id, game_type, game_name) => { + let game_id = db.save_game(game_id, &user_id, &game_type, &game_name); + match game_id { + Ok(game_id) => { tx.send(DatabaseResponse::SaveGame(game_id)).await.unwrap(); } + err => panic!("{:?}", err), + } + } Request::User(uid) => { let user = db.user(&uid); match user { diff --git a/visions/server/src/database/mod.rs b/visions/server/src/database/mod.rs index 39211ce..e46456e 100644 --- a/visions/server/src/database/mod.rs +++ b/visions/server/src/database/mod.rs @@ -6,7 +6,7 @@ use std::path::Path; use async_std::channel::{bounded, Sender}; use async_trait::async_trait; use disk_db::{db_handler, DiskDb}; -pub use types::{CharacterId, CharsheetRow, GameRow, SessionId, UserId, UserRow}; +pub use types::{CharacterId, CharsheetRow, GameId, GameRow, SessionId, UserId, UserRow}; use crate::types::FatalError; @@ -15,6 +15,8 @@ enum Request { Charsheet(CharacterId), CreateSession(UserId), Games, + Game(GameId), + SaveGame(Option, UserId, String, String), SaveUser(Option, String, String, bool, bool), Session(SessionId), User(UserId), @@ -33,6 +35,8 @@ enum DatabaseResponse { Charsheet(Option), CreateSession(SessionId), Games(Vec), + Game(Option), + SaveGame(GameId), SaveUser(UserId), Session(Option), User(Option), @@ -41,6 +45,8 @@ enum DatabaseResponse { #[async_trait] pub trait Database: Send + Sync { + async fn users(&mut self) -> Result, FatalError>; + async fn user(&self, _: &UserId) -> Result, FatalError>; async fn user_by_username(&self, _: &str) -> Result, FatalError>; @@ -54,10 +60,18 @@ pub trait Database: Send + Sync { enabled: bool, ) -> Result; - async fn users(&mut self) -> Result, FatalError>; - async fn games(&mut self) -> Result, FatalError>; + async fn game(&self, _: &GameId) -> Result, FatalError>; + + async fn save_game( + &self, + game_id: Option, + gm: &UserId, + game_type: &str, + game_name: &str, + ) -> Result; + async fn character(&mut self, id: &CharacterId) -> Result, FatalError>; async fn session(&self, id: &SessionId) -> Result, FatalError>; @@ -109,6 +123,10 @@ macro_rules! send_request { #[async_trait] impl Database for DbConn { + async fn users(&mut self) -> Result, FatalError> { + send_request!(self, Request::Users, DatabaseResponse::Users(lst) => Ok(lst)) + } + async fn user(&self, uid: &UserId) -> Result, FatalError> { send_request!(self, Request::User(uid.clone()), DatabaseResponse::User(user) => Ok(user)) } @@ -136,14 +154,24 @@ impl Database for DbConn { DatabaseResponse::SaveUser(user_id) => Ok(user_id)) } - async fn users(&mut self) -> Result, FatalError> { - send_request!(self, Request::Users, DatabaseResponse::Users(lst) => Ok(lst)) - } - async fn games(&mut self) -> Result, FatalError> { send_request!(self, Request::Games, DatabaseResponse::Games(lst) => Ok(lst)) } + async fn game(&self, game_id: &GameId) -> Result, FatalError> { + send_request!(self, Request::Game(game_id.clone()), DatabaseResponse::Game(game) => Ok(game)) + } + + async fn save_game( + &self, + game_id: Option, + user_id: &UserId, + game_type: &str, + game_name: &str, + ) -> Result { + send_request!(self, Request::SaveGame(game_id, user_id.to_owned(), game_type.to_owned(), game_name.to_owned()), DatabaseResponse::SaveGame(game_id) => Ok(game_id)) + } + async fn character(&mut self, id: &CharacterId) -> Result, FatalError> { send_request!(self, Request::Charsheet(id.to_owned()), DatabaseResponse::Charsheet(row) => Ok(row)) } @@ -173,8 +201,9 @@ mod test { let no_path: Option = None; let db = DiskDb::new(no_path).unwrap(); - db.save_user(Some(UserId::from("admin")), "admin", "abcdefg", true, true).unwrap(); - let game_id = db.save_game(None, "Candela").unwrap(); + db.save_user(Some(UserId::from("admin")), "admin", "abcdefg", true, true) + .unwrap(); + let game_id = db.save_game(None, &UserId::from("admin"), "Candela", "Circle of the Winter Solstice").unwrap(); (db, game_id) } diff --git a/visions/server/src/database/types.rs b/visions/server/src/database/types.rs index 8e2daef..cd1be73 100644 --- a/visions/server/src/database/types.rs +++ b/visions/server/src/database/types.rs @@ -172,7 +172,9 @@ pub struct Role { #[derive(Clone, Debug)] pub struct GameRow { - pub id: UserId, + pub id: GameId, + pub gm: UserId, + pub game_type: String, pub name: String, } @@ -189,3 +191,5 @@ pub struct SessionRow { user_id: SessionId, } + + diff --git a/visions/server/src/handlers/game_management.rs b/visions/server/src/handlers/game_management.rs new file mode 100644 index 0000000..818bb93 --- /dev/null +++ b/visions/server/src/handlers/game_management.rs @@ -0,0 +1,33 @@ +use axum::{http::{HeaderMap, StatusCode}, Json}; +use result_extended::ResultExt; +use serde::{Deserialize, Serialize}; + +use crate::{database::GameId, core::Core}; + +use super::auth_required; + + + +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +pub struct CreateGameRequest { + pub game_type: String, + pub game_name: String, +} + +pub async fn create_game( + core: Core, + headers: HeaderMap, + req: CreateGameRequest, +) -> (StatusCode, Json>) { + println!("create game handler"); + auth_required(core.clone(), headers, |user| async move { + let game = core.create_game(&user.id, &req.game_type, &req.game_name).await; + println!("create_game completed: {:?}", game); + match game { + ResultExt::Ok(game_id) => (StatusCode::OK, Json(Some(game_id))), + ResultExt::Err(_err) => (StatusCode::BAD_REQUEST, Json(None)), + ResultExt::Fatal(fatal) => panic!("{}", fatal), + } + }).await +} + diff --git a/visions/server/src/handlers/mod.rs b/visions/server/src/handlers/mod.rs index be48e68..5448961 100644 --- a/visions/server/src/handlers/mod.rs +++ b/visions/server/src/handlers/mod.rs @@ -1,3 +1,6 @@ +pub mod game_management; +pub use game_management::CreateGameRequest; + use std::future::Future; use axum::{ @@ -20,8 +23,6 @@ async fn check_session( core: &Core, headers: HeaderMap, ) -> ResultExt, AppError, FatalError> { - println!("headers: {:?}", headers); - println!("auth_header: {:?}", headers.get("Authorization")); match headers.get("Authorization") { Some(token) => { match token diff --git a/visions/server/src/routes.rs b/visions/server/src/routes.rs index 81db116..fd9703d 100644 --- a/visions/server/src/routes.rs +++ b/visions/server/src/routes.rs @@ -11,8 +11,7 @@ use crate::{ core::Core, database::UserId, handlers::{ - check_password, create_user, get_user, healthcheck, set_password, AuthRequest, - CreateUserRequest, SetPasswordRequest, + check_password, create_user, game_management::create_game, get_user, healthcheck, set_password, AuthRequest, CreateGameRequest, CreateUserRequest, SetPasswordRequest }, }; @@ -67,6 +66,16 @@ pub fn routes(core: Core) -> Router { } }), ) + .route( + "/api/v1/games", + put({ + let core = core.clone(); + move |headers: HeaderMap, req: Json| { + let Json(req) = req; + create_game(core, headers, req) + } + }), + ) } #[cfg(test)] @@ -82,8 +91,8 @@ mod test { use crate::{ asset_db::FsAssets, core::Core, - database::{Database, DbConn, SessionId, UserId}, - handlers::UserProfile, + database::{Database, DbConn, GameId, SessionId, UserId}, + handlers::{CreateGameRequest, UserProfile}, }; fn setup_without_admin() -> (Core, TestServer) { @@ -127,7 +136,15 @@ mod test { username: "savanni".to_owned(), }) .await; + response.assert_status_ok(); + let response = server + .put("/api/v1/user") + .add_header("Authorization", format!("Bearer {}", session_id)) + .json(&CreateUserRequest { + username: "shephard".to_owned(), + }) + .await; response.assert_status_ok(); (core, server) @@ -305,7 +322,7 @@ mod test { response.assert_status(StatusCode::BAD_REQUEST); let response = server - .put(&format!("/api/v1/user/password")) + .put("/api/v1/user/password") .add_header("Authorization", format!("Bearer {}", session_id)) .json(&SetPasswordRequest { password_1: "abcdefg".to_owned(), @@ -315,16 +332,28 @@ mod test { response.assert_status(StatusCode::OK); } - #[ignore] - #[tokio::test] - async fn a_user_cannot_change_another_users_password() { - unimplemented!(); - } - - #[ignore] #[tokio::test] async fn a_user_can_create_a_game() { - unimplemented!(); + let (_core, server) = setup_with_user().await; + + let response = server + .post("/api/v1/auth") + .json(&AuthRequest { + username: "savanni".to_owned(), + password: "".to_owned(), + }) + .await; + let session_id = response.json::>().unwrap(); + + let response = server + .put("/api/v1/games") + .add_header("Authorization", format!("Bearer {}", session_id)) + .json(&CreateGameRequest { + game_type: "Candela".to_owned(), + game_name: "Circle of the Winter Solstice".to_owned(), + }) + .await; + let _game_id = response.json::>().unwrap(); } #[ignore] diff --git a/visions/server/src/types.rs b/visions/server/src/types.rs index 3fbc70e..07c7b0a 100644 --- a/visions/server/src/types.rs +++ b/visions/server/src/types.rs @@ -33,6 +33,9 @@ pub enum AppError { #[error("invalid request")] BadRequest, + #[error("could not create an object")] + CouldNotCreateObject, + #[error("something wasn't found {0}")] NotFound(String), -- 2.47.1 From 5bb9f00a0d24aca49661fbc8da4b22f647205d7e Mon Sep 17 00:00:00 2001 From: Savanni D'Gerinel Date: Thu, 2 Jan 2025 13:41:05 -0500 Subject: [PATCH 18/23] Extract the user management handlers --- visions/server/src/filters/mod.rs | 138 ------------- visions/server/src/filters/user_management.rs | 182 ------------------ .../server/src/handlers/user_management.rs | 166 ++++++++++++++++ visions/server/src/main.rs | 5 +- visions/server/src/types.rs | 19 +- 5 files changed, 186 insertions(+), 324 deletions(-) delete mode 100644 visions/server/src/filters/mod.rs delete mode 100644 visions/server/src/filters/user_management.rs create mode 100644 visions/server/src/handlers/user_management.rs diff --git a/visions/server/src/filters/mod.rs b/visions/server/src/filters/mod.rs deleted file mode 100644 index a433057..0000000 --- a/visions/server/src/filters/mod.rs +++ /dev/null @@ -1,138 +0,0 @@ -mod user_management; -// pub use user_management::routes_user_management; - -use crate::{ - asset_db::AssetId, - core::Core, -}; - - -// Per-endpoint Authentication: -// -// If an endpoint requires authentication: -// - check the Authorization header for a token -// - if the token is absent or unknown, return a 403 -// - if the admin user is absent, return a 403, with a body that indicates the admin user is absent -// -// The login function does not require authentication, but it should return a session ID - -/* -fn cors(methods: Vec, headers: Vec) -> Builder -where - M: Into, - H: Into, -{ - warp::cors() - .allow_credentials(true) - .allow_methods(methods) - .allow_headers(headers) -} -*/ - -/* -pub fn route_healthcheck() -> impl Filter + Clone -{ - warp::path!("api" / "v1" / "healthcheck") - .and(warp::get()) - .map(|| warp::reply::reply()) -} - -pub fn route_server_status( - core: Core, -) -> impl Filter + Clone { - warp::path!("api" / "v1" / "status") - .and(warp::get()) - .then(move || handle_server_status(core.clone())) -} - -pub fn route_set_bg_image( - core: Core, -) -> impl Filter + Clone { - warp::path!("api" / "v1" / "tabletop" / "bg_image") - .and(warp::put()) - .and(warp::body::json()) - .then({ - let core = core.clone(); - move |body| handle_set_background_image(core.clone(), body) - }) - .with(cors::(vec![Method::PUT], vec![])) -} - -pub fn route_image( - core: Core, -) -> impl Filter + Clone { - warp::path!("api" / "v1" / "image" / String) - .and(warp::get()) - .then({ - let core = core.clone(); - move |file_name| handle_file(core.clone(), AssetId::from(file_name)) - }) -} - -pub fn route_available_images( - core: Core, -) -> impl Filter + Clone { - warp::path!("api" / "v1" / "image").and(warp::get()).then({ - let core = core.clone(); - move || handle_available_images(core.clone()) - }) -} - -pub fn route_register_client( - core: Core, -) -> impl Filter + Clone { - warp::path!("api" / "v1" / "client") - .and(warp::post()) - .then({ - let core = core.clone(); - move || handle_register_client(core.clone(), RegisterRequest {}) - }) -} - -pub fn route_unregister_client( - core: Core, -) -> impl Filter + Clone { - warp::path!("api" / "v1" / "client" / String) - .and(warp::delete()) - .then({ - let core = core.clone(); - move |client_id| handle_unregister_client(core.clone(), client_id) - }) -} - -pub fn route_websocket( - core: Core, -) -> impl Filter + Clone { - warp::path("ws") - .and(warp::ws()) - .and(warp::path::param()) - .then({ - let core = core.clone(); - move |ws, client_id| handle_connect_websocket(core.clone(), ws, client_id) - }) -} - -pub fn route_get_charsheet( - core: Core, -) -> impl Filter + Clone { - warp::path!("api" / "v1" / "charsheet" / String) - .and(warp::get()) - .then({ - let core = core.clone(); - move |charid| handle_get_charsheet(core.clone(), charid) - }) -} - -pub fn route_authenticate( - core: Core, -) -> impl Filter + Clone { - warp::path!("api" / "v1" / "auth") - .and(warp::put()) - .and(warp::body::json()) - .then({ - let core = core.clone(); - move |body| handle_check_password(core.clone(), body) - }) - .with(cors::(vec![Method::PUT], vec![])) -} -*/ diff --git a/visions/server/src/filters/user_management.rs b/visions/server/src/filters/user_management.rs deleted file mode 100644 index 1a80bb9..0000000 --- a/visions/server/src/filters/user_management.rs +++ /dev/null @@ -1,182 +0,0 @@ -use std::{convert::Infallible, future::Future}; - -use crate::{ - core::Core, -}; - -/* -async fn handle_rejection(err: warp::Rejection) -> Result { - println!("handle_rejection: {:?}", err); - if let Some(Unauthorized) = err.find() { - Ok(warp::reply::with_status( - "".to_owned(), - StatusCode::UNAUTHORIZED, - )) - } else { - Ok(warp::reply::with_status( - "".to_owned(), - StatusCode::INTERNAL_SERVER_ERROR, - )) - } -} -*/ - -/* -#[derive(Debug)] -struct Unauthorized; - -impl reject::Reject for Unauthorized {} - -use super::cors; - -fn route_get_users( - core: Core, -) -> impl Filter>,), Error = warp::Rejection> + Clone { - warp::path!("api" / "v1" / "users") - .and(warp::get()) - .and(warp::header::optional::("authorization")) - .and(warp::any().map(move || core.clone())) - .and_then(|auth_token, core: Core| async move { - match auth_token { - Some(token) => Ok(handle_get_users(core.clone()).await), - None => Err(warp::reject::custom(Unauthorized)), - } - }) -} - -fn route_set_admin_password( - core: Core, -) -> impl Filter + Clone { - warp::path!("api" / "v1" / "admin_password") - .and(warp::put()) - .and(warp::body::json()) - .then({ move |body| handle_set_admin_password(core.clone(), body) }) - .with(cors(vec![Method::PUT], vec![CONTENT_TYPE])) -} - -pub fn routes_user_management( - core: Core, -) -> impl Filter + Clone { - route_get_users(core.clone()).or(route_set_admin_password(core.clone())) -} -*/ - -/* -pub fn route_check_password( - core: Core, -) -> impl Filter + Clone { - warp::path!("api" / "v1" / "auth") - .and(warp::put()) - .and(warp::body::json()) - .then({ move |body| handle_check_password(core.clone(), body) }) - .with(cors::(vec![Method::PUT], vec![])) -} -*/ - -/* -#[cfg(test)] -mod test { - use std::{collections::HashMap, path::PathBuf}; - - use result_extended::ResultExt; - use warp::http::StatusCode; - - use crate::{ - asset_db::mocks::MemoryAssets, - database::{Database, DbConn, UserId}, - }; - - use super::*; - - async fn setup() -> Core { - let asset_store = MemoryAssets::new(vec![]); - let memory_file: Option = None; - let db = DbConn::new(memory_file); - db.save_user(None, "admin", "", true, true).await.unwrap(); - Core::new(asset_store, db) - } - - #[tokio::test] - async fn handle_check_password_should_return_a_valid_token() { - let core = setup().await; - match core.list_users().await { - ResultExt::Ok(users) => println!("{:?}", users), - ResultExt::Err(err) => panic!("{}", err), - ResultExt::Fatal(err) => panic!("{}", err), - } - match core.user_by_username("admin").await { - ResultExt::Ok(Some(user)) => { - let _ = core - .set_password(UserId::from(user.id), "aoeu".to_owned()) - .await; - } - ResultExt::Ok(None) => panic!("expected user wasn't found"), - ResultExt::Err(err) => panic!("{}", err), - ResultExt::Fatal(err) => panic!("{}", err), - } - let filter = route_check_password(core); - let params: HashMap = vec![ - ("username".to_owned(), "admin".to_owned()), - ("password".to_owned(), "aoeu".to_owned()), - ] - .into_iter() - .collect(); - let resp = warp::test::request() - .method("PUT") - .path("/api/v1/auth") - .json(¶ms) - .reply(&filter) - .await; - - println!("response: {}", resp.status()); - assert!(resp.status().is_success()); - println!( - "resp.body(): {}", - String::from_utf8(resp.body().to_vec()).unwrap() - ); - serde_json::from_slice::(resp.body()).unwrap(); - } - - /* - #[tokio::test] - async fn handle_check_auth_token() { - let core = setup().await; - let filter = route_get_users(core); - let response = warp::test::request() - .method("GET") - .path("/api/v1/users") - .header("Authorization", "abcdefg") - .reply(&filter) - .await; - - println!("response: {}", response.status()); - assert!(false); - } - */ - - #[tokio::test] - async fn it_rejects_unauthorized_requests() { - let core = setup().await; - let filter = route_get_users(core) - .recover(handle_rejection); - let response = warp::test::request() - .method("GET") - .path("/api/v1/users") - .reply(&filter) - .await; - - println!("response: {:?}", response); - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - } - - #[tokio::test] - async fn it_accepts_authorized_requests() { - unimplemented!(); - } - - #[tokio::test] - async fn it_returns_special_response_for_no_admin() { - unimplemented!(); - } -} -*/ diff --git a/visions/server/src/handlers/user_management.rs b/visions/server/src/handlers/user_management.rs new file mode 100644 index 0000000..1634b01 --- /dev/null +++ b/visions/server/src/handlers/user_management.rs @@ -0,0 +1,166 @@ +use serde::{Deserialize, Serialize}; + +use crate::database::UserId; + + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[typeshare] +pub struct AuthRequest { + pub username: String, + pub password: String, +} + +#[derive(Deserialize, Serialize)] +#[typeshare] +pub struct CreateUserRequest { + pub username: String, +} + +#[derive(Deserialize, Serialize)] +#[typeshare] +pub struct SetPasswordRequest { + pub password_1: String, + pub password_2: String, +} + +async fn check_session( + core: &Core, + headers: HeaderMap, +) -> ResultExt, AppError, FatalError> { + match headers.get("Authorization") { + Some(token) => { + match token + .to_str() + .unwrap() + .split(" ") + .collect::>() + .as_slice() + { + [_schema, token] => core.session(&SessionId::from(token.to_owned())).await, + _ => error(AppError::BadRequest), + } + } + None => ok(None), + } +} + +pub async fn auth_required( + core: Core, + headers: HeaderMap, + f: F, +) -> (StatusCode, Json>) +where + F: FnOnce(User) -> Fut, + Fut: Future>)>, +{ + match check_session(&core, headers).await { + ResultExt::Ok(Some(user)) => f(user).await, + ResultExt::Ok(None) => (StatusCode::UNAUTHORIZED, Json(None)), + ResultExt::Err(_err) => (StatusCode::BAD_REQUEST, Json(None)), + ResultExt::Fatal(err) => panic!("{}", err), + } +} + +pub async fn admin_required( + core: Core, + headers: HeaderMap, + f: F, +) -> (StatusCode, Json>) +where + F: FnOnce(User) -> Fut, + Fut: Future>)>, +{ + match check_session(&core, headers).await { + ResultExt::Ok(Some(user)) => { + if user.admin { + f(user).await + } else { + (StatusCode::FORBIDDEN, Json(None)) + } + } + ResultExt::Ok(None) => (StatusCode::UNAUTHORIZED, Json(None)), + ResultExt::Err(_err) => (StatusCode::BAD_REQUEST, Json(None)), + ResultExt::Fatal(err) => panic!("{}", err), + } +} + +pub async fn check_password( + core: Core, + req: Json, +) -> (StatusCode, Json>) { + let Json(AuthRequest { username, password }) = req; + match core.auth(&username, &password).await { + ResultExt::Ok(session_id) => (StatusCode::OK, Json(Some(session_id))), + ResultExt::Err(_err) => (StatusCode::UNAUTHORIZED, Json(None)), + ResultExt::Fatal(err) => panic!("Fatal: {}", err), + } +} + +pub async fn get_user( + core: Core, + headers: HeaderMap, + user_id: Option, +) -> (StatusCode, Json>) { + auth_required(core.clone(), headers, |user| async move { + match user_id { + Some(user_id) => match core.user(user_id).await { + ResultExt::Ok(Some(user)) => ( + StatusCode::OK, + Json(Some(UserProfile { + userid: UserId::from(user.id), + username: user.name, + is_admin: user.admin, + })), + ), + ResultExt::Ok(None) => (StatusCode::NOT_FOUND, Json(None)), + ResultExt::Err(_err) => (StatusCode::BAD_REQUEST, Json(None)), + ResultExt::Fatal(err) => panic!("{}", err), + }, + None => ( + StatusCode::OK, + Json(Some(UserProfile { + userid: UserId::from(user.id), + username: user.name, + is_admin: user.admin, + })), + ), + } + }) + .await +} + +pub async fn create_user( + core: Core, + headers: HeaderMap, + req: CreateUserRequest, +) -> (StatusCode, Json>) { + admin_required(core.clone(), headers, |_admin| async { + match core.create_user(&req.username).await { + ResultExt::Ok(_) => (StatusCode::OK, Json(None)), + ResultExt::Err(_err) => (StatusCode::BAD_REQUEST, Json(None)), + ResultExt::Fatal(fatal) => panic!("{}", fatal), + } + }) + .await +} + +pub async fn set_password( + core: Core, + headers: HeaderMap, + req: SetPasswordRequest, +) -> (StatusCode, Json>) { + auth_required(core.clone(), headers, |user| async { + if req.password_1 == req.password_2 { + match core.set_password(user.id, req.password_1).await { + ResultExt::Ok(_) => (StatusCode::OK, Json(None)), + ResultExt::Err(_err) => (StatusCode::BAD_REQUEST, Json(None)), + ResultExt::Fatal(fatal) => panic!("{}", fatal), + } + } else { + (StatusCode::BAD_REQUEST, Json(None)) + } + }) + .await +} + + diff --git a/visions/server/src/main.rs b/visions/server/src/main.rs index 84524e6..71032a2 100644 --- a/visions/server/src/main.rs +++ b/visions/server/src/main.rs @@ -7,9 +7,8 @@ use database::DbConn; mod asset_db; mod core; mod database; -mod filters; -pub mod handlers; -pub mod routes; +mod handlers; +mod routes; mod types; #[tokio::main] diff --git a/visions/server/src/types.rs b/visions/server/src/types.rs index 07c7b0a..a9d5f84 100644 --- a/visions/server/src/types.rs +++ b/visions/server/src/types.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use typeshare::typeshare; -use crate::{asset_db::AssetId, database::{UserId, UserRow}}; +use crate::{asset_db::AssetId, database::{GameId, UserId, UserRow}}; #[derive(Debug, Error)] pub enum FatalError { @@ -129,4 +129,21 @@ pub enum Message { UpdateTabletop(Tabletop), } +#[derive(Deserialize, Serialize)] +#[typeshare] +pub struct UserProfile { + pub id: UserId, + pub username: String, + pub games: Vec, + pub is_admin: bool, +} + +#[derive(Deserialize, Serialize)] +#[typeshare] +pub struct GameOverview { + pub id: GameId, + pub game_type: String, + pub game_name: String, +} + -- 2.47.1 From 4dc7a5000028a90505f147f9a0f06243fd35bec8 Mon Sep 17 00:00:00 2001 From: Savanni D'Gerinel Date: Thu, 2 Jan 2025 14:45:06 -0500 Subject: [PATCH 19/23] Get games that the user is GMing in the user profile --- visions/server/src/core.rs | 24 +- visions/server/src/database/disk_db.rs | 58 +++- visions/server/src/database/mod.rs | 12 +- visions/server/src/handlers/mod.rs | 273 +----------------- .../server/src/handlers/user_management.rs | 30 +- visions/server/src/routes.rs | 20 +- visions/server/src/types.rs | 24 +- 7 files changed, 123 insertions(+), 318 deletions(-) diff --git a/visions/server/src/core.rs b/visions/server/src/core.rs index 079c6bb..e9224f6 100644 --- a/visions/server/src/core.rs +++ b/visions/server/src/core.rs @@ -10,8 +10,7 @@ use uuid::Uuid; use crate::{ asset_db::{self, AssetId, Assets}, - database::{CharacterId, Database, GameId, SessionId, UserId}, - types::{AppError, FatalError, Game, Message, Rgb, Tabletop, User}, + database::{CharacterId, Database, GameId, SessionId, UserId}, types::{AppError, FatalError, Game, GameOverview, Message, Rgb, Tabletop, User, UserProfile}, }; const DEFAULT_BACKGROUND_COLOR: Rgb = Rgb { @@ -126,9 +125,20 @@ impl Core { } } - pub async fn user(&self, user_id: UserId) -> ResultExt, AppError, FatalError> { + pub async fn user(&self, user_id: UserId) -> ResultExt, AppError, FatalError> { let users = return_error!(self.list_users().await); - ok(users.into_iter().find(|user| user.id == user_id)) + let games = return_error!(self.list_games().await); + let user = match users.into_iter().find(|user| user.id == user_id) { + Some(user) => user, + None => return ok(None), + }; + let user_games = games.into_iter().filter(|g| g.gm == user.id).collect(); + ok(Some(UserProfile { + id: user.id, + name: user.name, + games: user_games, + is_admin: user.admin, + })) } pub async fn create_user(&self, username: &str) -> ResultExt { @@ -142,11 +152,11 @@ impl Core { } } - pub async fn list_games(&self) -> ResultExt, AppError, FatalError> { - let games = self.0.write().await.db.games().await; + pub async fn list_games(&self) -> ResultExt, AppError, FatalError> { + let games = self.0.read().await.db.games().await; match games { // Ok(games) => ok(games.into_iter().map(|g| Game::from(g)).collect()), - Ok(_games) => unimplemented!(), + Ok(games) => ok(games.into_iter().map(GameOverview::from).collect()), Err(err) => fatal(err), } } diff --git a/visions/server/src/database/disk_db.rs b/visions/server/src/database/disk_db.rs index b14368e..5cc98eb 100644 --- a/visions/server/src/database/disk_db.rs +++ b/visions/server/src/database/disk_db.rs @@ -1,14 +1,19 @@ use std::path::Path; -use async_std::channel::{bounded, Receiver, Sender}; +use async_std::channel::Receiver; use include_dir::{include_dir, Dir}; use lazy_static::lazy_static; use rusqlite::Connection; use rusqlite_migration::Migrations; -use crate::{database::{DatabaseResponse, Request}, types::FatalError}; +use crate::{ + database::{DatabaseResponse, Request}, + types::FatalError, +}; -use super::{types::GameId, CharacterId, CharsheetRow, DatabaseRequest, SessionId, UserId, UserRow}; +use super::{ + types::GameId, CharacterId, CharsheetRow, DatabaseRequest, GameRow, SessionId, UserId, UserRow +}; static MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/migrations"); @@ -142,7 +147,13 @@ impl DiskDb { Ok(items) } - pub fn save_game(&self, game_id: Option, gm: &UserId, game_type: &str, name: &str) -> Result { + pub fn save_game( + &self, + game_id: Option, + gm: &UserId, + game_type: &str, + name: &str, + ) -> Result { match game_id { None => { let game_id = GameId::new(); @@ -150,7 +161,8 @@ impl DiskDb { .conn .prepare("INSERT INTO games VALUES (?, ?, ?, ?)") .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; - stmt.execute((game_id.as_str(), gm.as_str(), game_type, name)).unwrap(); + stmt.execute((game_id.as_str(), gm.as_str(), game_type, name)) + .unwrap(); Ok(game_id) } Some(game_id) => { @@ -158,12 +170,33 @@ impl DiskDb { .conn .prepare("UPDATE games SET gm=? game_type=? name=? WHERE uuid=?") .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; - stmt.execute((gm.as_str(), game_type, name, game_id.as_str())).unwrap(); + stmt.execute((gm.as_str(), game_type, name, game_id.as_str())) + .unwrap(); Ok(game_id) } } } + pub fn games(&self) -> Result, FatalError> { + let mut stmt = self + .conn + .prepare("SELECT * FROM games") + .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?; + let items = stmt + .query_map([], |row| { + Ok(GameRow { + id: row.get(0).unwrap(), + gm: row.get(1).unwrap(), + game_type: row.get(2).unwrap(), + name: row.get(3).unwrap(), + }) + }) + .unwrap() + .collect::, rusqlite::Error>>() + .unwrap(); + Ok(items) + } + pub fn session(&self, session_id: &SessionId) -> Result, FatalError> { let mut stmt = self.conn .prepare("SELECT u.uuid, u.name, u.password, u.admin, u.enabled FROM sessions s INNER JOIN users u ON u.uuid = s.user_id WHERE s.id = ?") @@ -274,7 +307,7 @@ pub async fn db_handler(db: DiskDb, requestor: Receiver) { Ok(sheet) => { tx.send(DatabaseResponse::Charsheet(sheet)).await.unwrap(); } - _ => unimplemented!(), + _ => unimplemented!("errors for Charsheet"), } } Request::CreateSession(id) => { @@ -284,15 +317,20 @@ pub async fn db_handler(db: DiskDb, requestor: Receiver) { .unwrap(); } Request::Games => { - unimplemented!(); + match db.games() { + Ok(games) => tx.send(DatabaseResponse::Games(games)).await.unwrap(), + _ => unimplemented!("errors for Request::Games"), + } } Request::Game(_game_id) => { - unimplemented!(); + unimplemented!("Request::Game handler"); } Request::SaveGame(game_id, user_id, game_type, game_name) => { let game_id = db.save_game(game_id, &user_id, &game_type, &game_name); match game_id { - Ok(game_id) => { tx.send(DatabaseResponse::SaveGame(game_id)).await.unwrap(); } + Ok(game_id) => { + tx.send(DatabaseResponse::SaveGame(game_id)).await.unwrap(); + } err => panic!("{:?}", err), } } diff --git a/visions/server/src/database/mod.rs b/visions/server/src/database/mod.rs index e46456e..02b040b 100644 --- a/visions/server/src/database/mod.rs +++ b/visions/server/src/database/mod.rs @@ -45,7 +45,7 @@ enum DatabaseResponse { #[async_trait] pub trait Database: Send + Sync { - async fn users(&mut self) -> Result, FatalError>; + async fn users(&self) -> Result, FatalError>; async fn user(&self, _: &UserId) -> Result, FatalError>; @@ -60,7 +60,7 @@ pub trait Database: Send + Sync { enabled: bool, ) -> Result; - async fn games(&mut self) -> Result, FatalError>; + async fn games(&self) -> Result, FatalError>; async fn game(&self, _: &GameId) -> Result, FatalError>; @@ -72,7 +72,7 @@ pub trait Database: Send + Sync { game_name: &str, ) -> Result; - async fn character(&mut self, id: &CharacterId) -> Result, FatalError>; + async fn character(&self, id: &CharacterId) -> Result, FatalError>; async fn session(&self, id: &SessionId) -> Result, FatalError>; @@ -123,7 +123,7 @@ macro_rules! send_request { #[async_trait] impl Database for DbConn { - async fn users(&mut self) -> Result, FatalError> { + async fn users(&self) -> Result, FatalError> { send_request!(self, Request::Users, DatabaseResponse::Users(lst) => Ok(lst)) } @@ -154,7 +154,7 @@ impl Database for DbConn { DatabaseResponse::SaveUser(user_id) => Ok(user_id)) } - async fn games(&mut self) -> Result, FatalError> { + async fn games(&self) -> Result, FatalError> { send_request!(self, Request::Games, DatabaseResponse::Games(lst) => Ok(lst)) } @@ -172,7 +172,7 @@ impl Database for DbConn { send_request!(self, Request::SaveGame(game_id, user_id.to_owned(), game_type.to_owned(), game_name.to_owned()), DatabaseResponse::SaveGame(game_id) => Ok(game_id)) } - async fn character(&mut self, id: &CharacterId) -> Result, FatalError> { + async fn character(&self, id: &CharacterId) -> Result, FatalError> { send_request!(self, Request::Charsheet(id.to_owned()), DatabaseResponse::Charsheet(row) => Ok(row)) } diff --git a/visions/server/src/handlers/mod.rs b/visions/server/src/handlers/mod.rs index 5448961..d2d7056 100644 --- a/visions/server/src/handlers/mod.rs +++ b/visions/server/src/handlers/mod.rs @@ -1,44 +1,12 @@ -pub mod game_management; -pub use game_management::CreateGameRequest; +mod game_management; +mod user_management; +pub use game_management::*; +pub use user_management::*; -use std::future::Future; - -use axum::{ - http::{HeaderMap, StatusCode}, - Json, -}; -use futures::{SinkExt, StreamExt}; -use result_extended::{error, ok, return_error, ResultExt}; +use result_extended::ResultExt; use serde::{Deserialize, Serialize}; -use typeshare::typeshare; -use crate::{ - asset_db::AssetId, - core::Core, - database::{CharacterId, SessionId, UserId}, - types::{AppError, FatalError, User}, -}; - -async fn check_session( - core: &Core, - headers: HeaderMap, -) -> ResultExt, AppError, FatalError> { - match headers.get("Authorization") { - Some(token) => { - match token - .to_str() - .unwrap() - .split(" ") - .collect::>() - .as_slice() - { - [_schema, token] => core.session(&SessionId::from(token.to_owned())).await, - _ => error(AppError::BadRequest), - } - } - None => ok(None), - } -} +use crate::core::Core; #[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] pub struct HealthCheck { @@ -56,213 +24,7 @@ pub async fn healthcheck(core: Core) -> Vec { } } -#[derive(Clone, Debug, Deserialize, Serialize)] -#[typeshare] -pub struct AuthRequest { - pub username: String, - pub password: String, -} - -pub async fn check_password( - core: Core, - req: Json, -) -> (StatusCode, Json>) { - let Json(AuthRequest { username, password }) = req; - match core.auth(&username, &password).await { - ResultExt::Ok(session_id) => (StatusCode::OK, Json(Some(session_id))), - ResultExt::Err(_err) => (StatusCode::UNAUTHORIZED, Json(None)), - ResultExt::Fatal(err) => panic!("Fatal: {}", err), - } -} - -pub async fn auth_required( - core: Core, - headers: HeaderMap, - f: F, -) -> (StatusCode, Json>) -where - F: FnOnce(User) -> Fut, - Fut: Future>)>, -{ - match check_session(&core, headers).await { - ResultExt::Ok(Some(user)) => f(user).await, - ResultExt::Ok(None) => (StatusCode::UNAUTHORIZED, Json(None)), - ResultExt::Err(_err) => (StatusCode::BAD_REQUEST, Json(None)), - ResultExt::Fatal(err) => panic!("{}", err), - } -} - -pub async fn admin_required( - core: Core, - headers: HeaderMap, - f: F, -) -> (StatusCode, Json>) -where - F: FnOnce(User) -> Fut, - Fut: Future>)>, -{ - match check_session(&core, headers).await { - ResultExt::Ok(Some(user)) => { - if user.admin { - f(user).await - } else { - (StatusCode::FORBIDDEN, Json(None)) - } - } - ResultExt::Ok(None) => (StatusCode::UNAUTHORIZED, Json(None)), - ResultExt::Err(_err) => (StatusCode::BAD_REQUEST, Json(None)), - ResultExt::Fatal(err) => panic!("{}", err), - } -} - -#[derive(Deserialize, Serialize)] -#[typeshare] -pub struct UserProfile { - pub userid: UserId, - pub username: String, - pub is_admin: bool, -} - -pub async fn get_user( - core: Core, - headers: HeaderMap, - user_id: Option, -) -> (StatusCode, Json>) { - auth_required(core.clone(), headers, |user| async move { - match user_id { - Some(user_id) => match core.user(user_id).await { - ResultExt::Ok(Some(user)) => ( - StatusCode::OK, - Json(Some(UserProfile { - userid: UserId::from(user.id), - username: user.name, - is_admin: user.admin, - })), - ), - ResultExt::Ok(None) => (StatusCode::NOT_FOUND, Json(None)), - ResultExt::Err(_err) => (StatusCode::BAD_REQUEST, Json(None)), - ResultExt::Fatal(err) => panic!("{}", err), - }, - None => ( - StatusCode::OK, - Json(Some(UserProfile { - userid: UserId::from(user.id), - username: user.name, - is_admin: user.admin, - })), - ), - } - }) - .await -} - -#[derive(Deserialize, Serialize)] -#[typeshare] -pub struct CreateUserRequest { - pub username: String, -} - -pub async fn create_user( - core: Core, - headers: HeaderMap, - req: CreateUserRequest, -) -> (StatusCode, Json>) { - admin_required(core.clone(), headers, |_admin| async { - match core.create_user(&req.username).await { - ResultExt::Ok(_) => (StatusCode::OK, Json(None)), - ResultExt::Err(_err) => (StatusCode::BAD_REQUEST, Json(None)), - ResultExt::Fatal(fatal) => panic!("{}", fatal), - } - }) - .await -} - -#[derive(Deserialize, Serialize)] -#[typeshare] -pub struct SetPasswordRequest { - pub password_1: String, - pub password_2: String, -} - -pub async fn set_password( - core: Core, - headers: HeaderMap, - req: SetPasswordRequest, -) -> (StatusCode, Json>) { - auth_required(core.clone(), headers, |user| async { - if req.password_1 == req.password_2 { - match core.set_password(user.id, req.password_1).await { - ResultExt::Ok(_) => (StatusCode::OK, Json(None)), - ResultExt::Err(_err) => (StatusCode::BAD_REQUEST, Json(None)), - ResultExt::Fatal(fatal) => panic!("{}", fatal), - } - } else { - (StatusCode::BAD_REQUEST, Json(None)) - } - }) - .await -} - /* -pub async fn handle_auth( - auth_ctx: &AuthDB, - auth_token: AuthToken, -) -> Result, Error> { - match auth_ctx.authenticate(auth_token).await { - Ok(Some(session)) => match serde_json::to_string(&session) { - Ok(session_token) => Response::builder() - .status(StatusCode::OK) - .body(session_token), - Err(_) => Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .body("".to_owned()), - }, - Ok(None) => Response::builder() - .status(StatusCode::UNAUTHORIZED) - .body("".to_owned()), - Err(_) => Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .body("".to_owned()), - } -} -*/ - -/* -pub async fn handler(f: F) -> impl Reply -where - F: Future>, AppError, FatalError>>, -{ - match f.await { - ResultExt::Ok(response) => response, - ResultExt::Err(AppError::NotFound(_)) => Response::builder() - .status(StatusCode::NOT_FOUND) - .body(vec![]) - .unwrap(), - ResultExt::Err(err) => { - println!("request error: {:?}", err); - Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .body(vec![]) - .unwrap() - } - ResultExt::Fatal(err) => { - panic!("Shutting down with fatal error: {:?}", err); - } - } -} - -pub async fn handle_server_status(core: Core) -> impl Reply { - handler(async move { - let status = return_error!(core.status().await); - ok(Response::builder() - .header("Access-Control-Allow-Origin", "*") - .header("Content-Type", "application/json") - .body(serde_json::to_vec(&status).unwrap()) - .unwrap()) - }) - .await -} - pub async fn handle_file(core: Core, asset_id: AssetId) -> impl Reply { handler(async move { let (mime, bytes) = return_error!(core.get_asset(asset_id).await); @@ -453,27 +215,4 @@ pub async fn handle_set_admin_password(core: Core, password: String) -> impl Rep }) .await } - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[typeshare] -pub struct AuthRequest { - username: String, - password: String, -} - -pub async fn handle_check_password(core: Core, auth_request: AuthRequest) -> impl Reply { - handler(async move { - let session_id = return_error!( - core.auth(&auth_request.username, &auth_request.password) - .await - ); - println!("handle_check_password: {:?}", session_id); - - ok(Response::builder() - .header("Content-Type", "application/json") - .body(serde_json::to_vec(&session_id).unwrap()) - .unwrap()) - }) - .await -} */ diff --git a/visions/server/src/handlers/user_management.rs b/visions/server/src/handlers/user_management.rs index 1634b01..3a30cfe 100644 --- a/visions/server/src/handlers/user_management.rs +++ b/visions/server/src/handlers/user_management.rs @@ -1,7 +1,17 @@ +use axum::{ + http::{HeaderMap, StatusCode}, + Json, +}; +use futures::Future; +use result_extended::{error, ok, ResultExt}; use serde::{Deserialize, Serialize}; +use typeshare::typeshare; -use crate::database::UserId; - +use crate::{ + core::Core, + database::{SessionId, UserId}, + types::{AppError, FatalError, User, UserProfile}, +}; #[derive(Clone, Debug, Deserialize, Serialize)] #[typeshare] @@ -104,14 +114,7 @@ pub async fn get_user( auth_required(core.clone(), headers, |user| async move { match user_id { Some(user_id) => match core.user(user_id).await { - ResultExt::Ok(Some(user)) => ( - StatusCode::OK, - Json(Some(UserProfile { - userid: UserId::from(user.id), - username: user.name, - is_admin: user.admin, - })), - ), + ResultExt::Ok(Some(user)) => (StatusCode::OK, Json(Some(user))), ResultExt::Ok(None) => (StatusCode::NOT_FOUND, Json(None)), ResultExt::Err(_err) => (StatusCode::BAD_REQUEST, Json(None)), ResultExt::Fatal(err) => panic!("{}", err), @@ -119,9 +122,10 @@ pub async fn get_user( None => ( StatusCode::OK, Json(Some(UserProfile { - userid: UserId::from(user.id), - username: user.name, + id: UserId::from(user.id), + name: user.name, is_admin: user.admin, + games: vec![], })), ), } @@ -162,5 +166,3 @@ pub async fn set_password( }) .await } - - diff --git a/visions/server/src/routes.rs b/visions/server/src/routes.rs index fd9703d..76150ef 100644 --- a/visions/server/src/routes.rs +++ b/visions/server/src/routes.rs @@ -1,8 +1,6 @@ -use std::fmt; - use axum::{ extract::Path, - http::{HeaderMap, StatusCode}, + http::HeaderMap, routing::{get, post, put}, Json, Router, }; @@ -11,7 +9,8 @@ use crate::{ core::Core, database::UserId, handlers::{ - check_password, create_user, game_management::create_game, get_user, healthcheck, set_password, AuthRequest, CreateGameRequest, CreateUserRequest, SetPasswordRequest + check_password, create_game, create_user, get_user, healthcheck, set_password, AuthRequest, + CreateGameRequest, CreateUserRequest, SetPasswordRequest, }, }; @@ -92,7 +91,8 @@ mod test { asset_db::FsAssets, core::Core, database::{Database, DbConn, GameId, SessionId, UserId}, - handlers::{CreateGameRequest, UserProfile}, + handlers::CreateGameRequest, + types::UserProfile, }; fn setup_without_admin() -> (Core, TestServer) { @@ -230,8 +230,8 @@ mod test { response.assert_status_ok(); let profile: Option = response.json(); let profile = profile.unwrap(); - assert_eq!(profile.userid, UserId::from("admin")); - assert_eq!(profile.username, "admin"); + assert_eq!(profile.id, UserId::from("admin")); + assert_eq!(profile.name, "admin"); } #[tokio::test] @@ -269,7 +269,7 @@ mod test { response.assert_status_ok(); let profile: Option = response.json(); let profile = profile.unwrap(); - assert_eq!(profile.username, "savanni"); + assert_eq!(profile.name, "savanni"); let response = server .get("/api/v1/user/admin") @@ -278,7 +278,7 @@ mod test { response.assert_status_ok(); let profile: Option = response.json(); let profile = profile.unwrap(); - assert_eq!(profile.username, "admin"); + assert_eq!(profile.name, "admin"); } #[tokio::test] @@ -300,7 +300,7 @@ mod test { .add_header("Authorization", format!("Bearer {}", session_id)) .await; let profile = response.json::>().unwrap(); - assert_eq!(profile.username, "savanni"); + assert_eq!(profile.name, "savanni"); let response = server .put("/api/v1/user/password") diff --git a/visions/server/src/types.rs b/visions/server/src/types.rs index a9d5f84..2e892b2 100644 --- a/visions/server/src/types.rs +++ b/visions/server/src/types.rs @@ -3,7 +3,10 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use typeshare::typeshare; -use crate::{asset_db::AssetId, database::{GameId, UserId, UserRow}}; +use crate::{ + asset_db::AssetId, + database::{GameId, GameRow, UserId, UserRow}, +}; #[derive(Debug, Error)] pub enum FatalError { @@ -111,7 +114,8 @@ pub struct Player { pub struct Game { pub id: String, pub name: String, - pub players: Vec, + pub gm: UserId, + pub players: Vec, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -133,7 +137,7 @@ pub enum Message { #[typeshare] pub struct UserProfile { pub id: UserId, - pub username: String, + pub name: String, pub games: Vec, pub is_admin: bool, } @@ -144,6 +148,18 @@ pub struct GameOverview { pub id: GameId, pub game_type: String, pub game_name: String, + pub gm: UserId, + pub players: Vec, } - +impl From for GameOverview { + fn from(row: GameRow) -> Self { + Self { + id: row.id, + gm: row.gm, + game_type: row.game_type, + game_name: row.name, + players: vec![], + } + } +} -- 2.47.1 From dc8cb834e09849da18e3f42856d09cd388b09c06 Mon Sep 17 00:00:00 2001 From: Savanni D'Gerinel Date: Thu, 2 Jan 2025 22:34:10 -0500 Subject: [PATCH 20/23] Handle all applications errors in one location --- Cargo.lock | 12 +++ visions/server/Cargo.toml | 2 +- .../server/src/handlers/game_management.rs | 29 +++-- visions/server/src/handlers/mod.rs | 32 +++++- .../server/src/handlers/user_management.rs | 82 +++++--------- visions/server/src/routes.rs | 100 +++++++++--------- 6 files changed, 133 insertions(+), 124 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 532f500..f4cba53 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -314,6 +314,7 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" dependencies = [ "async-trait", "axum-core", + "axum-macros", "bytes", "futures-util", "http 1.2.0", @@ -361,6 +362,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", +] + [[package]] name = "axum-test" version = "16.4.1" diff --git a/visions/server/Cargo.toml b/visions/server/Cargo.toml index 1e9f78d..6d1cf60 100644 --- a/visions/server/Cargo.toml +++ b/visions/server/Cargo.toml @@ -9,7 +9,7 @@ edition = "2021" async-std = { version = "1.13.0" } async-trait = { version = "0.1.83" } authdb = { path = "../../authdb/" } -axum = { version = "0.7.9" } +axum = { version = "0.7.9", features = [ "macros" ] } futures = { version = "0.3.31" } include_dir = { version = "0.7.4" } lazy_static = { version = "1.5.0" } diff --git a/visions/server/src/handlers/game_management.rs b/visions/server/src/handlers/game_management.rs index 818bb93..9e72bc7 100644 --- a/visions/server/src/handlers/game_management.rs +++ b/visions/server/src/handlers/game_management.rs @@ -1,13 +1,18 @@ -use axum::{http::{HeaderMap, StatusCode}, Json}; +use axum::{ + http::{HeaderMap, StatusCode}, + Json, +}; use result_extended::ResultExt; use serde::{Deserialize, Serialize}; -use crate::{database::GameId, core::Core}; +use crate::{ + core::Core, + database::GameId, + types::{AppError, FatalError}, +}; use super::auth_required; - - #[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] pub struct CreateGameRequest { pub game_type: String, @@ -18,16 +23,10 @@ pub async fn create_game( core: Core, headers: HeaderMap, req: CreateGameRequest, -) -> (StatusCode, Json>) { - println!("create game handler"); +) -> ResultExt { auth_required(core.clone(), headers, |user| async move { - let game = core.create_game(&user.id, &req.game_type, &req.game_name).await; - println!("create_game completed: {:?}", game); - match game { - ResultExt::Ok(game_id) => (StatusCode::OK, Json(Some(game_id))), - ResultExt::Err(_err) => (StatusCode::BAD_REQUEST, Json(None)), - ResultExt::Fatal(fatal) => panic!("{}", fatal), - } - }).await + core.create_game(&user.id, &req.game_type, &req.game_name) + .await + }) + .await } - diff --git a/visions/server/src/handlers/mod.rs b/visions/server/src/handlers/mod.rs index d2d7056..0e1ad18 100644 --- a/visions/server/src/handlers/mod.rs +++ b/visions/server/src/handlers/mod.rs @@ -1,18 +1,48 @@ mod game_management; mod user_management; +use axum::{http::StatusCode, Json}; +use futures::Future; pub use game_management::*; +use typeshare::typeshare; pub use user_management::*; use result_extended::ResultExt; use serde::{Deserialize, Serialize}; -use crate::core::Core; +use crate::{ + core::Core, + types::{AppError, FatalError}, +}; #[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] pub struct HealthCheck { pub ok: bool, } +pub async fn wrap_handler(f: F) -> (StatusCode, Json>) +where + F: FnOnce() -> Fut, + Fut: Future>, +{ + match f().await { + ResultExt::Ok(val) => (StatusCode::OK, Json(Some(val))), + ResultExt::Err(AppError::BadRequest) => (StatusCode::BAD_REQUEST, Json(None)), + ResultExt::Err(AppError::CouldNotCreateObject) => (StatusCode::BAD_REQUEST, Json(None)), + ResultExt::Err(AppError::NotFound(_)) => (StatusCode::NOT_FOUND, Json(None)), + ResultExt::Err(AppError::Inaccessible(_)) => (StatusCode::NOT_FOUND, Json(None)), + ResultExt::Err(AppError::PermissionDenied) => (StatusCode::FORBIDDEN, Json(None)), + ResultExt::Err(AppError::AuthFailed) => (StatusCode::UNAUTHORIZED, Json(None)), + ResultExt::Err(AppError::JsonError(_)) => (StatusCode::INTERNAL_SERVER_ERROR, Json(None)), + ResultExt::Err(AppError::UnexpectedError(_)) => { + (StatusCode::INTERNAL_SERVER_ERROR, Json(None)) + } + ResultExt::Err(AppError::UsernameUnavailable) => (StatusCode::BAD_REQUEST, Json(None)), + ResultExt::Fatal(err) => { + panic!("The server encountered a fatal error: {}", err); + } + } +} + pub async fn healthcheck(core: Core) -> Vec { match core.status().await { ResultExt::Ok(s) => serde_json::to_vec(&HealthCheck { diff --git a/visions/server/src/handlers/user_management.rs b/visions/server/src/handlers/user_management.rs index 3a30cfe..dcbad23 100644 --- a/visions/server/src/handlers/user_management.rs +++ b/visions/server/src/handlers/user_management.rs @@ -3,7 +3,7 @@ use axum::{ Json, }; use futures::Future; -use result_extended::{error, ok, ResultExt}; +use result_extended::{error, ok, return_error, ResultExt}; use serde::{Deserialize, Serialize}; use typeshare::typeshare; @@ -58,16 +58,14 @@ pub async fn auth_required( core: Core, headers: HeaderMap, f: F, -) -> (StatusCode, Json>) +) -> ResultExt where F: FnOnce(User) -> Fut, - Fut: Future>)>, + Fut: Future>, { - match check_session(&core, headers).await { - ResultExt::Ok(Some(user)) => f(user).await, - ResultExt::Ok(None) => (StatusCode::UNAUTHORIZED, Json(None)), - ResultExt::Err(_err) => (StatusCode::BAD_REQUEST, Json(None)), - ResultExt::Fatal(err) => panic!("{}", err), + match return_error!(check_session(&core, headers).await) { + Some(user) => f(user).await, + None => error(AppError::AuthFailed), } } @@ -75,94 +73,64 @@ pub async fn admin_required( core: Core, headers: HeaderMap, f: F, -) -> (StatusCode, Json>) +) -> ResultExt where F: FnOnce(User) -> Fut, - Fut: Future>)>, + Fut: Future>, { - match check_session(&core, headers).await { - ResultExt::Ok(Some(user)) => { + match return_error!(check_session(&core, headers).await) { + Some(user) => { if user.admin { f(user).await } else { - (StatusCode::FORBIDDEN, Json(None)) + error(AppError::PermissionDenied) } } - ResultExt::Ok(None) => (StatusCode::UNAUTHORIZED, Json(None)), - ResultExt::Err(_err) => (StatusCode::BAD_REQUEST, Json(None)), - ResultExt::Fatal(err) => panic!("{}", err), + None => error(AppError::AuthFailed), } } pub async fn check_password( core: Core, req: Json, -) -> (StatusCode, Json>) { +) -> ResultExt { let Json(AuthRequest { username, password }) = req; - match core.auth(&username, &password).await { - ResultExt::Ok(session_id) => (StatusCode::OK, Json(Some(session_id))), - ResultExt::Err(_err) => (StatusCode::UNAUTHORIZED, Json(None)), - ResultExt::Fatal(err) => panic!("Fatal: {}", err), - } + core.auth(&username, &password).await } pub async fn get_user( core: Core, headers: HeaderMap, user_id: Option, -) -> (StatusCode, Json>) { +) -> ResultExt, AppError, FatalError> { auth_required(core.clone(), headers, |user| async move { match user_id { - Some(user_id) => match core.user(user_id).await { - ResultExt::Ok(Some(user)) => (StatusCode::OK, Json(Some(user))), - ResultExt::Ok(None) => (StatusCode::NOT_FOUND, Json(None)), - ResultExt::Err(_err) => (StatusCode::BAD_REQUEST, Json(None)), - ResultExt::Fatal(err) => panic!("{}", err), - }, - None => ( - StatusCode::OK, - Json(Some(UserProfile { - id: UserId::from(user.id), - name: user.name, - is_admin: user.admin, - games: vec![], - })), - ), + Some(user_id) => core.user(user_id).await, + None => core.user(user.id).await, } - }) - .await + }).await } pub async fn create_user( core: Core, headers: HeaderMap, req: CreateUserRequest, -) -> (StatusCode, Json>) { +) -> ResultExt { admin_required(core.clone(), headers, |_admin| async { - match core.create_user(&req.username).await { - ResultExt::Ok(_) => (StatusCode::OK, Json(None)), - ResultExt::Err(_err) => (StatusCode::BAD_REQUEST, Json(None)), - ResultExt::Fatal(fatal) => panic!("{}", fatal), - } - }) - .await + core.create_user(&req.username).await + }).await } pub async fn set_password( core: Core, headers: HeaderMap, req: SetPasswordRequest, -) -> (StatusCode, Json>) { +) -> ResultExt<(), AppError, FatalError> { auth_required(core.clone(), headers, |user| async { if req.password_1 == req.password_2 { - match core.set_password(user.id, req.password_1).await { - ResultExt::Ok(_) => (StatusCode::OK, Json(None)), - ResultExt::Err(_err) => (StatusCode::BAD_REQUEST, Json(None)), - ResultExt::Fatal(fatal) => panic!("{}", fatal), - } + core.set_password(user.id, req.password_1).await } else { - (StatusCode::BAD_REQUEST, Json(None)) + error(AppError::BadRequest) } - }) - .await + }).await } diff --git a/visions/server/src/routes.rs b/visions/server/src/routes.rs index 76150ef..94369bd 100644 --- a/visions/server/src/routes.rs +++ b/visions/server/src/routes.rs @@ -1,16 +1,16 @@ use axum::{ extract::Path, - http::HeaderMap, + http::{HeaderMap, StatusCode}, routing::{get, post, put}, Json, Router, }; use crate::{ core::Core, - database::UserId, + database::{SessionId, UserId}, handlers::{ - check_password, create_game, create_user, get_user, healthcheck, set_password, AuthRequest, - CreateGameRequest, CreateUserRequest, SetPasswordRequest, + check_password, create_game, create_user, get_user, healthcheck, set_password, + wrap_handler, AuthRequest, CreateGameRequest, CreateUserRequest, SetPasswordRequest, }, }; @@ -27,54 +27,54 @@ pub fn routes(core: Core) -> Router { "/api/v1/auth", post({ let core = core.clone(); - move |req: Json| check_password(core, req) - }), - ) - .route( - // By default, just get the self user. - "/api/v1/user", - get({ - let core = core.clone(); - move |headers: HeaderMap| get_user(core, headers, None) - }) - .put({ - let core = core.clone(); - move |headers: HeaderMap, req: Json| { - let Json(req) = req; - create_user(core, headers, req) - } - }), - ) - .route( - "/api/v1/user/password", - put({ - let core = core.clone(); - move |headers: HeaderMap, req: Json| { - let Json(req) = req; - set_password(core, headers, req) - } - }), - ) - .route( - "/api/v1/user/:user_id", - get({ - let core = core.clone(); - move |user_id: Path, headers: HeaderMap| { - let Path(user_id) = user_id; - get_user(core, headers, Some(user_id)) - } - }), - ) - .route( - "/api/v1/games", - put({ - let core = core.clone(); - move |headers: HeaderMap, req: Json| { - let Json(req) = req; - create_game(core, headers, req) - } + move |req: Json| wrap_handler(|| check_password(core, req)) }), ) + .route( + // By default, just get the self user. + "/api/v1/user", + get({ + let core = core.clone(); + move |headers: HeaderMap| wrap_handler(|| get_user(core, headers, None)) + }) + .put({ + let core = core.clone(); + move |headers: HeaderMap, req: Json| { + let Json(req) = req; + wrap_handler(|| create_user(core, headers, req)) + } + }), + ) + .route( + "/api/v1/user/password", + put({ + let core = core.clone(); + move |headers: HeaderMap, req: Json| { + let Json(req) = req; + wrap_handler(|| set_password(core, headers, req)) + } + }), + ) + .route( + "/api/v1/user/:user_id", + get({ + let core = core.clone(); + move |user_id: Path, headers: HeaderMap| { + let Path(user_id) = user_id; + wrap_handler(|| get_user(core, headers, Some(user_id))) + } + }), + ) + .route( + "/api/v1/games", + put({ + let core = core.clone(); + move |headers: HeaderMap, req: Json| { + let Json(req) = req; + wrap_handler(|| create_game(core, headers, req)) + } + }), + ) } #[cfg(test)] -- 2.47.1 From 08462388ea16234012cd2666bda7fc2c08af3fdc Mon Sep 17 00:00:00 2001 From: Savanni D'Gerinel Date: Fri, 3 Jan 2025 11:59:33 -0500 Subject: [PATCH 21/23] Move the assertNever utility to a utilities file --- Cargo.lock | 15 +++ visions/server/Cargo.toml | 1 + visions/server/src/database/types.rs | 5 + visions/server/src/handlers/mod.rs | 6 +- visions/server/src/routes.rs | 102 ++++++++++-------- visions/ui/src/App.tsx | 6 +- visions/ui/src/client.ts | 12 ++- .../ui/src/components/Tabletop/Tabletop.tsx | 4 +- visions/ui/src/plugins/Candela/Charsheet.tsx | 2 +- .../ui/src/plugins/Candela/CharsheetPanel.tsx | 2 +- visions/ui/src/plugins/Candela/index.tsx | 4 - .../providers/StateProvider/StateProvider.tsx | 45 ++++++-- visions/ui/src/utils.ts | 4 + .../views/Authentication/Authentication.tsx | 67 +++++++----- visions/ui/src/views/GmView/GmView.tsx | 24 +++-- .../ui/src/views/PlayerView/PlayerView.tsx | 26 +++-- 16 files changed, 208 insertions(+), 117 deletions(-) create mode 100644 visions/ui/src/utils.ts diff --git a/Cargo.lock b/Cargo.lock index f4cba53..375dd58 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4394,6 +4394,20 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "403fa3b783d4b626a8ad51d766ab03cb6d2dbfc46b1c5d4448395e6628dc9697" +dependencies = [ + "bitflags 2.6.0", + "bytes", + "http 1.2.0", + "pin-project-lite", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -4688,6 +4702,7 @@ dependencies = [ "thiserror 2.0.3", "tokio", "tokio-stream", + "tower-http", "typeshare", "urlencoding", "uuid 1.11.0", diff --git a/visions/server/Cargo.toml b/visions/server/Cargo.toml index 6d1cf60..619ae02 100644 --- a/visions/server/Cargo.toml +++ b/visions/server/Cargo.toml @@ -24,6 +24,7 @@ serde_json = { version = "*" } thiserror = { version = "2.0.3" } tokio = { version = "1", features = [ "full" ] } tokio-stream = { version = "0.1.16" } +tower-http = { version = "0.6.2", features = ["cors"] } typeshare = { version = "1.0.4" } urlencoding = { version = "2.1.3" } uuid = { version = "1.11.0", features = ["v4"] } diff --git a/visions/server/src/database/types.rs b/visions/server/src/database/types.rs index cd1be73..6a24996 100644 --- a/visions/server/src/database/types.rs +++ b/visions/server/src/database/types.rs @@ -2,9 +2,11 @@ use std::fmt; use rusqlite::types::{FromSql, FromSqlResult, ValueRef}; use serde::{Deserialize, Serialize}; +use typeshare::typeshare; use uuid::Uuid; #[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[typeshare] pub struct UserId(String); impl UserId { @@ -47,6 +49,7 @@ impl fmt::Display for UserId { } #[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[typeshare] pub struct SessionId(String); impl SessionId { @@ -87,6 +90,7 @@ impl fmt::Display for SessionId { } #[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[typeshare] pub struct GameId(String); impl GameId { @@ -121,6 +125,7 @@ impl FromSql for GameId { } #[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[typeshare] pub struct CharacterId(String); impl CharacterId { diff --git a/visions/server/src/handlers/mod.rs b/visions/server/src/handlers/mod.rs index 0e1ad18..19950cd 100644 --- a/visions/server/src/handlers/mod.rs +++ b/visions/server/src/handlers/mod.rs @@ -16,7 +16,7 @@ use crate::{ #[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] pub struct HealthCheck { - pub ok: bool, + pub admin_enabled: bool, } pub async fn wrap_handler(f: F) -> (StatusCode, Json>) @@ -46,10 +46,10 @@ where pub async fn healthcheck(core: Core) -> Vec { match core.status().await { ResultExt::Ok(s) => serde_json::to_vec(&HealthCheck { - ok: s.admin_enabled, + admin_enabled: s.admin_enabled, }) .unwrap(), - ResultExt::Err(_) => serde_json::to_vec(&HealthCheck { ok: false }).unwrap(), + ResultExt::Err(_) => serde_json::to_vec(&HealthCheck { admin_enabled: false }).unwrap(), ResultExt::Fatal(err) => panic!("{}", err), } } diff --git a/visions/server/src/routes.rs b/visions/server/src/routes.rs index 94369bd..bc51f23 100644 --- a/visions/server/src/routes.rs +++ b/visions/server/src/routes.rs @@ -1,9 +1,10 @@ use axum::{ extract::Path, - http::{HeaderMap, StatusCode}, + http::{header::CONTENT_TYPE, HeaderMap, Method, StatusCode}, routing::{get, post, put}, Json, Router, }; +use tower_http::cors::{Any, CorsLayer}; use crate::{ core::Core, @@ -21,60 +22,67 @@ pub fn routes(core: Core) -> Router { get({ let core = core.clone(); move || healthcheck(core) - }), + }) + .layer( + CorsLayer::new() + .allow_methods([Method::GET]) + .allow_origin(Any), + ), ) .route( "/api/v1/auth", post({ let core = core.clone(); move |req: Json| wrap_handler(|| check_password(core, req)) + }).layer( + CorsLayer::new().allow_methods([Method::POST]).allow_headers([CONTENT_TYPE]).allow_origin(Any), + ), + ) + .route( + // By default, just get the self user. + "/api/v1/user", + get({ + let core = core.clone(); + move |headers: HeaderMap| wrap_handler(|| get_user(core, headers, None)) + }) + .put({ + let core = core.clone(); + move |headers: HeaderMap, req: Json| { + let Json(req) = req; + wrap_handler(|| create_user(core, headers, req)) + } + }), + ) + .route( + "/api/v1/user/password", + put({ + let core = core.clone(); + move |headers: HeaderMap, req: Json| { + let Json(req) = req; + wrap_handler(|| set_password(core, headers, req)) + } + }), + ) + .route( + "/api/v1/user/:user_id", + get({ + let core = core.clone(); + move |user_id: Path, headers: HeaderMap| { + let Path(user_id) = user_id; + wrap_handler(|| get_user(core, headers, Some(user_id))) + } + }), + ) + .route( + "/api/v1/games", + put({ + let core = core.clone(); + move |headers: HeaderMap, req: Json| { + let Json(req) = req; + wrap_handler(|| create_game(core, headers, req)) + } }), ) - .route( - // By default, just get the self user. - "/api/v1/user", - get({ - let core = core.clone(); - move |headers: HeaderMap| wrap_handler(|| get_user(core, headers, None)) - }) - .put({ - let core = core.clone(); - move |headers: HeaderMap, req: Json| { - let Json(req) = req; - wrap_handler(|| create_user(core, headers, req)) - } - }), - ) - .route( - "/api/v1/user/password", - put({ - let core = core.clone(); - move |headers: HeaderMap, req: Json| { - let Json(req) = req; - wrap_handler(|| set_password(core, headers, req)) - } - }), - ) - .route( - "/api/v1/user/:user_id", - get({ - let core = core.clone(); - move |user_id: Path, headers: HeaderMap| { - let Path(user_id) = user_id; - wrap_handler(|| get_user(core, headers, Some(user_id))) - } - }), - ) - .route( - "/api/v1/games", - put({ - let core = core.clone(); - move |headers: HeaderMap, req: Json| { - let Json(req) = req; - wrap_handler(|| create_game(core, headers, req)) - } - }), - ) } #[cfg(test)] diff --git a/visions/ui/src/App.tsx b/visions/ui/src/App.tsx index c8afe75..9b98110 100644 --- a/visions/ui/src/App.tsx +++ b/visions/ui/src/App.tsx @@ -46,9 +46,9 @@ const App = ({ client }: AppProps) => { console.log("rendering app"); const [websocketUrl, setWebsocketUrl] = useState(undefined); - useEffect(() => { - client.registerWebsocket().then((url) => setWebsocketUrl(url)) - }, [client]); + // useEffect(() => { + // client.registerWebsocket().then((url) => setWebsocketUrl(url)) + // }, [client]); let router = createBrowserRouter([ diff --git a/visions/ui/src/client.ts b/visions/ui/src/client.ts index 8fa92f9..37c3e9a 100644 --- a/visions/ui/src/client.ts +++ b/visions/ui/src/client.ts @@ -1,3 +1,5 @@ +import { SessionId } from "visions-types"; + export type PlayingField = { backgroundImage: string; } @@ -64,15 +66,15 @@ export class Client { return fetch(url, { method: 'PUT', headers: [['Content-Type', 'application/json']], body: JSON.stringify(password) }); } - async auth(username: string, password: string) { + async auth(username: string, password: string): Promise { const url = new URL(this.base); - url.pathname = `api/v1/auth` - return fetch(url, { method: 'PUT', headers: [['Content-Type', 'application/json']], body: JSON.stringify({ 'username': username, 'password': password }) }); + url.pathname = `/api/v1/auth` + return fetch(url, { method: 'POST', headers: [['Content-Type', 'application/json']], body: JSON.stringify({ 'username': username, 'password': password }) }); } - async status() { + async health() { const url = new URL(this.base); - url.pathname = `/api/v1/status`; + url.pathname = `/api/v1/health`; return fetch(url).then((response) => response.json()); } diff --git a/visions/ui/src/components/Tabletop/Tabletop.tsx b/visions/ui/src/components/Tabletop/Tabletop.tsx index efa21bf..55f9c3c 100644 --- a/visions/ui/src/components/Tabletop/Tabletop.tsx +++ b/visions/ui/src/components/Tabletop/Tabletop.tsx @@ -1,9 +1,9 @@ import React, { useContext } from 'react'; import './Tabletop.css'; -import { RGB } from 'visions-types'; +import { Rgb } from 'visions-types'; interface TabletopElementProps { - backgroundColor: RGB; + backgroundColor: Rgb; backgroundUrl: URL | undefined; } diff --git a/visions/ui/src/plugins/Candela/Charsheet.tsx b/visions/ui/src/plugins/Candela/Charsheet.tsx index 362a7df..5b194aa 100644 --- a/visions/ui/src/plugins/Candela/Charsheet.tsx +++ b/visions/ui/src/plugins/Candela/Charsheet.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { assertNever } from '.'; +import { assertNever } from '../../utils'; import './Charsheet.css'; import { DriveGuage } from './DriveGuage/DriveGuage'; import { Charsheet, Nerve, Cunning, Intuition } from './types'; diff --git a/visions/ui/src/plugins/Candela/CharsheetPanel.tsx b/visions/ui/src/plugins/Candela/CharsheetPanel.tsx index 109479b..f2c12fb 100644 --- a/visions/ui/src/plugins/Candela/CharsheetPanel.tsx +++ b/visions/ui/src/plugins/Candela/CharsheetPanel.tsx @@ -1,9 +1,9 @@ import React from 'react'; -import { assertNever } from '.'; import { SimpleGuage } from '../../components/Guages/SimpleGuage'; import { Charsheet, Nerve, Cunning, Intuition } from './types'; import './CharsheetPanel.css'; import classNames from 'classnames'; +import { assertNever } from '../../utils'; interface CharsheetPanelProps { sheet: Charsheet; diff --git a/visions/ui/src/plugins/Candela/index.tsx b/visions/ui/src/plugins/Candela/index.tsx index 818120b..fefbe14 100644 --- a/visions/ui/src/plugins/Candela/index.tsx +++ b/visions/ui/src/plugins/Candela/index.tsx @@ -1,9 +1,5 @@ import { CharsheetElement } from './Charsheet'; import { CharsheetPanelElement } from './CharsheetPanel'; -export function assertNever(value: never) { - throw new Error("Unexpected value: " + value); -} - export default { CharsheetElement, CharsheetPanelElement }; diff --git a/visions/ui/src/providers/StateProvider/StateProvider.tsx b/visions/ui/src/providers/StateProvider/StateProvider.tsx index e22ebe3..64a9dc8 100644 --- a/visions/ui/src/providers/StateProvider/StateProvider.tsx +++ b/visions/ui/src/providers/StateProvider/StateProvider.tsx @@ -1,26 +1,53 @@ import React, { createContext, PropsWithChildren, useCallback, useEffect, useReducer, useRef } from "react"; import { Status, Tabletop } from "visions-types"; import { Client } from "../../client"; -import { assertNever } from "../../plugins/Candela"; +import { assertNever } from "../../utils"; type AuthState = { type: "NoAdmin" } | { type: "Unauthed" } | { type: "Authed", userid: string }; -type AppState = { - auth: AuthState; - tabletop: Tabletop; +type LoadingState = { type: "Loading" } + +type ReadyState = { + type: "Ready", + auth: AuthState, + tabletop: Tabletop, } +type AppState = LoadingState | ReadyState + type Action = { type: "SetAuthState", content: AuthState }; +/* const initialState = (): AppState => ( { auth: { type: "NoAdmin" }, tabletop: { backgroundColor: { red: 0, green: 0, blue: 0 }, backgroundImage: undefined } } ); +*/ +const initialState = (): AppState => ({ type: "Loading" }) + +const loadingReducer = (state: LoadingState, action: Action): AppState => { + return { type: "Ready", auth: action.content, tabletop: { backgroundColor: { red: 0, green: 0, blue: 0 }, backgroundImage: undefined } } +} + +const readyReducer = (state: ReadyState, action: Action): AppState => { + return { ...state, auth: action.content } +} const stateReducer = (state: AppState, action: Action): AppState => { - return { ...state, auth: action.content } + switch (state.type) { + case "Loading": { + return loadingReducer(state, action); + } + case "Ready": { + return readyReducer(state, action); + } + default: { + assertNever(state); + return { type: "Loading" }; + } + } } class StateManager { @@ -35,7 +62,7 @@ class StateManager { async status() { if (!this.client || !this.dispatch) return; - const { admin_enabled } = await this.client.status(); + const { admin_enabled } = await this.client.health(); if (!admin_enabled) { this.dispatch({ type: "SetAuthState", content: { type: "NoAdmin" } }); } else { @@ -54,9 +81,9 @@ class StateManager { if (!this.client || !this.dispatch) return; let resp = await this.client.auth(username, password); - let userid = await resp.json(); - console.log("userid retrieved", userid); - this.dispatch({ type: "SetAuthState", content: { type: "Authed", userid } }); + let sessionid = await resp.json(); + console.log("sessionid retrieved", sessionid); + this.dispatch({ type: "SetAuthState", content: { type: "Authed", sessionid } }); } } diff --git a/visions/ui/src/utils.ts b/visions/ui/src/utils.ts new file mode 100644 index 0000000..559ae58 --- /dev/null +++ b/visions/ui/src/utils.ts @@ -0,0 +1,4 @@ +export function assertNever(value: never) { + throw new Error("Unexpected value: " + value); +} + diff --git a/visions/ui/src/views/Authentication/Authentication.tsx b/visions/ui/src/views/Authentication/Authentication.tsx index 7b0e322..95f4be2 100644 --- a/visions/ui/src/views/Authentication/Authentication.tsx +++ b/visions/ui/src/views/Authentication/Authentication.tsx @@ -1,6 +1,6 @@ import { PropsWithChildren, useContext, useState } from 'react'; import { StateContext } from '../../providers/StateProvider/StateProvider'; -import { assertNever } from '../../plugins/Candela'; +import { assertNever } from '../../utils'; import './Authentication.css'; interface AuthenticationProps { @@ -17,35 +17,48 @@ export const Authentication = ({ onAdminPassword, onAuth, children }: PropsWithC let [pwField, setPwField] = useState(""); let [state, _] = useContext(StateContext); - switch (state.auth.type) { - case "NoAdmin": { - return

-
-

Welcome to your new Visions VTT Instance

-

Set your admin password:

- setPwField(evt.target.value)} /> - onAdminPassword(pwField)} /> -
-
; + switch (state.type) { + case "Loading": { + return
Loading
} - case "Unauthed": { - return
-
-

Welcome to Visions VTT

-
- setUserField(evt.target.value)} /> - setPwField(evt.target.value)} /> - onAuth(userField, pwField)} /> -
-
-
; - } - case "Authed": { - return
{children}
; + case "Ready": { + switch (state.auth.type) { + case "NoAdmin": { + return
+
+

Welcome to your new Visions VTT Instance

+

Set your admin password:

+ setPwField(evt.target.value)} /> + onAdminPassword(pwField)} /> +
+
; + } + case "Unauthed": { + return
+
+

Welcome to Visions VTT

+
+ setUserField(evt.target.value)} /> + setPwField(evt.target.value)} /> + onAuth(userField, pwField)} /> +
+
+
; + } + case "Authed": { + return
{children}
; + } + default: { + assertNever(state.auth); + return
; + } + } + } default: { - assertNever(state.auth); - return
; + assertNever(state); + return
} } + } diff --git a/visions/ui/src/views/GmView/GmView.tsx b/visions/ui/src/views/GmView/GmView.tsx index 948906e..8874f85 100644 --- a/visions/ui/src/views/GmView/GmView.tsx +++ b/visions/ui/src/views/GmView/GmView.tsx @@ -5,6 +5,7 @@ import { TabletopElement } from '../../components/Tabletop/Tabletop'; import { ThumbnailElement } from '../../components/Thumbnail/Thumbnail'; import { WebsocketContext } from '../../components/WebsocketProvider'; import './GmView.css'; +import { assertNever } from '../../utils'; interface GmViewProps { client: Client @@ -18,12 +19,21 @@ export const GmView = ({ client }: GmViewProps) => { client.availableImages().then((images) => setImages(images)); }, [client]); - const backgroundUrl = state.tabletop.backgroundImage ? client.imageUrl(state.tabletop.backgroundImage) : undefined; - return (
-
- {images.map((imageName) => { client.setBackgroundImage(imageName); }} />)} -
- -
) + switch (state.type) { + case "Loading": return
; + case "Ready": { + const backgroundUrl = state.tabletop.backgroundImage ? client.imageUrl(state.tabletop.backgroundImage) : undefined; + return (
+
+ {images.map((imageName) => { client.setBackgroundImage(imageName); }} />)} +
+ +
) + } + default: { + assertNever(state); + return
; + } + } } diff --git a/visions/ui/src/views/PlayerView/PlayerView.tsx b/visions/ui/src/views/PlayerView/PlayerView.tsx index 3d3304c..017c2c0 100644 --- a/visions/ui/src/views/PlayerView/PlayerView.tsx +++ b/visions/ui/src/views/PlayerView/PlayerView.tsx @@ -5,6 +5,7 @@ import { Client } from '../../client'; import { TabletopElement } from '../../components/Tabletop/Tabletop'; import Candela from '../../plugins/Candela'; import { StateContext } from '../../providers/StateProvider/StateProvider'; +import { assertNever } from '../../utils'; const TEST_CHARSHEET_UUID = "12df9c09-1f2f-4147-8eda-a97bd2a7a803"; @@ -26,14 +27,23 @@ export const PlayerView = ({ client }: PlayerViewProps) => { [client, setCharsheet] ); - const backgroundColor = state.tabletop.backgroundColor; - const tabletopColorStyle = `rgb(${backgroundColor.red}, ${backgroundColor.green}, ${backgroundColor.blue})`; - const backgroundUrl = state.tabletop.backgroundImage ? client.imageUrl(state.tabletop.backgroundImage) : undefined; + switch (state.type) { + case "Loading": return
; + case "Ready": { + const backgroundColor = state.tabletop.backgroundColor; + const tabletopColorStyle = `rgb(${backgroundColor.red}, ${backgroundColor.green}, ${backgroundColor.blue})`; + const backgroundUrl = state.tabletop.backgroundImage ? client.imageUrl(state.tabletop.backgroundImage) : undefined; - return (
-
-
- {charsheet ? :
}
-
) + return (
+
+
+ {charsheet ? :
}
+
) + } + default: { + assertNever(state); + return
; + } + } } -- 2.47.1 From 208083d39e01651a68a2f8981d2f4fa03e59a88f Mon Sep 17 00:00:00 2001 From: Savanni D'Gerinel Date: Fri, 3 Jan 2025 16:19:43 -0500 Subject: [PATCH 22/23] Authenticate the user and populate AppState with the stored session ID --- visions/ui/src/App.tsx | 64 ++++++++-------- visions/ui/src/client.ts | 5 +- visions/ui/src/components/Profile/Profile.css | 0 visions/ui/src/components/Profile/Profile.tsx | 10 +++ .../providers/StateProvider/StateProvider.tsx | 76 ++++++++++--------- .../views/Authentication/Authentication.tsx | 13 +--- visions/ui/src/views/GmView/GmView.css | 4 - visions/ui/src/views/GmView/GmView.tsx | 39 ---------- visions/ui/src/views/Main/Main.css | 0 visions/ui/src/views/Main/Main.tsx | 16 ++++ .../ui/src/views/PlayerView/PlayerView.css | 15 ---- .../ui/src/views/PlayerView/PlayerView.tsx | 49 ------------ visions/ui/src/views/index.ts | 3 + 13 files changed, 111 insertions(+), 183 deletions(-) create mode 100644 visions/ui/src/components/Profile/Profile.css create mode 100644 visions/ui/src/components/Profile/Profile.tsx delete mode 100644 visions/ui/src/views/GmView/GmView.css delete mode 100644 visions/ui/src/views/GmView/GmView.tsx create mode 100644 visions/ui/src/views/Main/Main.css create mode 100644 visions/ui/src/views/Main/Main.tsx delete mode 100644 visions/ui/src/views/PlayerView/PlayerView.css delete mode 100644 visions/ui/src/views/PlayerView/PlayerView.tsx create mode 100644 visions/ui/src/views/index.ts diff --git a/visions/ui/src/App.tsx b/visions/ui/src/App.tsx index 9b98110..b6dcf0e 100644 --- a/visions/ui/src/App.tsx +++ b/visions/ui/src/App.tsx @@ -1,64 +1,64 @@ -import React, { PropsWithChildren, useContext, useEffect, useState } from 'react'; -import './App.css'; -import { Client } from './client'; -import { createBrowserRouter, RouterProvider } from 'react-router-dom'; -import { DesignPage } from './views/Design/Design'; -import { GmView } from './views/GmView/GmView'; -import { WebsocketProvider } from './components/WebsocketProvider'; -import { PlayerView } from './views/PlayerView/PlayerView'; -import { Admin } from './views/Admin/Admin'; -import Candela from './plugins/Candela'; -import { Authentication } from './views/Authentication/Authentication'; -import { StateContext, StateProvider } from './providers/StateProvider/StateProvider'; +import React, { PropsWithChildren, useContext, useEffect, useState } from 'react' +import './App.css' +import { Client } from './client' +import { createBrowserRouter, RouterProvider } from 'react-router-dom' +import { DesignPage } from './views/Design/Design' +import { Admin } from './views/Admin/Admin' +import Candela from './plugins/Candela' +import { Authentication } from './views/Authentication/Authentication' +import { StateContext, StateProvider } from './providers/StateProvider/StateProvider' +import { MainView } from './views' -const TEST_CHARSHEET_UUID = "12df9c09-1f2f-4147-8eda-a97bd2a7a803"; +const TEST_CHARSHEET_UUID = "12df9c09-1f2f-4147-8eda-a97bd2a7a803" interface AppProps { - client: Client; + client: Client } const CandelaCharsheet = ({ client }: { client: Client }) => { - let [sheet, setSheet] = useState(undefined); + let [sheet, setSheet] = useState(undefined) useEffect( - () => { client.charsheet(TEST_CHARSHEET_UUID).then((c) => setSheet(c)); }, + () => { client.charsheet(TEST_CHARSHEET_UUID).then((c) => setSheet(c)) }, [client, setSheet] - ); + ) return sheet ? :
} interface AuthedViewProps { - client: Client; + client: Client } const AuthedView = ({ client, children }: PropsWithChildren) => { - const [state, manager] = useContext(StateContext); + const [state, manager] = useContext(StateContext) return ( { - manager.setAdminPassword(password); + manager.setAdminPassword(password) }} onAuth={(username, password) => manager.auth(username, password)}> {children} - ); + ) } const App = ({ client }: AppProps) => { - console.log("rendering app"); - const [websocketUrl, setWebsocketUrl] = useState(undefined); + console.log("rendering app") + const [websocketUrl, setWebsocketUrl] = useState(undefined) // useEffect(() => { // client.registerWebsocket().then((url) => setWebsocketUrl(url)) - // }, [client]); + // }, [client]) let router = createBrowserRouter([ { path: "/", - element: - }, - { - path: "/gm", - element: websocketUrl ? :
+ element: ( + + + + + + ) }, { path: "/admin", @@ -72,12 +72,12 @@ const App = ({ client }: AppProps) => { path: "/design", element: } - ]); + ]) return (
- ); + ) } -export default App; +export default App diff --git a/visions/ui/src/client.ts b/visions/ui/src/client.ts index 37c3e9a..5521429 100644 --- a/visions/ui/src/client.ts +++ b/visions/ui/src/client.ts @@ -6,6 +6,7 @@ export type PlayingField = { export class Client { private base: URL; + private sessionId: string | undefined; constructor() { this.base = new URL("http://localhost:8001"); @@ -69,7 +70,9 @@ export class Client { async auth(username: string, password: string): Promise { const url = new URL(this.base); url.pathname = `/api/v1/auth` - return fetch(url, { method: 'POST', headers: [['Content-Type', 'application/json']], body: JSON.stringify({ 'username': username, 'password': password }) }); + const response = await fetch(url, { method: 'POST', headers: [['Content-Type', 'application/json']], body: JSON.stringify({ 'username': username, 'password': password }) }); + const session_id: SessionId = await response.json(); + return session_id; } async health() { diff --git a/visions/ui/src/components/Profile/Profile.css b/visions/ui/src/components/Profile/Profile.css new file mode 100644 index 0000000..e69de29 diff --git a/visions/ui/src/components/Profile/Profile.tsx b/visions/ui/src/components/Profile/Profile.tsx new file mode 100644 index 0000000..9c38a15 --- /dev/null +++ b/visions/ui/src/components/Profile/Profile.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import { Client } from '../../client'; + +interface ProfileProps { + client: Client +} + +export const ProfileElement = ({ client }: ProfileProps) => { + return
+} diff --git a/visions/ui/src/providers/StateProvider/StateProvider.tsx b/visions/ui/src/providers/StateProvider/StateProvider.tsx index 64a9dc8..6f928fa 100644 --- a/visions/ui/src/providers/StateProvider/StateProvider.tsx +++ b/visions/ui/src/providers/StateProvider/StateProvider.tsx @@ -1,51 +1,60 @@ import React, { createContext, PropsWithChildren, useCallback, useEffect, useReducer, useRef } from "react"; -import { Status, Tabletop } from "visions-types"; +import { SessionId, Status, Tabletop } from "visions-types"; import { Client } from "../../client"; import { assertNever } from "../../utils"; -type AuthState = { type: "NoAdmin" } | { type: "Unauthed" } | { type: "Authed", userid: string }; +type AuthState = { type: "NoAdmin" } | { type: "Unauthed" } | { type: "Authed", sessionId: string }; -type LoadingState = { type: "Loading" } +export enum LoadingState { + Loading, + Ready, +} -type ReadyState = { - type: "Ready", +type AppState = { + state: LoadingState, auth: AuthState, tabletop: Tabletop, } -type AppState = LoadingState | ReadyState - type Action = { type: "SetAuthState", content: AuthState }; -/* -const initialState = (): AppState => ( - { +const initialState = (): AppState => { + let state: AppState = { + state: LoadingState.Ready, auth: { type: "NoAdmin" }, - tabletop: { backgroundColor: { red: 0, green: 0, blue: 0 }, backgroundImage: undefined } + tabletop: { backgroundColor: { red: 0, green: 0, blue: 0 }, backgroundImage: undefined }, } -); -*/ -const initialState = (): AppState => ({ type: "Loading" }) -const loadingReducer = (state: LoadingState, action: Action): AppState => { - return { type: "Ready", auth: action.content, tabletop: { backgroundColor: { red: 0, green: 0, blue: 0 }, backgroundImage: undefined } } -} - -const readyReducer = (state: ReadyState, action: Action): AppState => { - return { ...state, auth: action.content } + const sessionId = window.localStorage.getItem("sessionId") + if (sessionId) { + return { ...state, auth: { type: "Authed", sessionId } } + } else { + return state + } } const stateReducer = (state: AppState, action: Action): AppState => { - switch (state.type) { - case "Loading": { - return loadingReducer(state, action); - } - case "Ready": { - return readyReducer(state, action); + switch (action.type) { + case "SetAuthState": { + return { ...state, auth: action.content } } + /* default: { - assertNever(state); - return { type: "Loading" }; + assertNever(action) + return state + } + */ + } +} + +export const getSessionId = (state: AppState): SessionId | undefined => { + switch (state.auth.type) { + case "NoAdmin": return undefined + case "Unauthed": return undefined + case "Authed": return state.auth.sessionId + default: { + assertNever(state.auth) + return undefined } } } @@ -65,8 +74,6 @@ class StateManager { const { admin_enabled } = await this.client.health(); if (!admin_enabled) { this.dispatch({ type: "SetAuthState", content: { type: "NoAdmin" } }); - } else { - this.dispatch({ type: "SetAuthState", content: { type: "Unauthed" } }); } } @@ -80,10 +87,11 @@ class StateManager { async auth(username: string, password: string) { if (!this.client || !this.dispatch) return; - let resp = await this.client.auth(username, password); - let sessionid = await resp.json(); - console.log("sessionid retrieved", sessionid); - this.dispatch({ type: "SetAuthState", content: { type: "Authed", sessionid } }); + let sessionId = await this.client.auth(username, password); + if (sessionId) { + window.localStorage.setItem("sessionId", sessionId); + this.dispatch({ type: "SetAuthState", content: { type: "Authed", sessionId } }); + } } } diff --git a/visions/ui/src/views/Authentication/Authentication.tsx b/visions/ui/src/views/Authentication/Authentication.tsx index 95f4be2..baf7d27 100644 --- a/visions/ui/src/views/Authentication/Authentication.tsx +++ b/visions/ui/src/views/Authentication/Authentication.tsx @@ -1,5 +1,5 @@ import { PropsWithChildren, useContext, useState } from 'react'; -import { StateContext } from '../../providers/StateProvider/StateProvider'; +import { LoadingState, StateContext } from '../../providers/StateProvider/StateProvider'; import { assertNever } from '../../utils'; import './Authentication.css'; @@ -17,11 +17,11 @@ export const Authentication = ({ onAdminPassword, onAuth, children }: PropsWithC let [pwField, setPwField] = useState(""); let [state, _] = useContext(StateContext); - switch (state.type) { - case "Loading": { + switch (state.state) { + case LoadingState.Loading: { return
Loading
} - case "Ready": { + case LoadingState.Ready: { switch (state.auth.type) { case "NoAdmin": { return
@@ -53,11 +53,6 @@ export const Authentication = ({ onAdminPassword, onAuth, children }: PropsWithC return
; } } - - } - default: { - assertNever(state); - return
} } diff --git a/visions/ui/src/views/GmView/GmView.css b/visions/ui/src/views/GmView/GmView.css deleted file mode 100644 index f3b6a10..0000000 --- a/visions/ui/src/views/GmView/GmView.css +++ /dev/null @@ -1,4 +0,0 @@ -.gm-view { - display: flex; - width: 100%; -} diff --git a/visions/ui/src/views/GmView/GmView.tsx b/visions/ui/src/views/GmView/GmView.tsx deleted file mode 100644 index 8874f85..0000000 --- a/visions/ui/src/views/GmView/GmView.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { useContext, useEffect, useState } from 'react'; -import { Client } from '../../client'; -import { StateContext } from '../../providers/StateProvider/StateProvider'; -import { TabletopElement } from '../../components/Tabletop/Tabletop'; -import { ThumbnailElement } from '../../components/Thumbnail/Thumbnail'; -import { WebsocketContext } from '../../components/WebsocketProvider'; -import './GmView.css'; -import { assertNever } from '../../utils'; - -interface GmViewProps { - client: Client -} - -export const GmView = ({ client }: GmViewProps) => { - const [state, dispatch] = useContext(StateContext); - - const [images, setImages] = useState([]); - useEffect(() => { - client.availableImages().then((images) => setImages(images)); - }, [client]); - - switch (state.type) { - case "Loading": return
; - case "Ready": { - const backgroundUrl = state.tabletop.backgroundImage ? client.imageUrl(state.tabletop.backgroundImage) : undefined; - return (
-
- {images.map((imageName) => { client.setBackgroundImage(imageName); }} />)} -
- -
) - } - default: { - assertNever(state); - return
; - } - } -} - diff --git a/visions/ui/src/views/Main/Main.css b/visions/ui/src/views/Main/Main.css new file mode 100644 index 0000000..e69de29 diff --git a/visions/ui/src/views/Main/Main.tsx b/visions/ui/src/views/Main/Main.tsx new file mode 100644 index 0000000..27ded81 --- /dev/null +++ b/visions/ui/src/views/Main/Main.tsx @@ -0,0 +1,16 @@ +import React, { useContext } from 'react'; +import { Client } from '../../client'; +import { getSessionId, StateContext, StateProvider } from '../../providers/StateProvider/StateProvider'; + +interface MainProps { + client: Client +} + +export const MainView = ({ client }: MainProps) => { + const [state, manager] = useContext(StateContext) + + const sessionId = getSessionId(state); + + return
Profile: {sessionId}
+} + diff --git a/visions/ui/src/views/PlayerView/PlayerView.css b/visions/ui/src/views/PlayerView/PlayerView.css deleted file mode 100644 index daf3bec..0000000 --- a/visions/ui/src/views/PlayerView/PlayerView.css +++ /dev/null @@ -1,15 +0,0 @@ -.player-view { - display: flex; - width: 100%; -} - -.player-view__left-panel { - min-width: 100px; - max-width: 20%; -} - -.player-view__right-panel { - width: 25%; -} - - diff --git a/visions/ui/src/views/PlayerView/PlayerView.tsx b/visions/ui/src/views/PlayerView/PlayerView.tsx deleted file mode 100644 index 017c2c0..0000000 --- a/visions/ui/src/views/PlayerView/PlayerView.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import React, { useContext, useEffect, useState } from 'react'; -import './PlayerView.css'; -import { WebsocketContext } from '../../components/WebsocketProvider'; -import { Client } from '../../client'; -import { TabletopElement } from '../../components/Tabletop/Tabletop'; -import Candela from '../../plugins/Candela'; -import { StateContext } from '../../providers/StateProvider/StateProvider'; -import { assertNever } from '../../utils'; - -const TEST_CHARSHEET_UUID = "12df9c09-1f2f-4147-8eda-a97bd2a7a803"; - -interface PlayerViewProps { - client: Client; -} - -export const PlayerView = ({ client }: PlayerViewProps) => { - const [state, dispatch] = useContext(StateContext); - - const [charsheet, setCharsheet] = useState(undefined); - - useEffect( - () => { - client.charsheet(TEST_CHARSHEET_UUID).then((c) => { - setCharsheet(c) - }); - }, - [client, setCharsheet] - ); - - switch (state.type) { - case "Loading": return
; - case "Ready": { - const backgroundColor = state.tabletop.backgroundColor; - const tabletopColorStyle = `rgb(${backgroundColor.red}, ${backgroundColor.green}, ${backgroundColor.blue})`; - const backgroundUrl = state.tabletop.backgroundImage ? client.imageUrl(state.tabletop.backgroundImage) : undefined; - - return (
-
-
- {charsheet ? :
}
-
) - } - default: { - assertNever(state); - return
; - } - } -} - diff --git a/visions/ui/src/views/index.ts b/visions/ui/src/views/index.ts new file mode 100644 index 0000000..0719403 --- /dev/null +++ b/visions/ui/src/views/index.ts @@ -0,0 +1,3 @@ +import { MainView } from './Main/Main' + +export { MainView } -- 2.47.1 From f59c3544b43312746781e4454b0fa756df256b74 Mon Sep 17 00:00:00 2001 From: Savanni D'Gerinel Date: Fri, 3 Jan 2025 16:50:56 -0500 Subject: [PATCH 23/23] Set up the landing page, which shows the user their profile --- visions/server/src/routes.rs | 20 ++++++++++++----- visions/ui/src/client.ts | 22 +++++++++++++++++-- visions/ui/src/components/Profile/Profile.tsx | 16 ++++++++------ visions/ui/src/components/index.ts | 5 +++-- visions/ui/src/views/Main/Main.tsx | 21 ++++++++++++++---- 5 files changed, 64 insertions(+), 20 deletions(-) diff --git a/visions/server/src/routes.rs b/visions/server/src/routes.rs index bc51f23..7b23280 100644 --- a/visions/server/src/routes.rs +++ b/visions/server/src/routes.rs @@ -1,6 +1,6 @@ use axum::{ extract::Path, - http::{header::CONTENT_TYPE, HeaderMap, Method, StatusCode}, + http::{header::{AUTHORIZATION, CONTENT_TYPE}, HeaderMap, Method}, routing::{get, post, put}, Json, Router, }; @@ -8,7 +8,7 @@ use tower_http::cors::{Any, CorsLayer}; use crate::{ core::Core, - database::{SessionId, UserId}, + database::UserId, handlers::{ check_password, create_game, create_user, get_user, healthcheck, set_password, wrap_handler, AuthRequest, CreateGameRequest, CreateUserRequest, SetPasswordRequest, @@ -34,9 +34,13 @@ pub fn routes(core: Core) -> Router { post({ let core = core.clone(); move |req: Json| wrap_handler(|| check_password(core, req)) - }).layer( - CorsLayer::new().allow_methods([Method::POST]).allow_headers([CONTENT_TYPE]).allow_origin(Any), - ), + }) + .layer( + CorsLayer::new() + .allow_methods([Method::POST]) + .allow_headers([CONTENT_TYPE]) + .allow_origin(Any), + ), ) .route( // By default, just get the self user. @@ -45,6 +49,12 @@ pub fn routes(core: Core) -> Router { let core = core.clone(); move |headers: HeaderMap| wrap_handler(|| get_user(core, headers, None)) }) + .layer( + CorsLayer::new() + .allow_methods([Method::GET]) + .allow_headers([AUTHORIZATION]) + .allow_origin(Any), + ) .put({ let core = core.clone(); move |headers: HeaderMap, req: Json| { diff --git a/visions/ui/src/client.ts b/visions/ui/src/client.ts index 5521429..af8815c 100644 --- a/visions/ui/src/client.ts +++ b/visions/ui/src/client.ts @@ -1,4 +1,4 @@ -import { SessionId } from "visions-types"; +import { SessionId, UserId, UserProfile } from "visions-types"; export type PlayingField = { backgroundImage: string; @@ -70,11 +70,29 @@ export class Client { async auth(username: string, password: string): Promise { const url = new URL(this.base); url.pathname = `/api/v1/auth` - const response = await fetch(url, { method: 'POST', headers: [['Content-Type', 'application/json']], body: JSON.stringify({ 'username': username, 'password': password }) }); + const response = await fetch(url, { + method: 'POST', + headers: [['Content-Type', 'application/json']], + body: JSON.stringify({ 'username': username, 'password': password }) + }); const session_id: SessionId = await response.json(); return session_id; } + async profile(sessionId: SessionId, userId: UserId | undefined): Promise { + const url = new URL(this.base); + if (userId) { + url.pathname = `/api/v1/user${userId}` + } else { + url.pathname = `/api/v1/user` + } + const response = await fetch(url, { + method: 'GET', + headers: [['Authorization', `Bearer ${sessionId}`]], + }); + return await response.json() + } + async health() { const url = new URL(this.base); url.pathname = `/api/v1/health`; diff --git a/visions/ui/src/components/Profile/Profile.tsx b/visions/ui/src/components/Profile/Profile.tsx index 9c38a15..dde4545 100644 --- a/visions/ui/src/components/Profile/Profile.tsx +++ b/visions/ui/src/components/Profile/Profile.tsx @@ -1,10 +1,12 @@ -import React from 'react'; -import { Client } from '../../client'; +import { UserProfile } from 'visions-types'; -interface ProfileProps { - client: Client -} +export const ProfileElement = ({ name, games, is_admin }: UserProfile) => { + const adminNote = is_admin ?
Note: this user is an admin
: <>; -export const ProfileElement = ({ client }: ProfileProps) => { - return
+ return ( +
+

{name}

+
Games: {games.map((game) => <>{game.game_name} ({game.game_type})).join(', ')}
+ {adminNote} +
) } diff --git a/visions/ui/src/components/index.ts b/visions/ui/src/components/index.ts index 64551ca..95a33e5 100644 --- a/visions/ui/src/components/index.ts +++ b/visions/ui/src/components/index.ts @@ -1,5 +1,6 @@ +import { ProfileElement } from './Profile/Profile' +import { SimpleGuage } from './Guages/SimpleGuage' import { ThumbnailElement } from './Thumbnail/Thumbnail' import { TabletopElement } from './Tabletop/Tabletop' -import { SimpleGuage } from './Guages/SimpleGuage' -export default { ThumbnailElement, TabletopElement, SimpleGuage } +export { ProfileElement, ThumbnailElement, TabletopElement, SimpleGuage } diff --git a/visions/ui/src/views/Main/Main.tsx b/visions/ui/src/views/Main/Main.tsx index 27ded81..89d69ec 100644 --- a/visions/ui/src/views/Main/Main.tsx +++ b/visions/ui/src/views/Main/Main.tsx @@ -1,5 +1,7 @@ -import React, { useContext } from 'react'; +import { useContext, useEffect, useState } from 'react'; +import { UserProfile } from 'visions-types'; import { Client } from '../../client'; +import { ProfileElement } from '../../components'; import { getSessionId, StateContext, StateProvider } from '../../providers/StateProvider/StateProvider'; interface MainProps { @@ -7,10 +9,21 @@ interface MainProps { } export const MainView = ({ client }: MainProps) => { - const [state, manager] = useContext(StateContext) + const [state, _manager] = useContext(StateContext) + const [profile, setProfile] = useState(undefined) - const sessionId = getSessionId(state); + const sessionId = getSessionId(state) + useEffect(() => { + if (sessionId) { + client.profile(sessionId, undefined).then((profile) => setProfile(profile)) + } + }, [sessionId, client]) - return
Profile: {sessionId}
+ return ( +
+
Session ID: {sessionId}
+ {profile && } +
+ ) } -- 2.47.1