From 71fa8482eb68de12a9d46221bd30cd8071459390 Mon Sep 17 00:00:00 2001 From: Stefano Pigozzi Date: Fri, 11 Nov 2022 15:44:22 +0000 Subject: [PATCH] Create score submission route --- Cargo.lock | 1 + Cargo.toml | 1 + src/main.rs | 9 ++-- src/outcome.rs | 4 +- src/routes/board.rs | 111 +++++++++++++++++++++----------------------- src/routes/home.rs | 6 +-- src/routes/mod.rs | 5 +- src/routes/score.rs | 106 ++++++++++++++++++++++++++++++++++++++++++ src/types.rs | 50 ++++++++++++++++++++ 9 files changed, 224 insertions(+), 69 deletions(-) create mode 100644 src/routes/score.rs create mode 100644 src/types.rs diff --git a/Cargo.lock b/Cargo.lock index ecbaed0..9e890e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -152,6 +152,7 @@ dependencies = [ "r2d2", "rand", "redis", + "regex", "serde", "serde_json", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 1783864..04463d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,3 +16,4 @@ serde_json = { version = "1.0.87" } log = { version = "0.4.17" } pretty_env_logger = { version = "0.4.0" } rand = { version = "0.8.5" } +regex = { version = "1.7.0" } diff --git a/src/main.rs b/src/main.rs index 5c2fd79..c2fa923 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,10 @@ -mod config; +pub(crate) mod config; +pub(crate) mod outcome; +pub mod types; mod routes; -mod outcome; -use axum::routing::{get, post}; + +use axum::routing::{get, post, put}; #[tokio::main] @@ -20,6 +22,7 @@ async fn main() { let webapp = axum::Router::new() .route("/", get(routes::home::route_home_get)) .route("/board/", post(routes::board::route_board_post)) + .route("/score/", put(routes::score::route_score_put)) .layer(axum::Extension(rclient)); log::info!("Starting Axum server..."); diff --git a/src/outcome.rs b/src/outcome.rs index dcb9822..8eb33ed 100644 --- a/src/outcome.rs +++ b/src/outcome.rs @@ -33,7 +33,7 @@ macro_rules! req_success { pub(crate) use req_success; /// The server could not connect to Redis. -pub(crate) fn redis_conn_failed(_err: redis::RedisError) -> RequestTuple { +pub(crate) fn redis_conn_failed() -> RequestTuple { ( StatusCode::GATEWAY_TIMEOUT, req_error!("Could not connect to Redis") @@ -41,7 +41,7 @@ pub(crate) fn redis_conn_failed(_err: redis::RedisError) -> RequestTuple { } /// The execution of a command in Redis failed. -pub(crate) fn redis_cmd_failed(_err: redis::RedisError) -> RequestTuple { +pub(crate) fn redis_cmd_failed() -> RequestTuple { ( StatusCode::BAD_GATEWAY, req_error!("Could not execute Redis command") diff --git a/src/routes/board.rs b/src/routes/board.rs index c80d3cc..5412de3 100644 --- a/src/routes/board.rs +++ b/src/routes/board.rs @@ -6,123 +6,116 @@ use redis::AsyncCommands; use serde::Serialize; use serde::Deserialize; use crate::outcome; +use crate::types::SortingOrder; -#[derive(Copy, Clone, Debug, Serialize, Deserialize)] -pub enum Order { - /// The greater the score, the worse it is. - #[serde(rename = "ASC")] - Ascending, - /// The greater the score, the better it is. - #[serde(rename = "DSC")] - Descending, -} - -impl From for String { - fn from(ord: Order) -> Self { - match ord { - Order::Ascending => "ASC".to_string(), - Order::Descending => "DSC".to_string(), - } - } -} - - -/// Expected input data for `POST /board/`. +/// Expected input data for [`POST /board/`](route_board_post). #[derive(Clone, Debug, Serialize, Deserialize)] -pub struct RouteBoardPostInput { +pub(crate) struct RouteBoardPostInput { /// The name of the board to create. - name: String, - /// The [`Order`] of the scores in the board to create. - order: Order, + pub(crate) name: String, + /// The [`SortingOrder`] of the scores in the board to create. + pub(crate) order: SortingOrder, } - -/// Expected output data for `POST /board/`. +/// Expected output data for [`POST /board/`](route_board_post). #[derive(Clone, Debug, Serialize, Deserialize)] -pub struct RouteBoardPostOutput { - /// The name of the created board. - name: String, - /// The [`Order`] of the scores in the created board. - order: Order, +pub(crate) struct RouteBoardPostOutput { /// The token to use to submit scores to the board. - token: String, + /// + /// ### It's a secret + /// + /// Be careful to keep this visible only to the board admins! + pub(crate) token: String, } - +/// Ensure that there is nothing stored at a certain Redis key. async fn ensure_key_is_empty(rconn: &mut redis::aio::Connection, key: &str) -> Result<(), outcome::RequestTuple> { log::trace!("Ensuring that the Redis key `{key}` does not contain anything..."); redis::cmd("TYPE").arg(&key) .query_async::(rconn).await - .map_err(outcome::redis_cmd_failed)? + .map_err(|_| outcome::redis_cmd_failed())? .eq("none") .then_some(()) .ok_or((StatusCode::CONFLICT, outcome::req_error!("Board already exists"))) } - +/// Alphabet for base-62 encoding. const TOKEN_CHARS: &[char; 62] = &[ '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z' ]; +/// Generate a cryptographically secure Base-62 token via [`rand::rngs::OsRng`]. +fn generate_secure_token() -> Result { + log::trace!("Generating a board token..."); + + let mut rng = rand::rngs::OsRng::default(); + let mut token: [u32; 16] = [0; 16]; + + rand::Fill::try_fill(&mut token, &mut rng) + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, outcome::req_error!("Failed to generate a secure board token.")))?; + + Ok( + // FIXME: only works on platforms where usize >= 32-bit? + token.iter().map(|e| TOKEN_CHARS.get(*e as usize % 62).expect("randomly generated value to be a valid index")) + .collect::() + ) +} /// Handler for `POST /board/`. /// -/// Creates a new board, storing the details on Redis. +/// Creates a new board, storing the details on [Redis]. /// /// Will refuse to overwrite an already existing board. -pub async fn route_board_post( +/// +/// Be aware that once created, boards cannot be deleted, if not manually via `redis-cli`. +/// +/// If successful, returns [`StatusCode::CREATED`]. +pub(crate) async fn route_board_post( Extension(rclient): Extension, Json(RouteBoardPostInput {name, order}): Json, ) -> outcome::RequestResult { - log::trace!("Connecting to Redis..."); - let mut rconn = rclient.get_async_connection().await - .map_err(outcome::redis_conn_failed)?; - log::trace!("Determining the Redis key names..."); let order_key = format!("board:{name}:order"); let token_key = format!("board:{name}:token"); let scores_key = format!("board:{name}:scores"); + log::trace!("Connecting to Redis..."); + let mut rconn = rclient.get_async_connection().await + .map_err(|_| outcome::redis_conn_failed())?; + log::trace!("Ensuring a board does not already exist..."); ensure_key_is_empty(&mut rconn, &order_key).await?; ensure_key_is_empty(&mut rconn, &token_key).await?; ensure_key_is_empty(&mut rconn, &scores_key).await?; - log::info!("Creating board: {name:?}"); + log::debug!("Creating board: {name:?}"); + + let token = generate_secure_token()?; + log::trace!("Board token is: {token:?}"); log::trace!("Board order is: {order:?}"); - - log::trace!("Generating a board token..."); - let mut rng = rand::rngs::OsRng::default(); - let mut token: [u32; 16] = [0; 16]; - rand::Fill::try_fill(&mut token, &mut rng) - .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, outcome::req_error!("Failed to generate a secure board token.")))?; - // FIXME: only works on platforms where usize >= 32-bit? - let token: String = token.iter().map(|e| TOKEN_CHARS.get(*e as usize % 62).expect("randomly generated value to be a valid index")) - .collect::(); - log::trace!("Board token is: {token:?}"); log::trace!("Starting Redis transaction..."); redis::cmd("MULTI").query_async(&mut rconn).await - .map_err(outcome::redis_cmd_failed)?; + .map_err(|_| outcome::redis_cmd_failed())?; log::trace!("Setting board order..."); rconn.set(&order_key, String::from(order)).await - .map_err(outcome::redis_cmd_failed)?; + .map_err(|_| outcome::redis_cmd_failed())?; log::trace!("Setting board token..."); rconn.set(&token_key, &token).await - .map_err(outcome::redis_cmd_failed)?; + .map_err(|_| outcome::redis_cmd_failed())?; log::trace!("Executing Redis transaction..."); redis::cmd("EXEC").query_async(&mut rconn).await - .map_err(outcome::redis_cmd_failed)?; + .map_err(|_| outcome::redis_cmd_failed())?; Ok(( - StatusCode::OK, - Json(serde_json::to_value(RouteBoardPostOutput {name, order, token}).expect("to be able to serialize RouteBoardPostOutput")) + StatusCode::CREATED, + Json(serde_json::to_value(RouteBoardPostOutput {token}).expect("to be able to serialize RouteBoardPostOutput")) )) } diff --git a/src/routes/home.rs b/src/routes/home.rs index 0aa27f9..262a45c 100644 --- a/src/routes/home.rs +++ b/src/routes/home.rs @@ -7,18 +7,18 @@ use crate::outcome; /// Handler for `GET /`. /// /// Pings Redis to verify that everything is working correctly. -pub async fn route_home_get( +pub(crate) async fn route_home_get( Extension(rclient): Extension ) -> outcome::RequestResult { log::trace!("Connecting to Redis..."); let mut rconn = rclient.get_async_connection().await - .map_err(outcome::redis_conn_failed)?; + .map_err(|_| outcome::redis_conn_failed())?; log::trace!("Sending PING and expecting PONG..."); redis::cmd("PING") .query_async::(&mut rconn).await - .map_err(outcome::redis_cmd_failed)? + .map_err(|_| outcome::redis_cmd_failed())? .eq("PONG") .then(outcome::success_null) .ok_or_else(outcome::redis_unexpected_behaviour) diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 2f35ffc..9c49403 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,2 +1,3 @@ -pub mod home; -pub mod board; +pub(crate) mod home; +pub(crate) mod board; +pub(crate) mod score; \ No newline at end of file diff --git a/src/routes/score.rs b/src/routes/score.rs new file mode 100644 index 0000000..e14f3ac --- /dev/null +++ b/src/routes/score.rs @@ -0,0 +1,106 @@ +//! Module defining routes for `/score/`. + +use axum::http::StatusCode; +use axum::http::header::HeaderMap; +use axum::extract::{Extension, Json}; +use redis::AsyncCommands; +use regex::Regex; +use serde::Serialize; +use serde::Deserialize; +use crate::outcome; +use crate::types::SortingOrder; + + +/// Expected input data for `PUT /score/`. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub(crate) struct RouteScorePutInput { + /// The board to submit the score to. + pub board: String, + /// The score to submit. + pub score: f64, + /// The name of the player submitting the score. + pub player: String, +} + + +/// Expected output data for `PUT /score/`. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub(crate) struct RouteScorePutOutput { + /// The best score of the player. + score: f64, +} + + +/// Handler for `PUT /score/`. +pub(crate) async fn route_score_put( + // Request headers + headers: HeaderMap, + // Request body + Json(RouteScorePutInput {board, score, player}): Json, + // Redis client + Extension(rclient): Extension, +) -> outcome::RequestResult { + lazy_static::lazy_static! { + static ref AUTH_HEADER_REGEX: Regex = Regex::new(r#"X-Board (\S+)"#) + .expect("AUTH_HEADER_REGEX to be valid"); + } + + log::trace!("Checking the Authorization header..."); + let token = headers.get("Authorization") + .ok_or_else(|| (StatusCode::UNAUTHORIZED, outcome::req_error!("Missing Authorization header")))?; + + let token = token.to_str() + .map_err(|_| (StatusCode::BAD_REQUEST, outcome::req_error!("Malformed Authorization header")))?; + + let token = AUTH_HEADER_REGEX.captures(token) + .ok_or_else(|| (StatusCode::BAD_REQUEST, outcome::req_error!("Malformed Authorization header")))?; + + let token = token.get(1) + .ok_or_else(|| (StatusCode::UNAUTHORIZED, outcome::req_error!("Invalid Authorization header")))?; + + let token = token.as_str(); + log::trace!("Received token: {token:?}"); + + log::trace!("Determining the Redis key names..."); + let order_key = format!("board:{board}:order"); + let token_key = format!("board:{board}:token"); + let scores_key = format!("board:{board}:scores"); + + log::trace!("Connecting to Redis..."); + let mut rconn = rclient.get_async_connection().await + .map_err(|_| outcome::redis_conn_failed())?; + + log::trace!("Checking if the token exists and matches..."); + let btoken = rconn.get::<&str, String>(&token_key).await + .map_err(|_| outcome::redis_cmd_failed())?; + + if btoken.is_empty() { + log::trace!("Token is not set, board does not exist..."); + return Err((StatusCode::NOT_FOUND, outcome::req_error!("No such board"))) + } + + if btoken != token { + log::trace!("Token does not match, forbidding..."); + return Err((StatusCode::FORBIDDEN, outcome::req_error!("Invalid board token"))) + } + + log::trace!("Determining score insertion mode..."); + let order = rconn.get::<&str, String>(&order_key).await + .map_err(|_| outcome::redis_cmd_failed())?; + let order = SortingOrder::try_from(order) + .map_err(|_| outcome::redis_unexpected_behaviour())?; + + log::trace!("Inserting score: {score:?}"); + redis::cmd("ZADD").arg(&scores_key).arg(order.zadd_mode()).arg(&score).arg(&player).query_async(&mut rconn).await + .map_err(|_| outcome::redis_cmd_failed())?; + + log::trace!("Getting the new score..."); + let nscore = rconn.zscore::<&str, &str, f64>(&scores_key, &player).await + .map_err(|_| outcome::redis_cmd_failed())?; + log::trace!("Received score: {nscore:?}"); + + Ok(( + StatusCode::OK, + Json(serde_json::to_value(RouteScorePutOutput {score: nscore}).expect("to be able to serialize RouteScorePutOutput")) + )) +} \ No newline at end of file diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 0000000..9000460 --- /dev/null +++ b/src/types.rs @@ -0,0 +1,50 @@ +//! Module containing various types recurring in multiple modules of the crate. + + +use serde::Serialize; +use serde::Deserialize; + + +/// A sorting order for scores. +#[derive(Copy, Clone, Debug, Serialize, Deserialize)] +pub enum SortingOrder { + /// The greater the score, the worse it is. + #[serde(rename = "ASC")] + Ascending, + /// The greater the score, the better it is. + #[serde(rename = "DSC")] + Descending, +} + +impl SortingOrder { + /// Get the mode to use when using the [Redis] command [`ZADD`](https://redis.io/commands/zadd/). + pub fn zadd_mode(&self) -> String { + match self { + Self::Ascending => "LT".to_string(), + Self::Descending => "GT".to_string(), + } + } +} + +/// How the [`SortingOrder`] is stored in [Redis]. +impl From for String { + fn from(ord: SortingOrder) -> Self { + match ord { + SortingOrder::Ascending => "ASC".to_string(), + SortingOrder::Descending => "DSC".to_string(), + } + } +} + +/// How the [`SortingOrder`] is retrieved from [Redis]. +impl TryFrom for SortingOrder { + type Error = (); + + fn try_from(val: String) -> Result { + match val.as_str() { + "ASC" => Ok(Self::Ascending), + "DSC" => Ok(Self::Descending), + _ => Err(()) + } + } +}